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