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/v1";
135pub const PAYLOAD_SCHEMA: &str = "amiss/scanner-report-payload/v1";
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 resource: None,
393 },
394 phase,
395 )
396 })
397 .collect();
398 let error_count = i64::try_from(error_rows.len()).ok()?;
399
400 let payload = object(vec![
401 ("schema", string(PAYLOAD_SCHEMA)),
402 ("compatibility", string("experimental")),
403 ("engine", engine_block(engine)),
404 (
405 "evaluation",
406 object(vec![
407 ("status", string("unavailable")),
408 (
409 "request_digest",
410 evaluation_request_digest
411 .map_or(Value::Null, |digest| string(&digest.to_string())),
412 ),
413 ("reasons", Value::Array(reasons)),
414 ]),
415 ),
416 (
417 "controls",
418 object(vec![
419 ("status", string("unavailable")),
420 (
421 "request_digest",
422 controls_request_digest
423 .map_or(Value::Null, |digest| string(&digest.to_string())),
424 ),
425 ("reasons", Value::Array(vec![string("not-parsed")])),
426 ]),
427 ),
428 (
429 "result",
430 object(vec![
431 ("complete", Value::Bool(false)),
432 ("status", string("incomplete")),
433 ("exit_code", Value::Integer(2)),
434 ("finding_count", Value::Integer(0)),
435 ("error_count", Value::Integer(error_count)),
436 ]),
437 ),
438 ("summary", zero_summary()),
439 ("documents", Value::Array(Vec::new())),
440 ("observations", Value::Array(Vec::new())),
441 ("findings", Value::Array(Vec::new())),
442 ("errors", Value::Array(error_rows)),
443 ]);
444
445 let payload_digest = hj(PAYLOAD_SCHEMA, &payload);
446 Some(object(vec![
447 ("schema", string(ENVELOPE_SCHEMA)),
448 ("payload", payload),
449 ("payload_digest", string(&payload_digest.to_string())),
450 ]))
451}
452
453#[must_use]
456pub fn adapter_contract(engine: &EngineProvenance, adapter: Adapter) -> (Value, Digest) {
457 let descriptor = object(vec![
458 ("schema", string(ADAPTER_CONTRACT_SCHEMA)),
459 ("adapter_id", string(adapter.adapter_id())),
460 ("parser_name", string(adapter.parser_name())),
461 ("parser_version", string(&engine.version)),
462 ("grammar_profile", string(adapter.grammar_profile())),
463 (
464 "frontmatter_contract",
465 string(adapter.frontmatter_contract()),
466 ),
467 ("source_projection", string(adapter.source_projection())),
468 ("structural_address", string(adapter.structural_address())),
469 ]);
470 let digest = hj(ADAPTER_CONTRACT_SCHEMA, &descriptor);
471 (descriptor, digest)
472}
473
474#[must_use]
477pub fn engine_block(engine: &EngineProvenance) -> Value {
478 let adapter_rows: Vec<Value> = Adapter::ALL
479 .iter()
480 .map(|adapter| {
481 let (descriptor, digest) = adapter_contract(engine, *adapter);
482 object(vec![
483 ("adapter_id", string(adapter.adapter_id())),
484 ("contract_descriptor", descriptor),
485 ("contract_digest", string(&digest.to_string())),
486 ])
487 })
488 .collect();
489 object(vec![
490 ("engine_contract", string(ENGINE_CONTRACT)),
491 ("engine_version", string(&engine.version)),
492 ("engine_digest", string(&engine.digest.to_string())),
493 ("action_provenance", object(vec![("kind", string("local"))])),
494 ("built_in_policy_version", string(BUILT_IN_POLICY_VERSION)),
495 ("adapters", Value::Array(adapter_rows)),
496 ])
497}
498
499fn zero_summary() -> Value {
500 let documents = [
501 "discovered",
502 "outside_document_set",
503 "scanned",
504 "unsupported",
505 "excluded_builtin",
506 "unlinked",
507 "frontmatter_documents",
508 "opaque_mdx_documents",
509 "opaque_html_documents",
510 "opaque_mdx_regions",
511 "opaque_mdx_bytes",
512 "opaque_html_regions",
513 "opaque_html_bytes",
514 "frontmatter_regions",
515 "frontmatter_bytes",
516 ];
517 let references = [
518 "extracted",
519 "explicit_local",
520 "same_repository_github",
521 "external_out_of_scope",
522 "unsupported",
523 "resolved",
524 "missing",
525 ];
526 let findings = [
527 "total",
528 "record",
529 "warn",
530 "fail",
531 "introduced",
532 "pre_existing",
533 "resolved",
534 "unknown",
535 "not_applicable",
536 "debt_tolerated",
537 "waived",
538 "analysis_errors",
539 "unsupported_capabilities",
540 ];
541 object(vec![
542 ("counts_complete", Value::Bool(false)),
543 ("documents", zero_counts(&documents)),
544 ("references", zero_counts(&references)),
545 ("findings", zero_counts(&findings)),
546 ("human_details_truncated", Value::Integer(0)),
547 ("governed_claims", Value::Integer(0)),
548 ("unattested_claims", Value::Integer(0)),
549 ])
550}
551
552fn zero_counts(fields: &[&str]) -> Value {
553 Value::Object(
554 fields
555 .iter()
556 .map(|field| ((*field).to_owned(), Value::Integer(0)))
557 .collect(),
558 )
559}
560
561fn object(members: Vec<(&str, Value)>) -> Value {
562 Value::Object(
563 members
564 .into_iter()
565 .map(|(key, value)| (key.to_owned(), value))
566 .collect(),
567 )
568}
569
570fn string(value: &str) -> Value {
571 Value::String(value.to_owned())
572}
573
574#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
577pub enum ResolutionStatus {
578 Resolved,
579 Missing,
580 TypeMismatch,
581 Unsupported,
582 Invalid,
583 ExternalOutOfScope,
584}
585
586impl ResolutionStatus {
587 #[must_use]
588 pub const fn as_str(self) -> &'static str {
589 match self {
590 Self::Resolved => "resolved",
591 Self::Missing => "missing",
592 Self::TypeMismatch => "type-mismatch",
593 Self::Unsupported => "unsupported",
594 Self::Invalid => "invalid",
595 Self::ExternalOutOfScope => "external-out-of-scope",
596 }
597 }
598}
599
600#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
602pub enum ResolutionCode {
603 ExactPath,
604 PathNotFound,
605 TargetTypeMismatch,
606 SymlinkEntry,
607 GitlinkEntry,
608 UnsupportedQuerySemantics,
609 UnsupportedFragmentSemantics,
610 UnsupportedVersionScope,
611 SiteRouteUnsupported,
612 NetworkPathUnsupported,
613 CodeFragmentUnevaluated,
614 InvalidUri,
615 InvalidPercentEncoding,
616 DecodedPathControl,
617 PathTraversal,
618 BackslashSeparator,
619 EncodedSlash,
620 InvalidFragmentEncoding,
621 InvalidReference,
622 ExternalUrl,
623 ForeignRepository,
624}
625
626impl ResolutionCode {
627 #[must_use]
628 pub const fn as_str(self) -> &'static str {
629 match self {
630 Self::ExactPath => "exact-path",
631 Self::PathNotFound => "path-not-found",
632 Self::TargetTypeMismatch => "target-type-mismatch",
633 Self::SymlinkEntry => "symlink-entry",
634 Self::GitlinkEntry => "gitlink-entry",
635 Self::UnsupportedQuerySemantics => "unsupported-query-semantics",
636 Self::UnsupportedFragmentSemantics => "unsupported-fragment-semantics",
637 Self::UnsupportedVersionScope => "unsupported-version-scope",
638 Self::SiteRouteUnsupported => "site-route-unsupported",
639 Self::NetworkPathUnsupported => "network-path-unsupported",
640 Self::CodeFragmentUnevaluated => "code-fragment-unevaluated",
641 Self::InvalidUri => "invalid-uri",
642 Self::InvalidPercentEncoding => "invalid-percent-encoding",
643 Self::DecodedPathControl => "decoded-path-control",
644 Self::PathTraversal => "path-traversal",
645 Self::BackslashSeparator => "backslash-separator",
646 Self::EncodedSlash => "encoded-slash",
647 Self::InvalidFragmentEncoding => "invalid-fragment-encoding",
648 Self::InvalidReference => "invalid-reference",
649 Self::ExternalUrl => "external-url",
650 Self::ForeignRepository => "foreign-repository",
651 }
652 }
653
654 #[must_use]
655 pub const fn status(self) -> ResolutionStatus {
656 match self {
657 Self::ExactPath => ResolutionStatus::Resolved,
658 Self::PathNotFound => ResolutionStatus::Missing,
659 Self::TargetTypeMismatch => ResolutionStatus::TypeMismatch,
660 Self::SymlinkEntry
661 | Self::GitlinkEntry
662 | Self::UnsupportedQuerySemantics
663 | Self::UnsupportedFragmentSemantics
664 | Self::UnsupportedVersionScope
665 | Self::SiteRouteUnsupported
666 | Self::NetworkPathUnsupported
667 | Self::CodeFragmentUnevaluated => ResolutionStatus::Unsupported,
668 Self::InvalidUri
669 | Self::InvalidPercentEncoding
670 | Self::DecodedPathControl
671 | Self::PathTraversal
672 | Self::BackslashSeparator
673 | Self::EncodedSlash
674 | Self::InvalidFragmentEncoding
675 | Self::InvalidReference => ResolutionStatus::Invalid,
676 Self::ExternalUrl | Self::ForeignRepository => ResolutionStatus::ExternalOutOfScope,
677 }
678 }
679}
680
681#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
683pub enum IntentKind {
684 RepositoryPath,
685 SameRepositoryGithub,
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::ExternalUrl => "external-url",
698 Self::SiteRoute => "site-route",
699 Self::Unsupported => "unsupported",
700 }
701 }
702}
703
704#[derive(Clone, Copy, Debug, PartialEq, Eq)]
706pub enum FindingScope {
707 Reference,
708 Observation,
709 Document,
710 Control,
711}
712
713#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
715pub enum Disposition {
716 Record,
717 Warn,
718 Fail,
719}
720
721impl Disposition {
722 #[must_use]
723 pub const fn as_str(self) -> &'static str {
724 match self {
725 Self::Record => "record",
726 Self::Warn => "warn",
727 Self::Fail => "fail",
728 }
729 }
730}
731
732#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
734pub enum FindingKind {
735 ExplicitTargetMissing,
736 ExplicitTargetTypeMismatch,
737 InvalidReference,
738 UnsupportedReferenceSemantics,
739 UnsupportedDocumentFormat,
740 UnsupportedTargetKind,
741 UnsupportedVersionScope,
742 UnsupportedCapability,
743 DependencyChangedSubjectUnchanged,
744 DependencyAndSubjectCochanged,
745 SubjectChanged,
746 ExplicitReferenceRemoved,
747 DocumentRemoved,
748 ExternalOutOfScope,
749 OpaqueMdxRegion,
750 OpaqueHtmlRegion,
751 ObservationCorrelationAmbiguous,
752 UnlinkedDocument,
753 PolicyWeakened,
754 CoverageReduced,
755 ControlPlaneChanged,
756 DebtWorsened,
757 DebtExpired,
758 WaiverInvalid,
759}
760
761impl FindingKind {
762 #[must_use]
763 pub const fn as_str(self) -> &'static str {
764 match self {
765 Self::ExplicitTargetMissing => "explicit-target-missing",
766 Self::ExplicitTargetTypeMismatch => "explicit-target-type-mismatch",
767 Self::InvalidReference => "invalid-reference",
768 Self::UnsupportedReferenceSemantics => "unsupported-reference-semantics",
769 Self::UnsupportedDocumentFormat => "unsupported-document-format",
770 Self::UnsupportedTargetKind => "unsupported-target-kind",
771 Self::UnsupportedVersionScope => "unsupported-version-scope",
772 Self::UnsupportedCapability => "unsupported-capability",
773 Self::DependencyChangedSubjectUnchanged => "dependency-changed-subject-unchanged",
774 Self::DependencyAndSubjectCochanged => "dependency-and-subject-cochanged",
775 Self::SubjectChanged => "subject-changed",
776 Self::ExplicitReferenceRemoved => "explicit-reference-removed",
777 Self::DocumentRemoved => "document-removed",
778 Self::ExternalOutOfScope => "external-out-of-scope",
779 Self::OpaqueMdxRegion => "opaque-mdx-region",
780 Self::OpaqueHtmlRegion => "opaque-html-region",
781 Self::ObservationCorrelationAmbiguous => "observation-correlation-ambiguous",
782 Self::UnlinkedDocument => "unlinked-document",
783 Self::PolicyWeakened => "policy-weakened",
784 Self::CoverageReduced => "coverage-reduced",
785 Self::ControlPlaneChanged => "control-plane-changed",
786 Self::DebtWorsened => "debt-worsened",
787 Self::DebtExpired => "debt-expired",
788 Self::WaiverInvalid => "waiver-invalid",
789 }
790 }
791
792 #[must_use]
794 pub const fn scope(self) -> FindingScope {
795 match self {
796 Self::ExplicitTargetMissing | Self::ExplicitTargetTypeMismatch => {
797 FindingScope::Reference
798 }
799 Self::InvalidReference
800 | Self::UnsupportedReferenceSemantics
801 | Self::UnsupportedTargetKind
802 | Self::UnsupportedVersionScope
803 | Self::DependencyChangedSubjectUnchanged
804 | Self::DependencyAndSubjectCochanged
805 | Self::SubjectChanged
806 | Self::ExplicitReferenceRemoved
807 | Self::ExternalOutOfScope
808 | Self::ObservationCorrelationAmbiguous => FindingScope::Observation,
809 Self::UnsupportedDocumentFormat
810 | Self::DocumentRemoved
811 | Self::OpaqueMdxRegion
812 | Self::OpaqueHtmlRegion
813 | Self::UnlinkedDocument => FindingScope::Document,
814 Self::UnsupportedCapability
815 | Self::PolicyWeakened
816 | Self::CoverageReduced
817 | Self::ControlPlaneChanged
818 | Self::DebtWorsened
819 | Self::DebtExpired
820 | Self::WaiverInvalid => FindingScope::Control,
821 }
822 }
823
824 #[must_use]
827 pub const fn built_in_disposition(self, enforce: bool) -> Disposition {
828 match self {
829 Self::ExplicitTargetMissing
830 | Self::ExplicitTargetTypeMismatch
831 | Self::InvalidReference => {
832 if enforce {
833 Disposition::Fail
834 } else {
835 Disposition::Warn
836 }
837 }
838 Self::UnsupportedCapability
839 | Self::PolicyWeakened
840 | Self::CoverageReduced
841 | Self::ControlPlaneChanged
842 | Self::DebtWorsened
843 | Self::DebtExpired
844 | Self::WaiverInvalid => Disposition::Fail,
845 Self::DependencyChangedSubjectUnchanged | Self::ExplicitReferenceRemoved => {
846 Disposition::Warn
847 }
848 Self::UnsupportedReferenceSemantics
849 | Self::UnsupportedDocumentFormat
850 | Self::UnsupportedTargetKind
851 | Self::UnsupportedVersionScope
852 | Self::DependencyAndSubjectCochanged
853 | Self::SubjectChanged
854 | Self::DocumentRemoved
855 | Self::ExternalOutOfScope
856 | Self::OpaqueMdxRegion
857 | Self::OpaqueHtmlRegion
858 | Self::ObservationCorrelationAmbiguous
859 | Self::UnlinkedDocument => Disposition::Record,
860 }
861 }
862}
863
864pub const SANDBOX_SCHEMA: &str = "amiss/scanner-sandbox-profile/v1";
865
866#[must_use]
869pub fn sandbox_descriptor() -> (Value, Digest) {
870 let descriptor = object(vec![
871 ("schema", string(SANDBOX_SCHEMA)),
872 ("profile", string("scanner-v0-zero-capability-v1")),
873 ("isolation", string("process")),
874 ("network", string("denied")),
875 ("child_processes", string("denied")),
876 ("repository_processes", string("denied")),
877 ("credentials", string("absent")),
878 ("secrets", string("absent")),
879 ("shared_cache", string("denied")),
880 ("workspace", string("read-only")),
881 ("environment", string("scanner-process-env-v1")),
882 (
883 "physical_memory",
884 object(vec![("maximum_bytes", Value::Integer(1_073_741_824))]),
885 ),
886 (
887 "temporary_storage",
888 object(vec![
889 ("kind", string("private-bounded")),
890 ("maximum_bytes", Value::Integer(67_108_864)),
891 ]),
892 ),
893 (
894 "watchdog",
895 object(vec![("maximum_milliseconds", Value::Integer(120_000))]),
896 ),
897 ]);
898 let digest = hj(SANDBOX_SCHEMA, &descriptor);
899 (descriptor, digest)
900}
901
902impl FindingKind {
903 #[must_use]
904 pub const fn evidence_class(self) -> &'static str {
905 match self {
906 Self::ExplicitTargetMissing
907 | Self::ExplicitTargetTypeMismatch
908 | Self::InvalidReference => "deterministic-structural",
909 Self::UnsupportedCapability
910 | Self::UnsupportedReferenceSemantics
911 | Self::UnsupportedDocumentFormat
912 | Self::UnsupportedTargetKind
913 | Self::UnsupportedVersionScope => "unsupported",
914 Self::DependencyChangedSubjectUnchanged
915 | Self::DependencyAndSubjectCochanged
916 | Self::SubjectChanged => "impact-observation",
917 Self::ExplicitReferenceRemoved
918 | Self::DocumentRemoved
919 | Self::ExternalOutOfScope
920 | Self::OpaqueMdxRegion
921 | Self::OpaqueHtmlRegion
922 | Self::ObservationCorrelationAmbiguous
923 | Self::UnlinkedDocument => "coverage-boundary",
924 Self::PolicyWeakened
925 | Self::CoverageReduced
926 | Self::ControlPlaneChanged
927 | Self::DebtWorsened
928 | Self::DebtExpired
929 | Self::WaiverInvalid => "control-plane",
930 }
931 }
932
933 #[must_use]
934 pub const fn invariant_class(self) -> &'static str {
935 match self {
936 Self::ExplicitTargetMissing
937 | Self::ExplicitTargetTypeMismatch
938 | Self::InvalidReference => "ratcheted",
939 Self::UnsupportedCapability => "analysis-integrity",
940 Self::UnsupportedReferenceSemantics
941 | Self::UnsupportedDocumentFormat
942 | Self::UnsupportedTargetKind
943 | Self::UnsupportedVersionScope
944 | Self::DependencyChangedSubjectUnchanged
945 | Self::DependencyAndSubjectCochanged
946 | Self::SubjectChanged
947 | Self::ExplicitReferenceRemoved
948 | Self::DocumentRemoved
949 | Self::ExternalOutOfScope
950 | Self::OpaqueMdxRegion
951 | Self::OpaqueHtmlRegion
952 | Self::ObservationCorrelationAmbiguous
953 | Self::UnlinkedDocument => "advisory",
954 Self::PolicyWeakened
955 | Self::CoverageReduced
956 | Self::ControlPlaneChanged
957 | Self::DebtWorsened
958 | Self::DebtExpired
959 | Self::WaiverInvalid => "absolute",
960 }
961 }
962}
963
964#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
968pub struct ErrorDetail {
969 pub code: AnalysisErrorCode,
970 pub path: Option<String>,
971 pub resource: Option<(crate::controls::ResourceName, u64, u64)>,
972}
973
974impl ErrorDetail {
975 #[must_use]
976 pub fn phase(&self) -> &'static str {
977 self.resource.map_or_else(
978 || self.code.fixed_phase().unwrap_or("internal"),
979 |(name, _limit, _observed)| name.phase(),
980 )
981 }
982}
983
984#[must_use]
986pub fn error_row_value(detail: &ErrorDetail) -> Value {
987 error_row(detail, detail.phase())
988}
989
990fn error_row(detail: &ErrorDetail, phase: &str) -> Value {
991 let (resource, limit, observed) = detail.resource.map_or(
992 (Value::Null, Value::Null, Value::Null),
993 |(name, limit, observed)| {
994 (
995 string(name.as_str()),
996 Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX)),
997 Value::Integer(i64::try_from(observed).unwrap_or(i64::MAX)),
998 )
999 },
1000 );
1001 object(vec![
1002 ("phase", string(phase)),
1003 ("code", string(detail.code.as_str())),
1004 ("path", detail.path.as_deref().map_or(Value::Null, string)),
1005 ("path_bytes_hex", Value::Null),
1006 ("resource", resource),
1007 ("configured_limit", limit),
1008 ("observed_lower_bound", observed),
1009 ])
1010}