1use std::collections::BTreeSet;
2
3use crate::digest::{Digest, hj};
4use crate::json::{Scratch, Sink, Value, canonical, canonical_length};
5use crate::model::Adapter;
6
7pub const ENGINE_CONTRACT: &str = "amiss/scanner-v0";
8
9pub const MACHINE_JSON_BYTES: u64 = 67_108_864;
12
13pub const FATAL_SCRATCH_BYTES: usize = 65_536;
17
18pub struct FatalSerializer {
24 staging: Vec<u8>,
25 scratch: Scratch,
26}
27
28impl FatalSerializer {
29 #[must_use]
31 pub fn new() -> Self {
32 Self {
33 staging: Vec::with_capacity(FATAL_SCRATCH_BYTES),
34 scratch: Scratch::new(),
35 }
36 }
37
38 pub fn emit(&mut self, envelope: &Value, out: &mut dyn std::io::Write) -> std::io::Result<u64> {
46 self.staging.clear();
47 let mut sink = StagedSink {
48 staging: &mut self.staging,
49 out,
50 written: 0,
51 error: None,
52 };
53 self.scratch.stream(envelope, &mut sink);
54 sink.write("\n");
55 let written = sink.flush();
56 self.staging.clear();
57 written
58 }
59
60 #[must_use]
64 pub fn wire_bytes(&mut self, envelope: &Value) -> Vec<u8> {
65 let exact = canonical_length(envelope).saturating_add(1);
66 let mut wire = Vec::with_capacity(usize::try_from(exact).unwrap_or(0));
67 if self.emit(envelope, &mut wire).is_err() {
68 wire.clear();
69 }
70 wire
71 }
72}
73
74impl Default for FatalSerializer {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80struct StagedSink<'a> {
81 staging: &'a mut Vec<u8>,
82 out: &'a mut dyn std::io::Write,
83 written: u64,
84 error: Option<std::io::Error>,
85}
86
87impl StagedSink<'_> {
88 fn drain(&mut self) {
89 if self.error.is_none() {
90 match self.out.write_all(self.staging) {
91 Ok(()) => {
92 self.written = self
93 .written
94 .saturating_add(u64::try_from(self.staging.len()).unwrap_or(u64::MAX));
95 }
96 Err(defect) => self.error = Some(defect),
97 }
98 }
99 self.staging.clear();
100 }
101
102 fn flush(&mut self) -> std::io::Result<u64> {
103 self.drain();
104 match self.error.take() {
105 Some(defect) => Err(defect),
106 None => Ok(self.written),
107 }
108 }
109}
110
111impl Sink for StagedSink<'_> {
112 fn write(&mut self, piece: &str) {
113 if piece.len() >= FATAL_SCRATCH_BYTES {
114 self.drain();
115 if self.error.is_none() {
116 match self.out.write_all(piece.as_bytes()) {
117 Ok(()) => {
118 self.written = self
119 .written
120 .saturating_add(u64::try_from(piece.len()).unwrap_or(u64::MAX));
121 }
122 Err(defect) => self.error = Some(defect),
123 }
124 }
125 return;
126 }
127 if self.staging.len().saturating_add(piece.len()) > FATAL_SCRATCH_BYTES {
128 self.drain();
129 }
130 self.staging.extend_from_slice(piece.as_bytes());
131 }
132}
133pub const ENGINE_DOMAIN: &str = "amiss/scanner-engine/v1";
134pub const ENVELOPE_SCHEMA: &str = "amiss/scanner-report-envelope/v3";
135pub const PAYLOAD_SCHEMA: &str = "amiss/scanner-report-payload/v3";
136pub const ADAPTER_CONTRACT_SCHEMA: &str = "amiss/scanner-adapter-contract/v1";
137pub const BUILT_IN_POLICY_VERSION: &str = "scanner-policy-defaults-v1";
138
139#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
141pub enum AnalysisErrorCode {
142 InvalidInvocation,
143 InvalidEvent,
144 InvalidProfile,
145 RequestUnreadable,
146 ConfigurationInvalid,
147 DuplicateJsonKey,
148 InvalidUtf8,
149 InvalidJson,
150 UnknownSchema,
151 UnknownField,
152 NoncanonicalArray,
153 DigestMismatch,
154 ControlBindingMismatch,
155 ExceptionOverlap,
156 UnsupportedCapability,
157 GitRepositoryUnavailable,
158 GitObjectMissing,
159 GitObjectWrongKind,
160 GitObjectUnreadable,
161 GitIndexInvalid,
162 GitIndexUnmerged,
163 GitIntentToAdd,
164 GitSnapshotChanged,
165 UnrepresentablePath,
166 DocumentInvalid,
167 ParserError,
168 ParserPanic,
169 InvalidSourceSpan,
170 ResolutionError,
171 ResourceLimitExceeded,
172 OutputLimitExceeded,
173 TooManyErrors,
174 ReportConstructionFailed,
175 SandboxViolation,
176 TrustedTimeInvalid,
177 InternalError,
178}
179
180impl AnalysisErrorCode {
181 #[must_use]
182 pub const fn as_str(self) -> &'static str {
183 match self {
184 Self::InvalidInvocation => "INVALID_INVOCATION",
185 Self::InvalidEvent => "INVALID_EVENT",
186 Self::InvalidProfile => "INVALID_PROFILE",
187 Self::RequestUnreadable => "REQUEST_UNREADABLE",
188 Self::ConfigurationInvalid => "CONFIGURATION_INVALID",
189 Self::DuplicateJsonKey => "DUPLICATE_JSON_KEY",
190 Self::InvalidUtf8 => "INVALID_UTF8",
191 Self::InvalidJson => "INVALID_JSON",
192 Self::UnknownSchema => "UNKNOWN_SCHEMA",
193 Self::UnknownField => "UNKNOWN_FIELD",
194 Self::NoncanonicalArray => "NONCANONICAL_ARRAY",
195 Self::DigestMismatch => "DIGEST_MISMATCH",
196 Self::ControlBindingMismatch => "CONTROL_BINDING_MISMATCH",
197 Self::ExceptionOverlap => "EXCEPTION_OVERLAP",
198 Self::UnsupportedCapability => "UNSUPPORTED_CAPABILITY",
199 Self::GitRepositoryUnavailable => "GIT_REPOSITORY_UNAVAILABLE",
200 Self::GitObjectMissing => "GIT_OBJECT_MISSING",
201 Self::GitObjectWrongKind => "GIT_OBJECT_WRONG_KIND",
202 Self::GitObjectUnreadable => "GIT_OBJECT_UNREADABLE",
203 Self::GitIndexInvalid => "GIT_INDEX_INVALID",
204 Self::GitIndexUnmerged => "GIT_INDEX_UNMERGED",
205 Self::GitIntentToAdd => "GIT_INTENT_TO_ADD",
206 Self::GitSnapshotChanged => "GIT_SNAPSHOT_CHANGED",
207 Self::UnrepresentablePath => "UNREPRESENTABLE_PATH",
208 Self::DocumentInvalid => "DOCUMENT_INVALID",
209 Self::ParserError => "PARSER_ERROR",
210 Self::ParserPanic => "PARSER_PANIC",
211 Self::InvalidSourceSpan => "INVALID_SOURCE_SPAN",
212 Self::ResolutionError => "RESOLUTION_ERROR",
213 Self::ResourceLimitExceeded => "RESOURCE_LIMIT_EXCEEDED",
214 Self::OutputLimitExceeded => "OUTPUT_LIMIT_EXCEEDED",
215 Self::TooManyErrors => "TOO_MANY_ERRORS",
216 Self::ReportConstructionFailed => "REPORT_CONSTRUCTION_FAILED",
217 Self::SandboxViolation => "SANDBOX_VIOLATION",
218 Self::TrustedTimeInvalid => "TRUSTED_TIME_INVALID",
219 Self::InternalError => "INTERNAL_ERROR",
220 }
221 }
222
223 #[must_use]
226 pub const fn fixed_phase(self) -> Option<&'static str> {
227 match self {
228 Self::InvalidInvocation
229 | Self::InvalidEvent
230 | Self::InvalidProfile
231 | Self::RequestUnreadable => Some("invocation"),
232 Self::ConfigurationInvalid
233 | Self::DuplicateJsonKey
234 | Self::InvalidUtf8
235 | Self::InvalidJson
236 | Self::UnknownSchema
237 | Self::UnknownField
238 | Self::NoncanonicalArray
239 | Self::DigestMismatch
240 | Self::ControlBindingMismatch
241 | Self::ExceptionOverlap
242 | Self::TrustedTimeInvalid => Some("configuration"),
243 Self::GitRepositoryUnavailable
244 | Self::GitObjectMissing
245 | Self::GitObjectWrongKind
246 | Self::GitObjectUnreadable
247 | Self::GitIndexInvalid
248 | Self::GitIndexUnmerged
249 | Self::GitIntentToAdd
250 | Self::GitSnapshotChanged
251 | Self::UnrepresentablePath => Some("git"),
252 Self::DocumentInvalid
253 | Self::ParserError
254 | Self::ParserPanic
255 | Self::InvalidSourceSpan => Some("parse"),
256 Self::ResolutionError => Some("resolution"),
257 Self::UnsupportedCapability => Some("policy"),
258 Self::OutputLimitExceeded | Self::ReportConstructionFailed => Some("output"),
259 Self::SandboxViolation | Self::TooManyErrors | Self::InternalError => Some("internal"),
260 Self::ResourceLimitExceeded => None,
261 }
262 }
263
264 const fn evaluation_reason(self) -> Option<&'static str> {
265 match self {
266 Self::InvalidInvocation => Some("invalid-invocation"),
267 Self::InvalidEvent => Some("invalid-event"),
268 Self::InvalidProfile => Some("invalid-profile"),
269 Self::RequestUnreadable => Some("request-unreadable"),
270 Self::ConfigurationInvalid
271 | Self::DuplicateJsonKey
272 | Self::InvalidUtf8
273 | Self::InvalidJson
274 | Self::UnknownSchema
275 | Self::UnknownField
276 | Self::NoncanonicalArray
277 | Self::DigestMismatch
278 | Self::ControlBindingMismatch
279 | Self::ExceptionOverlap
280 | Self::UnsupportedCapability
281 | Self::GitRepositoryUnavailable
282 | Self::GitObjectMissing
283 | Self::GitObjectWrongKind
284 | Self::GitObjectUnreadable
285 | Self::GitIndexInvalid
286 | Self::GitIndexUnmerged
287 | Self::GitIntentToAdd
288 | Self::GitSnapshotChanged
289 | Self::UnrepresentablePath
290 | Self::DocumentInvalid
291 | Self::ParserError
292 | Self::ParserPanic
293 | Self::InvalidSourceSpan
294 | Self::ResolutionError
295 | Self::ResourceLimitExceeded
296 | Self::OutputLimitExceeded
297 | Self::TooManyErrors
298 | Self::ReportConstructionFailed
299 | Self::SandboxViolation
300 | Self::TrustedTimeInvalid
301 | Self::InternalError => None,
302 }
303 }
304}
305
306#[derive(Clone, Debug, PartialEq, Eq)]
307pub struct EngineProvenance {
308 pub version: String,
309 pub digest: Digest,
310}
311
312#[must_use]
318pub fn invocation_failure_wire(
319 engine: &EngineProvenance,
320 codes: &BTreeSet<AnalysisErrorCode>,
321) -> Option<Vec<u8>> {
322 unavailable_evaluation_wire(engine, codes, None, None)
323}
324
325#[must_use]
328pub fn invocation_failure_envelope(
329 engine: &EngineProvenance,
330 codes: &BTreeSet<AnalysisErrorCode>,
331) -> Option<Value> {
332 unavailable_evaluation_envelope(engine, codes, None, None)
333}
334
335#[must_use]
342pub fn unavailable_evaluation_wire(
343 engine: &EngineProvenance,
344 codes: &BTreeSet<AnalysisErrorCode>,
345 evaluation_request_digest: Option<Digest>,
346 controls_request_digest: Option<Digest>,
347) -> Option<Vec<u8>> {
348 let envelope = unavailable_evaluation_envelope(
349 engine,
350 codes,
351 evaluation_request_digest,
352 controls_request_digest,
353 )?;
354 let mut wire = canonical(&envelope);
355 wire.push(b'\n');
356 Some(wire)
357}
358
359#[must_use]
362pub fn unavailable_evaluation_envelope(
363 engine: &EngineProvenance,
364 codes: &BTreeSet<AnalysisErrorCode>,
365 evaluation_request_digest: Option<Digest>,
366 controls_request_digest: Option<Digest>,
367) -> Option<Value> {
368 if codes.is_empty() {
369 return None;
370 }
371 let mut reasons = Vec::new();
372 for code in codes {
373 reasons.push(Value::String(code.evaluation_reason()?.to_owned()));
374 }
375
376 let mut errors: Vec<(AnalysisErrorCode, &'static str)> = Vec::new();
377 for code in codes {
378 errors.push((*code, code.fixed_phase()?));
379 }
380 errors.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
381 let error_rows: Vec<Value> = errors
382 .iter()
383 .map(|(code, phase)| {
384 error_row(
385 &ErrorDetail {
386 code: *code,
387 path: None,
388 path_bytes: None,
389 resource: None,
390 },
391 phase,
392 )
393 })
394 .collect();
395 let error_count = i64::try_from(error_rows.len()).ok()?;
396
397 let payload = object(vec![
398 ("schema", string(PAYLOAD_SCHEMA)),
399 ("compatibility", string("experimental")),
400 ("engine", engine_block(engine)),
401 (
402 "evaluation",
403 object(vec![
404 ("status", string("unavailable")),
405 (
406 "request_digest",
407 evaluation_request_digest
408 .map_or(Value::Null, |digest| string(&digest.to_string())),
409 ),
410 ("reasons", Value::Array(reasons)),
411 ]),
412 ),
413 (
414 "controls",
415 object(vec![
416 ("status", string("unavailable")),
417 (
418 "request_digest",
419 controls_request_digest
420 .map_or(Value::Null, |digest| string(&digest.to_string())),
421 ),
422 ("reasons", Value::Array(vec![string("not-parsed")])),
423 ]),
424 ),
425 (
426 "result",
427 object(vec![
428 ("complete", Value::Bool(false)),
429 ("status", string("incomplete")),
430 ("exit_code", Value::Integer(2)),
431 ("finding_count", Value::Integer(0)),
432 ("error_count", Value::Integer(error_count)),
433 ]),
434 ),
435 ("summary", zero_summary()),
436 ("documents", Value::Array(Vec::new())),
437 ("observations", Value::Array(Vec::new())),
438 ("findings", Value::Array(Vec::new())),
439 ("errors", Value::Array(error_rows)),
440 ]);
441
442 let payload_digest = hj(PAYLOAD_SCHEMA, &payload);
443 Some(object(vec![
444 ("schema", string(ENVELOPE_SCHEMA)),
445 ("payload", payload),
446 ("payload_digest", string(&payload_digest.to_string())),
447 ]))
448}
449
450#[must_use]
453pub fn adapter_contract(engine: &EngineProvenance, adapter: Adapter) -> (Value, Digest) {
454 let descriptor = object(vec![
455 ("schema", string(ADAPTER_CONTRACT_SCHEMA)),
456 ("adapter_id", string(adapter.adapter_id())),
457 ("parser_name", string(adapter.parser_name())),
458 ("parser_version", string(&engine.version)),
459 ("grammar_profile", string(adapter.grammar_profile())),
460 (
461 "frontmatter_contract",
462 string(adapter.frontmatter_contract()),
463 ),
464 ("source_projection", string(adapter.source_projection())),
465 ("structural_address", string(adapter.structural_address())),
466 ]);
467 let digest = hj(ADAPTER_CONTRACT_SCHEMA, &descriptor);
468 (descriptor, digest)
469}
470
471#[must_use]
474pub fn engine_block(engine: &EngineProvenance) -> Value {
475 let adapter_rows: Vec<Value> = Adapter::ALL
476 .iter()
477 .map(|adapter| {
478 let (descriptor, digest) = adapter_contract(engine, *adapter);
479 object(vec![
480 ("adapter_id", string(adapter.adapter_id())),
481 ("contract_descriptor", descriptor),
482 ("contract_digest", string(&digest.to_string())),
483 ])
484 })
485 .collect();
486 object(vec![
487 ("engine_contract", string(ENGINE_CONTRACT)),
488 ("engine_version", string(&engine.version)),
489 ("engine_digest", string(&engine.digest.to_string())),
490 ("action_provenance", object(vec![("kind", string("local"))])),
491 ("built_in_policy_version", string(BUILT_IN_POLICY_VERSION)),
492 ("adapters", Value::Array(adapter_rows)),
493 ])
494}
495
496fn zero_summary() -> Value {
497 let documents = [
498 "discovered",
499 "outside_document_set",
500 "scanned",
501 "unsupported",
502 "excluded_builtin",
503 "unlinked",
504 "frontmatter_documents",
505 "opaque_mdx_documents",
506 "opaque_html_documents",
507 "opaque_mdx_regions",
508 "opaque_mdx_bytes",
509 "opaque_html_regions",
510 "opaque_html_bytes",
511 "frontmatter_regions",
512 "frontmatter_bytes",
513 ];
514 let references = [
515 "extracted",
516 "explicit_local",
517 "same_repository",
518 "external_out_of_scope",
519 "unsupported",
520 "resolved",
521 "missing",
522 ];
523 let findings = [
524 "total",
525 "record",
526 "warn",
527 "fail",
528 "introduced",
529 "pre_existing",
530 "resolved",
531 "unknown",
532 "not_applicable",
533 "debt_tolerated",
534 "waived",
535 "analysis_errors",
536 "unsupported_capabilities",
537 ];
538 object(vec![
539 ("counts_complete", Value::Bool(false)),
540 ("documents", zero_counts(&documents)),
541 ("references", zero_counts(&references)),
542 ("findings", zero_counts(&findings)),
543 ("human_details_truncated", Value::Integer(0)),
544 ("governed_claims", Value::Integer(0)),
545 ("unattested_claims", Value::Integer(0)),
546 ])
547}
548
549fn zero_counts(fields: &[&str]) -> Value {
550 Value::Object(
551 fields
552 .iter()
553 .map(|field| ((*field).to_owned(), Value::Integer(0)))
554 .collect(),
555 )
556}
557
558fn object(members: Vec<(&str, Value)>) -> Value {
559 Value::Object(
560 members
561 .into_iter()
562 .map(|(key, value)| (key.to_owned(), value))
563 .collect(),
564 )
565}
566
567fn string(value: &str) -> Value {
568 Value::String(value.to_owned())
569}
570
571#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
574pub enum ResolutionStatus {
575 Resolved,
576 Missing,
577 TypeMismatch,
578 Unsupported,
579 Invalid,
580 ExternalOutOfScope,
581}
582
583impl ResolutionStatus {
584 #[must_use]
585 pub const fn as_str(self) -> &'static str {
586 match self {
587 Self::Resolved => "resolved",
588 Self::Missing => "missing",
589 Self::TypeMismatch => "type-mismatch",
590 Self::Unsupported => "unsupported",
591 Self::Invalid => "invalid",
592 Self::ExternalOutOfScope => "external-out-of-scope",
593 }
594 }
595}
596
597#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
599pub enum ResolutionCode {
600 ExactPath,
601 PathNotFound,
602 TargetTypeMismatch,
603 SymlinkEntry,
604 GitlinkEntry,
605 UnsupportedQuerySemantics,
606 UnsupportedFragmentSemantics,
607 UnsupportedVersionScope,
608 SiteRouteUnsupported,
609 NetworkPathUnsupported,
610 CodeFragmentUnevaluated,
611 InvalidUri,
612 InvalidPercentEncoding,
613 DecodedPathControl,
614 PathTraversal,
615 BackslashSeparator,
616 EncodedSlash,
617 InvalidFragmentEncoding,
618 InvalidReference,
619 ExternalUrl,
620 ForeignRepository,
621}
622
623impl ResolutionCode {
624 #[must_use]
625 pub const fn as_str(self) -> &'static str {
626 match self {
627 Self::ExactPath => "exact-path",
628 Self::PathNotFound => "path-not-found",
629 Self::TargetTypeMismatch => "target-type-mismatch",
630 Self::SymlinkEntry => "symlink-entry",
631 Self::GitlinkEntry => "gitlink-entry",
632 Self::UnsupportedQuerySemantics => "unsupported-query-semantics",
633 Self::UnsupportedFragmentSemantics => "unsupported-fragment-semantics",
634 Self::UnsupportedVersionScope => "unsupported-version-scope",
635 Self::SiteRouteUnsupported => "site-route-unsupported",
636 Self::NetworkPathUnsupported => "network-path-unsupported",
637 Self::CodeFragmentUnevaluated => "code-fragment-unevaluated",
638 Self::InvalidUri => "invalid-uri",
639 Self::InvalidPercentEncoding => "invalid-percent-encoding",
640 Self::DecodedPathControl => "decoded-path-control",
641 Self::PathTraversal => "path-traversal",
642 Self::BackslashSeparator => "backslash-separator",
643 Self::EncodedSlash => "encoded-slash",
644 Self::InvalidFragmentEncoding => "invalid-fragment-encoding",
645 Self::InvalidReference => "invalid-reference",
646 Self::ExternalUrl => "external-url",
647 Self::ForeignRepository => "foreign-repository",
648 }
649 }
650
651 #[must_use]
652 pub const fn status(self) -> ResolutionStatus {
653 match self {
654 Self::ExactPath => ResolutionStatus::Resolved,
655 Self::PathNotFound => ResolutionStatus::Missing,
656 Self::TargetTypeMismatch => ResolutionStatus::TypeMismatch,
657 Self::SymlinkEntry
658 | Self::GitlinkEntry
659 | Self::UnsupportedQuerySemantics
660 | Self::UnsupportedFragmentSemantics
661 | Self::UnsupportedVersionScope
662 | Self::SiteRouteUnsupported
663 | Self::NetworkPathUnsupported
664 | Self::CodeFragmentUnevaluated => ResolutionStatus::Unsupported,
665 Self::InvalidUri
666 | Self::InvalidPercentEncoding
667 | Self::DecodedPathControl
668 | Self::PathTraversal
669 | Self::BackslashSeparator
670 | Self::EncodedSlash
671 | Self::InvalidFragmentEncoding
672 | Self::InvalidReference => ResolutionStatus::Invalid,
673 Self::ExternalUrl | Self::ForeignRepository => ResolutionStatus::ExternalOutOfScope,
674 }
675 }
676}
677
678#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
681pub enum IntentKind {
682 RepositoryPath,
683 SameRepositoryGithub,
684 SameRepositoryGitlab,
685 SameRepositoryGitea,
686 ExternalUrl,
687 SiteRoute,
688 Unsupported,
689}
690
691impl IntentKind {
692 #[must_use]
693 pub const fn as_str(self) -> &'static str {
694 match self {
695 Self::RepositoryPath => "repository-path",
696 Self::SameRepositoryGithub => "same-repository-github",
697 Self::SameRepositoryGitlab => "same-repository-gitlab",
698 Self::SameRepositoryGitea => "same-repository-gitea",
699 Self::ExternalUrl => "external-url",
700 Self::SiteRoute => "site-route",
701 Self::Unsupported => "unsupported",
702 }
703 }
704}
705
706#[derive(Clone, Copy, Debug, PartialEq, Eq)]
708pub enum FindingScope {
709 Reference,
710 Observation,
711 Document,
712 Control,
713}
714
715#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
717pub enum Disposition {
718 Record,
719 Warn,
720 Fail,
721}
722
723impl Disposition {
724 #[must_use]
725 pub const fn as_str(self) -> &'static str {
726 match self {
727 Self::Record => "record",
728 Self::Warn => "warn",
729 Self::Fail => "fail",
730 }
731 }
732}
733
734#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
736pub enum FindingKind {
737 ExplicitTargetMissing,
738 ExplicitTargetTypeMismatch,
739 InvalidReference,
740 UnsupportedReferenceSemantics,
741 UnsupportedDocumentFormat,
742 UnsupportedTargetKind,
743 UnsupportedVersionScope,
744 UnsupportedCapability,
745 DependencyChangedSubjectUnchanged,
746 DependencyAndSubjectCochanged,
747 SubjectChanged,
748 ExplicitReferenceRemoved,
749 DocumentRemoved,
750 ExternalOutOfScope,
751 OpaqueMdxRegion,
752 OpaqueHtmlRegion,
753 ObservationCorrelationAmbiguous,
754 UnlinkedDocument,
755 PolicyWeakened,
756 CoverageReduced,
757 ControlPlaneChanged,
758 DebtWorsened,
759 DebtExpired,
760 WaiverInvalid,
761}
762
763impl FindingKind {
764 #[must_use]
765 pub const fn as_str(self) -> &'static str {
766 match self {
767 Self::ExplicitTargetMissing => "explicit-target-missing",
768 Self::ExplicitTargetTypeMismatch => "explicit-target-type-mismatch",
769 Self::InvalidReference => "invalid-reference",
770 Self::UnsupportedReferenceSemantics => "unsupported-reference-semantics",
771 Self::UnsupportedDocumentFormat => "unsupported-document-format",
772 Self::UnsupportedTargetKind => "unsupported-target-kind",
773 Self::UnsupportedVersionScope => "unsupported-version-scope",
774 Self::UnsupportedCapability => "unsupported-capability",
775 Self::DependencyChangedSubjectUnchanged => "dependency-changed-subject-unchanged",
776 Self::DependencyAndSubjectCochanged => "dependency-and-subject-cochanged",
777 Self::SubjectChanged => "subject-changed",
778 Self::ExplicitReferenceRemoved => "explicit-reference-removed",
779 Self::DocumentRemoved => "document-removed",
780 Self::ExternalOutOfScope => "external-out-of-scope",
781 Self::OpaqueMdxRegion => "opaque-mdx-region",
782 Self::OpaqueHtmlRegion => "opaque-html-region",
783 Self::ObservationCorrelationAmbiguous => "observation-correlation-ambiguous",
784 Self::UnlinkedDocument => "unlinked-document",
785 Self::PolicyWeakened => "policy-weakened",
786 Self::CoverageReduced => "coverage-reduced",
787 Self::ControlPlaneChanged => "control-plane-changed",
788 Self::DebtWorsened => "debt-worsened",
789 Self::DebtExpired => "debt-expired",
790 Self::WaiverInvalid => "waiver-invalid",
791 }
792 }
793
794 #[must_use]
796 pub const fn scope(self) -> FindingScope {
797 match self {
798 Self::ExplicitTargetMissing | Self::ExplicitTargetTypeMismatch => {
799 FindingScope::Reference
800 }
801 Self::InvalidReference
802 | Self::UnsupportedReferenceSemantics
803 | Self::UnsupportedTargetKind
804 | Self::UnsupportedVersionScope
805 | Self::DependencyChangedSubjectUnchanged
806 | Self::DependencyAndSubjectCochanged
807 | Self::SubjectChanged
808 | Self::ExplicitReferenceRemoved
809 | Self::ExternalOutOfScope
810 | Self::ObservationCorrelationAmbiguous => FindingScope::Observation,
811 Self::UnsupportedDocumentFormat
812 | Self::DocumentRemoved
813 | Self::OpaqueMdxRegion
814 | Self::OpaqueHtmlRegion
815 | Self::UnlinkedDocument => FindingScope::Document,
816 Self::UnsupportedCapability
817 | Self::PolicyWeakened
818 | Self::CoverageReduced
819 | Self::ControlPlaneChanged
820 | Self::DebtWorsened
821 | Self::DebtExpired
822 | Self::WaiverInvalid => FindingScope::Control,
823 }
824 }
825
826 #[must_use]
829 pub const fn built_in_disposition(self, enforce: bool) -> Disposition {
830 match self {
831 Self::ExplicitTargetMissing
832 | Self::ExplicitTargetTypeMismatch
833 | Self::InvalidReference => {
834 if enforce {
835 Disposition::Fail
836 } else {
837 Disposition::Warn
838 }
839 }
840 Self::UnsupportedCapability
841 | Self::PolicyWeakened
842 | Self::CoverageReduced
843 | Self::ControlPlaneChanged
844 | Self::DebtWorsened
845 | Self::DebtExpired
846 | Self::WaiverInvalid => Disposition::Fail,
847 Self::DependencyChangedSubjectUnchanged | Self::ExplicitReferenceRemoved => {
848 Disposition::Warn
849 }
850 Self::UnsupportedReferenceSemantics
851 | Self::UnsupportedDocumentFormat
852 | Self::UnsupportedTargetKind
853 | Self::UnsupportedVersionScope
854 | Self::DependencyAndSubjectCochanged
855 | Self::SubjectChanged
856 | Self::DocumentRemoved
857 | Self::ExternalOutOfScope
858 | Self::OpaqueMdxRegion
859 | Self::OpaqueHtmlRegion
860 | Self::ObservationCorrelationAmbiguous
861 | Self::UnlinkedDocument => Disposition::Record,
862 }
863 }
864}
865
866pub const SANDBOX_SCHEMA: &str = "amiss/scanner-sandbox-profile/v1";
867
868#[must_use]
871pub fn sandbox_descriptor() -> (Value, Digest) {
872 let descriptor = object(vec![
873 ("schema", string(SANDBOX_SCHEMA)),
874 ("profile", string("scanner-v0-zero-capability-v1")),
875 ("isolation", string("process")),
876 ("network", string("denied")),
877 ("child_processes", string("denied")),
878 ("repository_processes", string("denied")),
879 ("credentials", string("absent")),
880 ("secrets", string("absent")),
881 ("shared_cache", string("denied")),
882 ("workspace", string("read-only")),
883 ("environment", string("scanner-process-env-v1")),
884 (
885 "physical_memory",
886 object(vec![("maximum_bytes", Value::Integer(1_073_741_824))]),
887 ),
888 (
889 "temporary_storage",
890 object(vec![
891 ("kind", string("private-bounded")),
892 ("maximum_bytes", Value::Integer(67_108_864)),
893 ]),
894 ),
895 (
896 "watchdog",
897 object(vec![("maximum_milliseconds", Value::Integer(120_000))]),
898 ),
899 ]);
900 let digest = hj(SANDBOX_SCHEMA, &descriptor);
901 (descriptor, digest)
902}
903
904impl FindingKind {
905 #[must_use]
906 pub const fn evidence_class(self) -> &'static str {
907 match self {
908 Self::ExplicitTargetMissing
909 | Self::ExplicitTargetTypeMismatch
910 | Self::InvalidReference => "deterministic-structural",
911 Self::UnsupportedCapability
912 | Self::UnsupportedReferenceSemantics
913 | Self::UnsupportedDocumentFormat
914 | Self::UnsupportedTargetKind
915 | Self::UnsupportedVersionScope => "unsupported",
916 Self::DependencyChangedSubjectUnchanged
917 | Self::DependencyAndSubjectCochanged
918 | Self::SubjectChanged => "impact-observation",
919 Self::ExplicitReferenceRemoved
920 | Self::DocumentRemoved
921 | Self::ExternalOutOfScope
922 | Self::OpaqueMdxRegion
923 | Self::OpaqueHtmlRegion
924 | Self::ObservationCorrelationAmbiguous
925 | Self::UnlinkedDocument => "coverage-boundary",
926 Self::PolicyWeakened
927 | Self::CoverageReduced
928 | Self::ControlPlaneChanged
929 | Self::DebtWorsened
930 | Self::DebtExpired
931 | Self::WaiverInvalid => "control-plane",
932 }
933 }
934
935 #[must_use]
936 pub const fn invariant_class(self) -> &'static str {
937 match self {
938 Self::ExplicitTargetMissing
939 | Self::ExplicitTargetTypeMismatch
940 | Self::InvalidReference => "ratcheted",
941 Self::UnsupportedCapability => "analysis-integrity",
942 Self::UnsupportedReferenceSemantics
943 | Self::UnsupportedDocumentFormat
944 | Self::UnsupportedTargetKind
945 | Self::UnsupportedVersionScope
946 | Self::DependencyChangedSubjectUnchanged
947 | Self::DependencyAndSubjectCochanged
948 | Self::SubjectChanged
949 | Self::ExplicitReferenceRemoved
950 | Self::DocumentRemoved
951 | Self::ExternalOutOfScope
952 | Self::OpaqueMdxRegion
953 | Self::OpaqueHtmlRegion
954 | Self::ObservationCorrelationAmbiguous
955 | Self::UnlinkedDocument => "advisory",
956 Self::PolicyWeakened
957 | Self::CoverageReduced
958 | Self::ControlPlaneChanged
959 | Self::DebtWorsened
960 | Self::DebtExpired
961 | Self::WaiverInvalid => "absolute",
962 }
963 }
964}
965
966#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
971pub struct ErrorDetail {
972 pub code: AnalysisErrorCode,
973 pub path: Option<crate::model::RepoPath>,
974 pub path_bytes: Option<Vec<u8>>,
975 pub resource: Option<(crate::controls::ResourceName, u64, u64)>,
976}
977
978impl ErrorDetail {
979 #[must_use]
980 pub fn phase(&self) -> &'static str {
981 self.resource.map_or_else(
982 || self.code.fixed_phase().unwrap_or("internal"),
983 |(name, _limit, _observed)| name.phase(),
984 )
985 }
986}
987
988#[must_use]
990pub fn error_row_value(detail: &ErrorDetail) -> Value {
991 error_row(detail, detail.phase())
992}
993
994fn error_row(detail: &ErrorDetail, phase: &str) -> Value {
995 let (resource, limit, observed) = detail.resource.map_or(
996 (Value::Null, Value::Null, Value::Null),
997 |(name, limit, observed)| {
998 (
999 string(name.as_str()),
1000 Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX)),
1001 Value::Integer(i64::try_from(observed).unwrap_or(i64::MAX)),
1002 )
1003 },
1004 );
1005 object(vec![
1006 ("phase", string(phase)),
1007 ("code", string(detail.code.as_str())),
1008 (
1009 "path",
1010 detail
1011 .path
1012 .as_ref()
1013 .map_or(Value::Null, crate::model::RepoPath::to_value),
1014 ),
1015 (
1016 "path_bytes_hex",
1017 detail.path_bytes.as_deref().map_or(Value::Null, |bytes| {
1018 Value::String(crate::model::hex_lower(bytes))
1019 }),
1020 ),
1021 ("resource", resource),
1022 ("configured_limit", limit),
1023 ("observed_lower_bound", observed),
1024 ])
1025}