1use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10
11use sha2::{Digest, Sha256};
12use termivar_core::{
13 ApiEvidencePredicate, ApiVisibilityDimension, EntityId, EvidenceId, EvidenceValue, Probability,
14 SecuritySeverity, VerificationStage,
15};
16#[cfg(test)]
17use termivar_core::{HypothesisState, KnowledgePredicate, Outcome, OutcomeStatus};
18use thiserror::Error;
19use url::Url;
20
21use crate::api_observation::api_visibility_review_for_commit;
22use crate::knowledge::KnowledgeAuthority;
23use crate::KnowledgeBase;
24use crate::{ApiObservationCommitReceipt, ApiVisibilityReview, ApiVisibilityReviewDisposition};
25#[cfg(test)]
26use crate::{DecisionEvidenceReceipt, DecisionExecutionStage, DecisionOutcomeReport};
27
28pub const ASSESSMENT_ITEM_SCHEMA: &str = "venom-assessment-item/v1";
30pub const MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES: usize = 256;
32pub const MAX_ASSESSMENT_ITEM_SET_ITEMS: usize = 4_096;
34pub const MAX_ASSESSMENT_CAPABILITY_ID_BYTES: usize = 128;
36pub const MAX_ASSESSMENT_DISPLAY_BYTES: usize = 1_024;
38
39const FINGERPRINT_DOMAIN: &[u8] = b"venom.assessment-item.fingerprint.v1\0";
40const SCOPE_IDENTITY_DOMAIN: &[u8] = b"venom.assessment-scope.exact-origin.v1\0";
41const SUBJECT_IDENTITY_DOMAIN: &[u8] = b"venom.assessment-subject.identity.v1\0";
42const DISCOVERED_SUBJECT_IDENTITY_DOMAIN: &[u8] =
43 b"venom.assessment-subject.discovered-resource.v1\0";
44const DISCOVERED_SUBJECT_IDENTITY_VERSION: &str = "discovered-resource@1";
45#[cfg(feature = "graphql-review")]
46const GRAPHQL_SUBJECT_IDENTITY_DOMAIN: &[u8] = b"venom.assessment-subject.anonymous-graphql.v1\0";
47#[cfg(feature = "graphql-review")]
48const GRAPHQL_SUBJECT_IDENTITY_VERSION: &str = "anonymous-graphql-endpoint@1";
49const MAX_STABLE_SUBJECT_ID_BYTES: usize = 256;
50const MAX_QUERY_PARAMETER_NAME_BYTES: usize = 256;
51const MAX_PROJECTION_SUBJECTS: usize = 1_024;
52const MAX_PROJECTION_QUERY_NAMES_PER_SUBJECT: usize = 256;
53#[cfg(test)]
54const MAX_PROJECTION_CASES: usize = 10_000;
55#[cfg(test)]
56const MAX_PROJECTION_OUTCOMES: usize = 10_000;
57const MAX_PROJECTION_EVIDENCE: usize = 262_144;
58const MAX_PROJECTION_SUBJECT_ID_BYTES: usize = 16_384;
59const MAX_PROJECTION_RUNTIME_ID_BYTES: usize = 1_024;
60
61#[derive(Clone, PartialEq, Eq)]
65pub(crate) struct StableAssessmentScopeId(String);
66
67impl StableAssessmentScopeId {
68 pub(crate) fn from_exact_origin(value: &str) -> Result<Self, AssessmentItemProjectionError> {
69 let url = Url::parse(value)
70 .map_err(|_| AssessmentItemProjectionError::InvalidStableScopeIdentity)?;
71 if !matches!(url.scheme(), "http" | "https")
72 || !url.username().is_empty()
73 || url.password().is_some()
74 || url.host().is_none()
75 || url.path() != "/"
76 || url.query().is_some()
77 || url.fragment().is_some()
78 || url.origin().ascii_serialization() != value
79 {
80 return Err(AssessmentItemProjectionError::InvalidStableScopeIdentity);
81 }
82 let mut digest = Sha256::new();
83 digest.update(SCOPE_IDENTITY_DOMAIN);
84 digest_field(&mut digest, value);
85 Ok(Self(format!("origin-sha256:{:x}", digest.finalize())))
86 }
87
88 fn as_str(&self) -> &str {
89 &self.0
90 }
91
92 #[cfg(feature = "reporting")]
93 fn matches_exact_origin(&self, value: &str) -> bool {
94 Self::from_exact_origin(value).is_ok_and(|expected| expected == *self)
95 }
96}
97
98impl fmt::Debug for StableAssessmentScopeId {
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 formatter.write_str("StableAssessmentScopeId(<host-approved>)")
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
110#[non_exhaustive]
111pub enum AssessmentDisposition {
112 Informational,
114 NeedsReview,
116 Confirmed,
118}
119
120impl AssessmentDisposition {
121 pub const fn as_str(self) -> &'static str {
123 match self {
124 Self::Informational => "informational",
125 Self::NeedsReview => "needs_review",
126 Self::Confirmed => "confirmed",
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
133pub struct AssessmentSubjectReference(u32);
134
135impl AssessmentSubjectReference {
136 pub(crate) const fn new(ordinal: u32) -> Self {
137 Self(ordinal)
138 }
139
140 pub const fn ordinal(self) -> u32 {
142 self.0
143 }
144}
145
146impl fmt::Display for AssessmentSubjectReference {
147 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148 write!(formatter, "subject-{:04}", self.0)
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
154pub struct AssessmentEvidenceReference(u32);
155
156impl AssessmentEvidenceReference {
157 pub(crate) const fn new(ordinal: u32) -> Self {
158 Self(ordinal)
159 }
160
161 pub const fn ordinal(self) -> u32 {
163 self.0
164 }
165}
166
167impl fmt::Display for AssessmentEvidenceReference {
168 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169 write!(formatter, "evidence-{:04}", self.0)
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
175pub struct AssessmentCaseReference(u32);
176
177impl AssessmentCaseReference {
178 #[cfg(test)]
179 pub(crate) const fn new(ordinal: u32) -> Self {
180 Self(ordinal)
181 }
182
183 pub const fn ordinal(self) -> u32 {
185 self.0
186 }
187}
188
189impl fmt::Display for AssessmentCaseReference {
190 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191 write!(formatter, "case-{:04}", self.0)
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
197pub struct AssessmentOutcomeReference(u32);
198
199impl AssessmentOutcomeReference {
200 #[cfg(test)]
201 pub(crate) const fn new(ordinal: u32) -> Self {
202 Self(ordinal)
203 }
204
205 pub const fn ordinal(self) -> u32 {
207 self.0
208 }
209}
210
211impl fmt::Display for AssessmentOutcomeReference {
212 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213 write!(formatter, "outcome-{:04}", self.0)
214 }
215}
216
217#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
222pub(crate) struct StableAssessmentSubjectId(String);
223
224impl StableAssessmentSubjectId {
225 pub(crate) fn new(value: impl Into<String>) -> Result<Self, AssessmentItemProjectionError> {
226 let value = value.into();
227 if !valid_stable_product_identity(&value) {
228 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
229 }
230 Ok(Self(value))
231 }
232
233 pub(crate) fn from_discovered_resource(
234 stable_scope_id: &StableAssessmentScopeId,
235 method: super::web_assessment::WebAssessmentMethod,
236 canonical_url: &Url,
237 query_parameter_names: &[String],
238 ) -> Result<Self, AssessmentItemProjectionError> {
239 let exact_origin = canonical_url.origin().ascii_serialization();
240 if !StableAssessmentScopeId::from_exact_origin(&exact_origin)
241 .is_ok_and(|expected| expected == *stable_scope_id)
242 || !matches!(canonical_url.scheme(), "http" | "https")
243 || canonical_url.host().is_none()
244 || !canonical_url.username().is_empty()
245 || canonical_url.password().is_some()
246 || canonical_url.path().is_empty()
247 || canonical_url.query().is_some()
248 || canonical_url.fragment().is_some()
249 || canonical_url.as_str().len() > MAX_PROJECTION_SUBJECT_ID_BYTES
250 || query_parameter_names.len() > MAX_PROJECTION_QUERY_NAMES_PER_SUBJECT
251 {
252 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
253 }
254
255 let mut canonical_names = BTreeSet::new();
256 for name in query_parameter_names {
257 if name.is_empty()
258 || name.len() > MAX_QUERY_PARAMETER_NAME_BYTES
259 || name.chars().any(char::is_control)
260 {
261 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
262 }
263 canonical_names.insert(name.as_str());
264 }
265
266 let method = match method {
267 super::web_assessment::WebAssessmentMethod::Get => "GET",
268 super::web_assessment::WebAssessmentMethod::Head => "HEAD",
269 };
270 let mut digest = Sha256::new();
271 digest.update(DISCOVERED_SUBJECT_IDENTITY_DOMAIN);
272 digest_field(&mut digest, DISCOVERED_SUBJECT_IDENTITY_VERSION);
273 digest_field(&mut digest, stable_scope_id.as_str());
274 digest_field(&mut digest, method);
275 digest_field(&mut digest, canonical_url.path());
276 digest.update((canonical_names.len() as u64).to_be_bytes());
277 for name in canonical_names {
278 digest_field(&mut digest, name);
279 }
280 Self::new(format!(
281 "{DISCOVERED_SUBJECT_IDENTITY_VERSION}:{:x}",
282 digest.finalize()
283 ))
284 }
285
286 #[cfg(feature = "graphql-review")]
293 pub(crate) fn from_anonymous_graphql_endpoint(
294 stable_scope_id: &StableAssessmentScopeId,
295 canonical_url: &Url,
296 ) -> Result<Self, AssessmentItemProjectionError> {
297 let exact_origin = canonical_url.origin().ascii_serialization();
298 if !StableAssessmentScopeId::from_exact_origin(&exact_origin)
299 .is_ok_and(|expected| expected == *stable_scope_id)
300 || !matches!(canonical_url.scheme(), "http" | "https")
301 || canonical_url.host().is_none()
302 || !canonical_url.username().is_empty()
303 || canonical_url.password().is_some()
304 || canonical_url.path().is_empty()
305 || canonical_url.query().is_some()
306 || canonical_url.fragment().is_some()
307 || canonical_url.as_str().len() > MAX_PROJECTION_SUBJECT_ID_BYTES
308 {
309 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
310 }
311
312 let mut digest = Sha256::new();
313 digest.update(GRAPHQL_SUBJECT_IDENTITY_DOMAIN);
314 digest_field(&mut digest, GRAPHQL_SUBJECT_IDENTITY_VERSION);
315 digest_field(&mut digest, stable_scope_id.as_str());
316 digest_field(&mut digest, "POST");
317 digest_field(&mut digest, canonical_url.path());
318 digest_field(&mut digest, "graphql");
319 digest_field(
320 &mut digest,
321 crate::graphql_review::GRAPHQL_REVIEW_STRATEGY_ID,
322 );
323 digest_field(&mut digest, "anonymous");
324 Self::new(format!(
325 "{GRAPHQL_SUBJECT_IDENTITY_VERSION}:{:x}",
326 digest.finalize()
327 ))
328 }
329
330 fn as_str(&self) -> &str {
331 &self.0
332 }
333}
334
335fn valid_stable_product_identity(value: &str) -> bool {
336 !value.is_empty()
337 && value.len() <= MAX_STABLE_SUBJECT_ID_BYTES
338 && value.trim() == value
339 && value.bytes().all(|byte| {
340 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'@' | b'-')
341 })
342}
343
344impl fmt::Debug for StableAssessmentSubjectId {
345 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
346 formatter.write_str("StableAssessmentSubjectId(<host-approved>)")
347 }
348}
349
350#[derive(Clone, PartialEq, Eq)]
352pub(crate) enum AssessmentItemTarget {
353 Subject,
354 QueryParameter(String),
355 #[cfg(feature = "ssrf-oast-review")]
356 SsrfOastQuery(String),
357 #[cfg(feature = "authorization-review")]
358 AuthorizationResource(String),
359 #[cfg(feature = "openapi-review")]
360 OpenApiDocument(String),
361 #[cfg(feature = "rest-review")]
362 RestOperation(String),
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370enum AssessmentItemPresentationTarget {
371 AssessmentSubject,
372 QueryParameter,
373 #[cfg(feature = "ssrf-oast-review")]
374 SsrfOastQuery,
375 #[cfg(feature = "authorization-review")]
376 AuthorizationResource,
377 #[cfg(feature = "openapi-review")]
378 OpenApiDocument,
379 #[cfg(feature = "rest-review")]
380 RestOperation,
381}
382
383impl AssessmentItemPresentationTarget {
384 const fn as_str(self) -> &'static str {
385 match self {
386 Self::AssessmentSubject => "assessment_subject",
387 Self::QueryParameter => "query_parameter",
388 #[cfg(feature = "ssrf-oast-review")]
389 Self::SsrfOastQuery => "ssrf_oast_query",
390 #[cfg(feature = "authorization-review")]
391 Self::AuthorizationResource => "authorization_resource",
392 #[cfg(feature = "openapi-review")]
393 Self::OpenApiDocument => "openapi_document",
394 #[cfg(feature = "rest-review")]
395 Self::RestOperation => "rest_operation",
396 }
397 }
398}
399
400impl AssessmentItemTarget {
401 pub(crate) const fn subject() -> Self {
402 Self::Subject
403 }
404
405 const fn presentation_target(&self) -> AssessmentItemPresentationTarget {
406 match self {
407 Self::Subject => AssessmentItemPresentationTarget::AssessmentSubject,
408 Self::QueryParameter(_) => AssessmentItemPresentationTarget::QueryParameter,
409 #[cfg(feature = "ssrf-oast-review")]
410 Self::SsrfOastQuery(_) => AssessmentItemPresentationTarget::SsrfOastQuery,
411 #[cfg(feature = "authorization-review")]
412 Self::AuthorizationResource(_) => {
413 AssessmentItemPresentationTarget::AuthorizationResource
414 },
415 #[cfg(feature = "openapi-review")]
416 Self::OpenApiDocument(_) => AssessmentItemPresentationTarget::OpenApiDocument,
417 #[cfg(feature = "rest-review")]
418 Self::RestOperation(_) => AssessmentItemPresentationTarget::RestOperation,
419 }
420 }
421
422 pub(crate) fn query_parameter(
423 name: impl Into<String>,
424 ) -> Result<Self, AssessmentItemProjectionError> {
425 let name = name.into();
426 if name.is_empty()
427 || name.len() > MAX_QUERY_PARAMETER_NAME_BYTES
428 || name.chars().any(char::is_control)
429 {
430 return Err(AssessmentItemProjectionError::InvalidQueryParameterTarget);
431 }
432 Ok(Self::QueryParameter(name))
433 }
434
435 #[cfg(feature = "ssrf-oast-review")]
436 pub(crate) fn ssrf_oast_query(
437 identity: impl Into<String>,
438 ) -> Result<Self, AssessmentItemProjectionError> {
439 let identity = identity.into();
440 let Some(digest) = identity.strip_prefix("ssrf-oast-parameter-sha256:") else {
441 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
442 };
443 if digest.len() != 64
444 || !digest
445 .bytes()
446 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
447 {
448 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
449 }
450 Ok(Self::SsrfOastQuery(identity))
451 }
452
453 #[cfg(feature = "authorization-review")]
454 pub(crate) fn authorization_resource(
455 identity: impl Into<String>,
456 ) -> Result<Self, AssessmentItemProjectionError> {
457 let identity = identity.into();
458 if !valid_stable_product_identity(&identity) {
459 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
460 }
461 Ok(Self::AuthorizationResource(identity))
462 }
463
464 #[cfg(feature = "openapi-review")]
465 pub(crate) fn openapi_document(
466 identity: impl Into<String>,
467 ) -> Result<Self, AssessmentItemProjectionError> {
468 let identity = identity.into();
469 if !valid_stable_product_identity(&identity) {
470 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
471 }
472 Ok(Self::OpenApiDocument(identity))
473 }
474
475 #[cfg(feature = "rest-review")]
476 pub(crate) fn rest_operation(
477 identity: impl Into<String>,
478 ) -> Result<Self, AssessmentItemProjectionError> {
479 let identity = identity.into();
480 if !valid_stable_product_identity(&identity) {
481 return Err(AssessmentItemProjectionError::InvalidStableSubjectIdentity);
482 }
483 Ok(Self::RestOperation(identity))
484 }
485}
486
487impl fmt::Debug for AssessmentItemTarget {
488 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
489 match self {
490 Self::Subject => formatter.write_str("AssessmentItemTarget::Subject"),
491 Self::QueryParameter(_) => {
492 formatter.write_str("AssessmentItemTarget::QueryParameter(<name-only>)")
493 },
494 #[cfg(feature = "ssrf-oast-review")]
495 Self::SsrfOastQuery(_) => {
496 formatter.write_str("AssessmentItemTarget::SsrfOastQuery(<stable-digest>)")
497 },
498 #[cfg(feature = "authorization-review")]
499 Self::AuthorizationResource(_) => {
500 formatter.write_str("AssessmentItemTarget::AuthorizationResource(<stable-digest>)")
501 },
502 #[cfg(feature = "openapi-review")]
503 Self::OpenApiDocument(_) => {
504 formatter.write_str("AssessmentItemTarget::OpenApiDocument(<stable-digest>)")
505 },
506 #[cfg(feature = "rest-review")]
507 Self::RestOperation(_) => {
508 formatter.write_str("AssessmentItemTarget::RestOperation(<stable-digest>)")
509 },
510 }
511 }
512}
513
514#[cfg(test)]
515#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
516struct RuntimeOutcomeIdentity {
517 subject: EntityId,
518 case_id: String,
519 action_id: String,
520 hypothesis_id: String,
521 verifier_rule_id: Option<String>,
522 stage: &'static str,
523 status: OutcomeStatus,
524 confidence: Probability,
525 evidence_ids: BTreeSet<EvidenceId>,
526}
527
528#[cfg(test)]
529impl RuntimeOutcomeIdentity {
530 fn from_outcome(outcome: &Outcome) -> Self {
531 Self {
532 subject: outcome.subject().clone(),
533 case_id: outcome.case_id().to_owned(),
534 action_id: outcome.action_id().to_owned(),
535 hypothesis_id: outcome.hypothesis_id().to_owned(),
536 verifier_rule_id: outcome.verifier_rule_id().map(str::to_owned),
537 stage: outcome.stage().as_str(),
538 status: outcome.status(),
539 confidence: outcome.confidence(),
540 evidence_ids: outcome.evidence_ids().clone(),
541 }
542 }
543}
544
545#[derive(Clone, PartialEq, Eq)]
546struct SubjectProjection {
547 reference: AssessmentSubjectReference,
548 stable_id: StableAssessmentSubjectId,
549 query_parameter_names: BTreeSet<String>,
550}
551
552#[derive(Clone, PartialEq, Eq)]
553struct EvidenceProjection {
554 reference: AssessmentEvidenceReference,
555 subject: EntityId,
556}
557
558pub(crate) struct AssessmentItemSet {
565 #[cfg(feature = "reporting")]
566 stable_scope_id: StableAssessmentScopeId,
567 #[cfg(feature = "reporting")]
568 evidence_references: BTreeMap<EvidenceId, AssessmentEvidenceReference>,
569 subjects: Vec<AssessmentSubjectInventoryEntry>,
570 items: Vec<AssessmentItem>,
571}
572
573#[derive(Clone, PartialEq, Eq)]
574pub(crate) struct AssessmentSubjectInventoryEntry {
575 reference: AssessmentSubjectReference,
576 fingerprint: String,
577}
578
579impl AssessmentSubjectInventoryEntry {
580 pub(crate) const fn reference(&self) -> AssessmentSubjectReference {
581 self.reference
582 }
583
584 #[cfg(any(feature = "reporting", test))]
585 pub(crate) fn fingerprint(&self) -> &str {
586 &self.fingerprint
587 }
588}
589
590impl fmt::Debug for AssessmentSubjectInventoryEntry {
591 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
592 formatter
593 .debug_struct("AssessmentSubjectInventoryEntry")
594 .field("reference", &self.reference)
595 .field("fingerprint", &"<stable-digest>")
596 .finish()
597 }
598}
599
600impl AssessmentItemSet {
601 pub(crate) fn items(&self) -> &[AssessmentItem] {
602 &self.items
603 }
604
605 #[cfg(feature = "reporting")]
606 pub(crate) fn matches_exact_origin(&self, value: &str) -> bool {
607 self.stable_scope_id.matches_exact_origin(value)
608 }
609
610 #[cfg(feature = "reporting")]
616 pub(crate) fn contains_stable_subject(&self, stable_identity: &str) -> bool {
617 let Ok(stable_identity) = StableAssessmentSubjectId::new(stable_identity) else {
618 return false;
619 };
620 let expected = assessment_subject_fingerprint(&self.stable_scope_id, &stable_identity);
621 self.subjects.iter().any(|subject| {
622 subject.reference() == AssessmentSubjectReference::new(0)
623 && subject.fingerprint() == expected
624 })
625 }
626
627 #[cfg(test)]
628 pub(crate) fn into_parts(self) -> (Vec<AssessmentSubjectInventoryEntry>, Vec<AssessmentItem>) {
629 (self.subjects, self.items)
630 }
631
632 #[cfg(feature = "reporting")]
633 pub(crate) fn into_report_parts(
634 self,
635 ) -> (
636 Vec<AssessmentSubjectInventoryEntry>,
637 Vec<AssessmentItem>,
638 BTreeMap<EvidenceId, AssessmentEvidenceReference>,
639 ) {
640 (self.subjects, self.items, self.evidence_references)
641 }
642}
643
644impl fmt::Debug for AssessmentItemSet {
645 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
646 formatter
647 .debug_struct("AssessmentItemSet")
648 .field("subject_count", &self.subjects.len())
649 .field("item_count", &self.items.len())
650 .finish()
651 }
652}
653
654pub(crate) struct AssessmentProjectionContext {
657 knowledge_authority: KnowledgeAuthority,
658 stable_scope_id: StableAssessmentScopeId,
659 subjects: BTreeMap<EntityId, SubjectProjection>,
660 stable_subject_ids: BTreeSet<StableAssessmentSubjectId>,
661 #[cfg(test)]
662 cases: BTreeMap<(EntityId, String), AssessmentCaseReference>,
663 #[cfg(test)]
664 outcomes: BTreeMap<RuntimeOutcomeIdentity, AssessmentOutcomeReference>,
665 evidence: BTreeMap<EvidenceId, EvidenceProjection>,
666 items: Vec<AssessmentItem>,
667}
668
669impl fmt::Debug for AssessmentProjectionContext {
670 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
671 let mut debug = formatter.debug_struct("AssessmentProjectionContext");
672 debug.field("subject_count", &self.subjects.len());
673 #[cfg(test)]
674 debug
675 .field("case_count", &self.cases.len())
676 .field("outcome_count", &self.outcomes.len());
677 debug
678 .field("evidence_count", &self.evidence.len())
679 .field("item_count", &self.items.len())
680 .finish()
681 }
682}
683
684impl AssessmentProjectionContext {
685 pub(crate) fn new(knowledge: &KnowledgeBase, stable_scope_id: StableAssessmentScopeId) -> Self {
686 Self {
687 knowledge_authority: knowledge.authority().clone(),
688 stable_scope_id,
689 subjects: BTreeMap::new(),
690 stable_subject_ids: BTreeSet::new(),
691 #[cfg(test)]
692 cases: BTreeMap::new(),
693 #[cfg(test)]
694 outcomes: BTreeMap::new(),
695 evidence: BTreeMap::new(),
696 items: Vec::new(),
697 }
698 }
699
700 pub(crate) fn register_subject(
701 &mut self,
702 subject: EntityId,
703 stable_id: StableAssessmentSubjectId,
704 query_parameter_names: impl IntoIterator<Item = String>,
705 ) -> Result<AssessmentSubjectReference, AssessmentItemProjectionError> {
706 check_projection_limit("subjects", self.subjects.len(), MAX_PROJECTION_SUBJECTS)?;
707 validate_runtime_identity(subject.as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
708 if self.subjects.contains_key(&subject) {
709 return Err(AssessmentItemProjectionError::DuplicateSubjectMapping);
710 }
711 if self.stable_subject_ids.contains(&stable_id) {
712 return Err(AssessmentItemProjectionError::DuplicateStableSubjectIdentity);
713 }
714 let mut names = BTreeSet::new();
715 for name in query_parameter_names {
716 check_projection_limit(
717 "query_parameter_names",
718 names.len(),
719 MAX_PROJECTION_QUERY_NAMES_PER_SUBJECT,
720 )?;
721 let AssessmentItemTarget::QueryParameter(name) =
722 AssessmentItemTarget::query_parameter(name)?
723 else {
724 unreachable!("query-parameter constructor returned another target")
725 };
726 if !names.insert(name) {
727 return Err(AssessmentItemProjectionError::DuplicateQueryParameterTarget);
728 }
729 }
730 let reference =
731 AssessmentSubjectReference::new(next_ordinal(self.subjects.len(), "subject")?);
732 self.stable_subject_ids.insert(stable_id.clone());
733 self.subjects.insert(
734 subject,
735 SubjectProjection {
736 reference,
737 stable_id,
738 query_parameter_names: names,
739 },
740 );
741 Ok(reference)
742 }
743
744 #[cfg(any(feature = "ssrf-oast-review", feature = "xml-external-entity-review"))]
745 pub(super) fn has_subject(&self, subject: &EntityId) -> bool {
746 self.subjects.contains_key(subject)
747 }
748
749 #[cfg(test)]
750 pub(crate) fn register_case(
751 &mut self,
752 subject: &EntityId,
753 case_id: impl Into<String>,
754 ) -> Result<AssessmentCaseReference, AssessmentItemProjectionError> {
755 check_projection_limit("cases", self.cases.len(), MAX_PROJECTION_CASES)?;
756 if !self.subjects.contains_key(subject) {
757 return Err(AssessmentItemProjectionError::UnknownSubjectMapping);
758 }
759 validate_runtime_identity(subject.as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
760 let case_id = case_id.into();
761 validate_runtime_identity(&case_id, MAX_PROJECTION_RUNTIME_ID_BYTES)?;
762 let identity = (subject.clone(), case_id);
763 if self.cases.contains_key(&identity) {
764 return Err(AssessmentItemProjectionError::DuplicateCaseMapping);
765 }
766 let reference = AssessmentCaseReference::new(next_ordinal(self.cases.len(), "case")?);
767 self.cases.insert(identity, reference);
768 Ok(reference)
769 }
770
771 #[cfg(test)]
772 pub(crate) fn register_outcome(
773 &mut self,
774 outcome: &Outcome,
775 ) -> Result<AssessmentOutcomeReference, AssessmentItemProjectionError> {
776 check_projection_limit("outcomes", self.outcomes.len(), MAX_PROJECTION_OUTCOMES)?;
777 preflight_ordered_evidence_ids(outcome.evidence_ids())?;
778 validate_outcome_identity(outcome)?;
779 if !self.subjects.contains_key(outcome.subject()) {
780 return Err(AssessmentItemProjectionError::UnknownSubjectMapping);
781 }
782 if !self
783 .cases
784 .contains_key(&(outcome.subject().clone(), outcome.case_id().to_owned()))
785 {
786 return Err(AssessmentItemProjectionError::UnknownCaseMapping);
787 }
788 let identity = RuntimeOutcomeIdentity::from_outcome(outcome);
789 if self.outcomes.contains_key(&identity) {
790 return Err(AssessmentItemProjectionError::DuplicateOutcomeMapping);
791 }
792 let reference =
793 AssessmentOutcomeReference::new(next_ordinal(self.outcomes.len(), "outcome")?);
794 self.outcomes.insert(identity, reference);
795 Ok(reference)
796 }
797
798 pub(crate) fn register_evidence(
799 &mut self,
800 knowledge: &KnowledgeBase,
801 evidence_id: &EvidenceId,
802 ) -> Result<AssessmentEvidenceReference, AssessmentItemProjectionError> {
803 check_projection_limit("evidence", self.evidence.len(), MAX_PROJECTION_EVIDENCE)?;
804 self.validate_knowledge_authority(knowledge)?;
805 let evidence = knowledge
806 .evidence(evidence_id)
807 .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
808 validate_runtime_identity(evidence_id.as_str(), MAX_PROJECTION_RUNTIME_ID_BYTES)?;
809 validate_runtime_identity(evidence.subject().as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
810 if !self.subjects.contains_key(evidence.subject()) {
811 return Err(AssessmentItemProjectionError::UnknownSubjectMapping);
812 }
813 if self.evidence.contains_key(evidence.id()) {
814 return Err(AssessmentItemProjectionError::DuplicateEvidenceMapping);
815 }
816 let reference =
817 AssessmentEvidenceReference::new(next_ordinal(self.evidence.len(), "evidence")?);
818 self.evidence.insert(
819 evidence.id().clone(),
820 EvidenceProjection {
821 reference,
822 subject: evidence.subject().clone(),
823 },
824 );
825 Ok(reference)
826 }
827
828 #[cfg(feature = "wordpress-review")]
829 pub(super) fn has_evidence(&self, evidence_id: &EvidenceId) -> bool {
830 self.evidence.contains_key(evidence_id)
831 }
832
833 #[cfg(test)]
834 pub(crate) fn registered_evidence_count(&self) -> usize {
835 self.evidence.len()
836 }
837
838 #[cfg(test)]
839 pub(crate) fn evidence_reference_for(
840 &self,
841 evidence_id: &EvidenceId,
842 ) -> Option<AssessmentEvidenceReference> {
843 self.evidence
844 .get(evidence_id)
845 .map(|projection| projection.reference)
846 }
847
848 pub(super) fn preflight_evidence_projection(
849 &self,
850 knowledge: &KnowledgeBase,
851 subject: &EntityId,
852 target: &AssessmentItemTarget,
853 evidence_ids: &[EvidenceId],
854 ) -> Result<(), AssessmentItemProjectionError> {
855 self.validate_knowledge_authority(knowledge)?;
856 self.subject(subject, target)?;
857 preflight_evidence_ids(evidence_ids)?;
858 for evidence_id in evidence_ids {
859 let evidence = knowledge
860 .evidence(evidence_id)
861 .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
862 validate_runtime_identity(
863 evidence.subject().as_str(),
864 MAX_PROJECTION_SUBJECT_ID_BYTES,
865 )?;
866 if evidence.subject() != subject {
867 return Err(AssessmentItemProjectionError::EvidenceMappingMismatch);
868 }
869 }
870 Ok(())
871 }
872
873 pub(crate) fn project_observation(
874 &mut self,
875 capability: &'static AssessmentCapabilityDescriptor,
876 knowledge: &KnowledgeBase,
877 subject: &EntityId,
878 target: &AssessmentItemTarget,
879 evidence_ids: &[EvidenceId],
880 ) -> Result<(), AssessmentItemProjectionError> {
881 check_projection_limit("items", self.items.len(), MAX_ASSESSMENT_ITEM_SET_ITEMS)?;
882 let item = AssessmentItem::from_observation(
883 capability,
884 self,
885 knowledge,
886 subject,
887 target,
888 evidence_ids,
889 )?;
890 self.push_item(item);
891 Ok(())
892 }
893
894 pub(super) fn project_differential(
899 &mut self,
900 capability: &'static AssessmentCapabilityDescriptor,
901 knowledge: &KnowledgeBase,
902 subject: &EntityId,
903 target: &AssessmentItemTarget,
904 control_ids: &[EvidenceId],
905 candidate_ids: &[EvidenceId],
906 ) -> Result<(), AssessmentItemProjectionError> {
907 check_projection_limit("items", self.items.len(), MAX_ASSESSMENT_ITEM_SET_ITEMS)?;
908 let item = AssessmentItem::from_differential(
909 capability,
910 self,
911 knowledge,
912 subject,
913 target,
914 control_ids,
915 candidate_ids,
916 )?;
917 self.push_item(item);
918 Ok(())
919 }
920
921 pub(super) fn project_api_visibility_paired_comparison(
926 &mut self,
927 capability: &'static AssessmentCapabilityDescriptor,
928 knowledge: &KnowledgeBase,
929 authorized_root_subject: &EntityId,
930 commit: &ApiObservationCommitReceipt,
931 review: &ApiVisibilityReview,
932 ) -> Result<(), AssessmentItemProjectionError> {
933 check_projection_limit("items", self.items.len(), MAX_ASSESSMENT_ITEM_SET_ITEMS)?;
934 check_projection_limit("evidence", self.evidence.len(), MAX_PROJECTION_EVIDENCE)?;
935 self.validate_knowledge_authority(knowledge)?;
936 if !capability.allows_differential_review()
937 || commit.resource_scope() != authorized_root_subject
938 || review.resource_scope() != authorized_root_subject
939 || review.comparison_subject() != commit.comparison_subject()
940 || review.relation_id() != commit.relation_id()
941 || review.evidence().id() != commit.evidence_id()
942 || review.evidence().subject() != commit.comparison_subject()
943 || review.disposition() != ApiVisibilityReviewDisposition::AwaitHumanReview
944 || review.boundary_hypotheses().len() != 1
945 || review.evidence().predicate()
946 != &ApiEvidencePredicate::JSON_AUTHORIZATION_CONTEXT_DIFFERENCE.into_knowledge()
947 || review.evidence().value() != &EvidenceValue::from(ApiVisibilityDimension::Fields)
948 {
949 return Err(AssessmentItemProjectionError::ApiVisibilityReviewContractMismatch);
950 }
951 let canonical = api_visibility_review_for_commit(knowledge, commit)
952 .ok_or(AssessmentItemProjectionError::ApiVisibilityReviewContractMismatch)?;
953 if &canonical != review {
954 return Err(AssessmentItemProjectionError::ApiVisibilityReviewContractMismatch);
955 }
956 validate_runtime_identity(
957 commit.comparison_subject().as_str(),
958 MAX_PROJECTION_SUBJECT_ID_BYTES,
959 )?;
960 validate_runtime_identity(
961 commit.evidence_id().as_str(),
962 MAX_PROJECTION_RUNTIME_ID_BYTES,
963 )?;
964 if self.evidence.contains_key(commit.evidence_id()) {
965 return Err(AssessmentItemProjectionError::DuplicateEvidenceMapping);
966 }
967 let committed = knowledge
968 .evidence(commit.evidence_id())
969 .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
970 if &committed != review.evidence() || committed.subject() != commit.comparison_subject() {
971 return Err(AssessmentItemProjectionError::EvidenceMappingMismatch);
972 }
973 let target = AssessmentItemTarget::subject();
974 let subject_projection = self.subject(authorized_root_subject, &target)?;
975 let confidence = bounded_observation_confidence(
976 capability,
977 knowledge,
978 std::iter::once(commit.evidence_id()),
979 )?;
980 let reference =
981 AssessmentEvidenceReference::new(next_ordinal(self.evidence.len(), "evidence")?);
982 let item = AssessmentItem::build(
983 capability,
984 self.stable_scope_id(),
985 subject_projection,
986 &target,
987 confidence,
988 AssessmentBasis::Differential(AssessmentDifferentialBasis {
989 evidence: AssessmentDifferentialEvidence::PairedComparison(reference),
990 }),
991 );
992 self.evidence.insert(
993 commit.evidence_id().clone(),
994 EvidenceProjection {
995 reference,
996 subject: commit.comparison_subject().clone(),
997 },
998 );
999 self.push_item(item);
1000 Ok(())
1001 }
1002
1003 #[cfg(all(test, feature = "scanning"))]
1004 pub(crate) fn project_verifier(
1005 &mut self,
1006 capability: &'static AssessmentCapabilityDescriptor,
1007 target: &AssessmentItemTarget,
1008 receipt: &DecisionEvidenceReceipt,
1009 decision: &DecisionOutcomeReport,
1010 knowledge: &KnowledgeBase,
1011 ) -> Result<(), AssessmentItemProjectionError> {
1012 check_projection_limit("items", self.items.len(), MAX_ASSESSMENT_ITEM_SET_ITEMS)?;
1013 let item = AssessmentItem::from_verifier_projection(
1014 capability, self, target, receipt, decision, knowledge,
1015 )?;
1016 self.push_item(item);
1017 Ok(())
1018 }
1019
1020 pub(crate) fn finish(self) -> AssessmentItemSet {
1021 let mut subjects = self
1022 .subjects
1023 .values()
1024 .map(|subject| AssessmentSubjectInventoryEntry {
1025 reference: subject.reference,
1026 fingerprint: assessment_subject_fingerprint(
1027 &self.stable_scope_id,
1028 &subject.stable_id,
1029 ),
1030 })
1031 .collect::<Vec<_>>();
1032 subjects.sort_unstable_by_key(AssessmentSubjectInventoryEntry::reference);
1033 AssessmentItemSet {
1034 #[cfg(feature = "reporting")]
1035 stable_scope_id: self.stable_scope_id,
1036 #[cfg(feature = "reporting")]
1037 evidence_references: self
1038 .evidence
1039 .into_iter()
1040 .map(|(evidence_id, projection)| (evidence_id, projection.reference))
1041 .collect(),
1042 subjects,
1043 items: self.items,
1044 }
1045 }
1046
1047 fn push_item(&mut self, item: AssessmentItem) {
1048 self.items.push(item);
1049 }
1050
1051 fn subject(
1052 &self,
1053 subject: &EntityId,
1054 target: &AssessmentItemTarget,
1055 ) -> Result<&SubjectProjection, AssessmentItemProjectionError> {
1056 let projection = self
1057 .subjects
1058 .get(subject)
1059 .ok_or(AssessmentItemProjectionError::UnknownSubjectMapping)?;
1060 if let AssessmentItemTarget::QueryParameter(name) = target {
1061 if !projection.query_parameter_names.contains(name) {
1062 return Err(AssessmentItemProjectionError::UnknownQueryParameterTarget);
1063 }
1064 }
1065 Ok(projection)
1066 }
1067
1068 fn stable_scope_id(&self) -> &StableAssessmentScopeId {
1069 &self.stable_scope_id
1070 }
1071
1072 #[cfg(test)]
1073 fn case_reference(
1074 &self,
1075 subject: &EntityId,
1076 case_id: &str,
1077 ) -> Result<AssessmentCaseReference, AssessmentItemProjectionError> {
1078 self.cases
1079 .get(&(subject.clone(), case_id.to_owned()))
1080 .copied()
1081 .ok_or(AssessmentItemProjectionError::UnknownCaseMapping)
1082 }
1083
1084 #[cfg(test)]
1085 fn outcome_reference(
1086 &self,
1087 outcome: &Outcome,
1088 ) -> Result<AssessmentOutcomeReference, AssessmentItemProjectionError> {
1089 self.outcomes
1090 .get(&RuntimeOutcomeIdentity::from_outcome(outcome))
1091 .copied()
1092 .ok_or(AssessmentItemProjectionError::UnknownOutcomeMapping)
1093 }
1094
1095 fn evidence_references(
1096 &self,
1097 knowledge: &KnowledgeBase,
1098 subject: &EntityId,
1099 evidence_ids: &[EvidenceId],
1100 ) -> Result<Vec<AssessmentEvidenceReference>, AssessmentItemProjectionError> {
1101 self.validate_knowledge_authority(knowledge)?;
1102 validate_runtime_identity(subject.as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
1103 preflight_evidence_ids(evidence_ids)?;
1104 evidence_ids
1105 .iter()
1106 .map(|evidence_id| {
1107 let projection = self
1108 .evidence
1109 .get(evidence_id)
1110 .ok_or(AssessmentItemProjectionError::UnknownEvidenceMapping)?;
1111 if &projection.subject != subject {
1112 return Err(AssessmentItemProjectionError::EvidenceSubjectMappingMismatch);
1113 }
1114 let committed = knowledge
1115 .evidence(evidence_id)
1116 .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
1117 if committed.subject() != subject {
1118 return Err(AssessmentItemProjectionError::EvidenceMappingMismatch);
1119 }
1120 Ok(projection.reference)
1121 })
1122 .collect()
1123 }
1124
1125 fn validate_knowledge_authority(
1126 &self,
1127 knowledge: &KnowledgeBase,
1128 ) -> Result<(), AssessmentItemProjectionError> {
1129 if knowledge.authority().is_same_as(&self.knowledge_authority) {
1130 Ok(())
1131 } else {
1132 Err(AssessmentItemProjectionError::KnowledgeAuthorityMismatch)
1133 }
1134 }
1135}
1136
1137#[cfg(test)]
1138fn validate_outcome_identity(outcome: &Outcome) -> Result<(), AssessmentItemProjectionError> {
1139 validate_runtime_identity(outcome.subject().as_str(), MAX_PROJECTION_SUBJECT_ID_BYTES)?;
1140 for identity in [
1141 outcome.case_id(),
1142 outcome.action_id(),
1143 outcome.hypothesis_id(),
1144 ] {
1145 validate_runtime_identity(identity, MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1146 }
1147 if let Some(verifier_rule_id) = outcome.verifier_rule_id() {
1148 validate_runtime_identity(verifier_rule_id, MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1149 }
1150 for evidence_id in outcome.evidence_ids() {
1151 validate_runtime_identity(evidence_id.as_str(), MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1152 }
1153 Ok(())
1154}
1155
1156fn validate_runtime_identity(
1157 identity: &str,
1158 maximum: usize,
1159) -> Result<(), AssessmentItemProjectionError> {
1160 if identity.is_empty() || identity.len() > maximum {
1161 Err(AssessmentItemProjectionError::InvalidRuntimeIdentity)
1162 } else {
1163 Ok(())
1164 }
1165}
1166
1167fn preflight_evidence_ids(
1168 evidence_ids: &[EvidenceId],
1169) -> Result<(), AssessmentItemProjectionError> {
1170 if evidence_ids.is_empty() {
1171 return Err(AssessmentItemProjectionError::MissingEvidence);
1172 }
1173 if evidence_ids.len() > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES {
1174 return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1175 }
1176 let mut seen = BTreeSet::new();
1177 for evidence_id in evidence_ids {
1178 validate_runtime_identity(evidence_id.as_str(), MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1179 if !seen.insert(evidence_id) {
1180 return Err(AssessmentItemProjectionError::DuplicateEvidenceReference);
1181 }
1182 }
1183 Ok(())
1184}
1185
1186#[cfg(test)]
1187fn preflight_ordered_evidence_ids(
1188 evidence_ids: &BTreeSet<EvidenceId>,
1189) -> Result<(), AssessmentItemProjectionError> {
1190 if evidence_ids.is_empty() {
1191 return Err(AssessmentItemProjectionError::MissingEvidence);
1192 }
1193 if evidence_ids.len() > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES {
1194 return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1195 }
1196 for evidence_id in evidence_ids {
1197 validate_runtime_identity(evidence_id.as_str(), MAX_PROJECTION_RUNTIME_ID_BYTES)?;
1198 }
1199 Ok(())
1200}
1201
1202fn next_ordinal(
1203 current_len: usize,
1204 kind: &'static str,
1205) -> Result<u32, AssessmentItemProjectionError> {
1206 u32::try_from(current_len)
1207 .map_err(|_| AssessmentItemProjectionError::ReferenceSpaceExhausted { kind })
1208}
1209
1210fn check_projection_limit(
1211 dimension: &'static str,
1212 current_len: usize,
1213 maximum: usize,
1214) -> Result<(), AssessmentItemProjectionError> {
1215 if current_len >= maximum {
1216 Err(AssessmentItemProjectionError::ProjectionContextLimit { dimension, maximum })
1217 } else {
1218 Ok(())
1219 }
1220}
1221
1222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1224pub struct AssessmentRemediation {
1225 id: &'static str,
1226 summary: &'static str,
1227}
1228
1229impl AssessmentRemediation {
1230 pub const fn id(self) -> &'static str {
1232 self.id
1233 }
1234
1235 pub const fn summary(self) -> &'static str {
1237 self.summary
1238 }
1239}
1240
1241#[derive(Debug, Clone, PartialEq, Eq)]
1243pub struct AssessmentObservationBasis {
1244 evidence: Vec<AssessmentEvidenceReference>,
1245}
1246
1247impl AssessmentObservationBasis {
1248 pub fn evidence(&self) -> &[AssessmentEvidenceReference] {
1250 &self.evidence
1251 }
1252}
1253
1254#[derive(Debug, Clone, PartialEq, Eq)]
1255enum AssessmentDifferentialEvidence {
1256 MatchedPair {
1257 control: Vec<AssessmentEvidenceReference>,
1258 candidate: Vec<AssessmentEvidenceReference>,
1259 },
1260 PairedComparison(AssessmentEvidenceReference),
1261}
1262
1263#[derive(Debug, Clone, PartialEq, Eq)]
1265pub struct AssessmentDifferentialBasis {
1266 evidence: AssessmentDifferentialEvidence,
1267}
1268
1269impl AssessmentDifferentialBasis {
1270 pub fn control(&self) -> &[AssessmentEvidenceReference] {
1272 match &self.evidence {
1273 AssessmentDifferentialEvidence::MatchedPair { control, .. } => control,
1274 AssessmentDifferentialEvidence::PairedComparison(_) => &[],
1275 }
1276 }
1277
1278 pub fn candidate(&self) -> &[AssessmentEvidenceReference] {
1280 match &self.evidence {
1281 AssessmentDifferentialEvidence::MatchedPair { candidate, .. } => candidate,
1282 AssessmentDifferentialEvidence::PairedComparison(_) => &[],
1283 }
1284 }
1285
1286 pub const fn paired_comparison(&self) -> Option<AssessmentEvidenceReference> {
1289 match self.evidence {
1290 AssessmentDifferentialEvidence::PairedComparison(reference) => Some(reference),
1291 AssessmentDifferentialEvidence::MatchedPair { .. } => None,
1292 }
1293 }
1294
1295 fn evidence_count(&self) -> usize {
1296 match &self.evidence {
1297 AssessmentDifferentialEvidence::MatchedPair { control, candidate } => {
1298 control.len() + candidate.len()
1299 },
1300 AssessmentDifferentialEvidence::PairedComparison(_) => 1,
1301 }
1302 }
1303}
1304
1305#[derive(Debug, Clone, PartialEq, Eq)]
1307pub struct AssessmentVerifierBasis {
1308 case_reference: AssessmentCaseReference,
1309 outcome_reference: AssessmentOutcomeReference,
1310 verifier_rule_id: &'static str,
1311 stage: VerificationStage,
1312 evidence: Vec<AssessmentEvidenceReference>,
1313}
1314
1315impl AssessmentVerifierBasis {
1316 pub const fn case_reference(&self) -> AssessmentCaseReference {
1318 self.case_reference
1319 }
1320
1321 pub const fn outcome_reference(&self) -> AssessmentOutcomeReference {
1323 self.outcome_reference
1324 }
1325
1326 pub const fn verifier_rule_id(&self) -> &'static str {
1328 self.verifier_rule_id
1329 }
1330
1331 pub const fn stage(&self) -> VerificationStage {
1333 self.stage
1334 }
1335
1336 pub fn evidence(&self) -> &[AssessmentEvidenceReference] {
1338 &self.evidence
1339 }
1340}
1341
1342#[derive(Debug, Clone, PartialEq, Eq)]
1344#[non_exhaustive]
1345pub enum AssessmentBasis {
1346 Observation(AssessmentObservationBasis),
1348 Differential(AssessmentDifferentialBasis),
1350 Verifier(AssessmentVerifierBasis),
1352}
1353
1354impl AssessmentBasis {
1355 const fn disposition(&self) -> AssessmentDisposition {
1356 match self {
1357 Self::Observation(_) => AssessmentDisposition::Informational,
1358 Self::Differential(_) => AssessmentDisposition::NeedsReview,
1359 Self::Verifier(_) => AssessmentDisposition::Confirmed,
1360 }
1361 }
1362
1363 pub fn evidence_count(&self) -> usize {
1365 match self {
1366 Self::Observation(basis) => basis.evidence.len(),
1367 Self::Differential(basis) => basis.evidence_count(),
1368 Self::Verifier(basis) => basis.evidence.len(),
1369 }
1370 }
1371
1372 pub const fn case_reference(&self) -> Option<AssessmentCaseReference> {
1374 match self {
1375 Self::Verifier(basis) => Some(basis.case_reference),
1376 Self::Observation(_) | Self::Differential(_) => None,
1377 }
1378 }
1379}
1380
1381#[derive(Debug, Clone, PartialEq, Eq)]
1387pub struct AssessmentItem {
1388 capability: &'static AssessmentCapabilityDescriptor,
1389 subject_reference: AssessmentSubjectReference,
1390 presentation_target: AssessmentItemPresentationTarget,
1391 confidence: Probability,
1392 fingerprint: String,
1393 basis: AssessmentBasis,
1394}
1395
1396impl AssessmentItem {
1397 pub const fn schema(&self) -> &'static str {
1399 ASSESSMENT_ITEM_SCHEMA
1400 }
1401
1402 pub const fn capability_id(&self) -> &'static str {
1404 self.capability.id
1405 }
1406
1407 pub const fn title(&self) -> &'static str {
1409 self.capability.title
1410 }
1411
1412 pub const fn subject_reference(&self) -> AssessmentSubjectReference {
1414 self.subject_reference
1415 }
1416
1417 pub(crate) const fn presentation_target_kind(&self) -> &'static str {
1419 self.presentation_target.as_str()
1420 }
1421
1422 pub const fn disposition(&self) -> AssessmentDisposition {
1424 self.basis.disposition()
1425 }
1426
1427 pub const fn severity(&self) -> Option<SecuritySeverity> {
1429 self.capability.severity
1430 }
1431
1432 pub const fn confidence(&self) -> Probability {
1434 self.confidence
1435 }
1436
1437 pub fn fingerprint(&self) -> &str {
1439 &self.fingerprint
1440 }
1441
1442 pub const fn basis(&self) -> &AssessmentBasis {
1444 &self.basis
1445 }
1446
1447 pub fn evidence_count(&self) -> usize {
1449 self.basis.evidence_count()
1450 }
1451
1452 pub const fn redacted_summary(&self) -> &'static str {
1454 self.capability.redacted_summary
1455 }
1456
1457 pub const fn category(&self) -> &'static str {
1459 self.capability.category
1460 }
1461
1462 pub const fn cwe(&self) -> Option<&'static str> {
1464 self.capability.cwe
1465 }
1466
1467 pub const fn remediation(&self) -> AssessmentRemediation {
1469 self.capability.remediation
1470 }
1471
1472 fn from_observation(
1473 capability: &'static AssessmentCapabilityDescriptor,
1474 context: &AssessmentProjectionContext,
1475 knowledge: &KnowledgeBase,
1476 subject: &EntityId,
1477 target: &AssessmentItemTarget,
1478 evidence_ids: &[EvidenceId],
1479 ) -> Result<Self, AssessmentItemProjectionError> {
1480 preflight_evidence_ids(evidence_ids)?;
1481 let subject_projection = context.subject(subject, target)?;
1482 let evidence = context.evidence_references(knowledge, subject, evidence_ids)?;
1483 let evidence = validate_reference_set("observation", evidence)?;
1484 let confidence = bounded_observation_confidence(capability, knowledge, evidence_ids)?;
1485 Ok(Self::build(
1486 capability,
1487 context.stable_scope_id(),
1488 subject_projection,
1489 target,
1490 confidence,
1491 AssessmentBasis::Observation(AssessmentObservationBasis { evidence }),
1492 ))
1493 }
1494
1495 fn from_differential(
1499 capability: &'static AssessmentCapabilityDescriptor,
1500 context: &AssessmentProjectionContext,
1501 knowledge: &KnowledgeBase,
1502 subject: &EntityId,
1503 target: &AssessmentItemTarget,
1504 control_ids: &[EvidenceId],
1505 candidate_ids: &[EvidenceId],
1506 ) -> Result<Self, AssessmentItemProjectionError> {
1507 if !capability.allows_differential_review() {
1508 return Err(AssessmentItemProjectionError::DispositionDenied {
1509 requested: AssessmentDisposition::NeedsReview,
1510 });
1511 }
1512 preflight_evidence_ids(control_ids)?;
1513 preflight_evidence_ids(candidate_ids)?;
1514 if control_ids.len().saturating_add(candidate_ids.len())
1515 > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES
1516 {
1517 return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1518 }
1519 let control_identity = control_ids.iter().collect::<BTreeSet<_>>();
1520 if candidate_ids
1521 .iter()
1522 .any(|evidence_id| control_identity.contains(evidence_id))
1523 {
1524 return Err(AssessmentItemProjectionError::OverlappingDifferentialEvidence);
1525 }
1526 let subject_projection = context.subject(subject, target)?;
1527 let control = context.evidence_references(knowledge, subject, control_ids)?;
1528 let candidate = context.evidence_references(knowledge, subject, candidate_ids)?;
1529 let control = validate_reference_set("differential control", control)?;
1530 let candidate = validate_reference_set("differential candidate", candidate)?;
1531 if control.len().saturating_add(candidate.len()) > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES {
1532 return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1533 }
1534 if control
1535 .iter()
1536 .any(|reference| candidate.contains(reference))
1537 {
1538 return Err(AssessmentItemProjectionError::OverlappingDifferentialEvidence);
1539 }
1540 let confidence = bounded_observation_confidence(
1541 capability,
1542 knowledge,
1543 control_ids.iter().chain(candidate_ids),
1544 )?;
1545 Ok(Self::build(
1546 capability,
1547 context.stable_scope_id(),
1548 subject_projection,
1549 target,
1550 confidence,
1551 AssessmentBasis::Differential(AssessmentDifferentialBasis {
1552 evidence: AssessmentDifferentialEvidence::MatchedPair { control, candidate },
1553 }),
1554 ))
1555 }
1556
1557 #[cfg(test)]
1558 fn from_verifier_projection(
1559 capability: &'static AssessmentCapabilityDescriptor,
1560 context: &AssessmentProjectionContext,
1561 target: &AssessmentItemTarget,
1562 receipt: &DecisionEvidenceReceipt,
1563 decision: &DecisionOutcomeReport,
1564 knowledge: &KnowledgeBase,
1565 ) -> Result<Self, AssessmentItemProjectionError> {
1566 let outcome = decision.verification().outcome();
1567 preflight_ordered_evidence_ids(outcome.evidence_ids())?;
1568 let extraction = extract_confirmation_proof(capability, receipt, decision, knowledge);
1569 extraction.proof.authorize()?;
1570
1571 validate_outcome_identity(outcome)?;
1572 let subject_projection = context.subject(outcome.subject(), target)?;
1573 let case_reference = context.case_reference(outcome.subject(), outcome.case_id())?;
1574 let outcome_reference = context.outcome_reference(outcome)?;
1575 let evidence_ids = extraction.evidence_ids.iter().cloned().collect::<Vec<_>>();
1576 let projected = context.evidence_references(knowledge, outcome.subject(), &evidence_ids)?;
1577 let evidence = validate_reference_set("verifier", projected)?;
1578 let policy = capability.verifier_policy().ok_or(
1579 AssessmentItemProjectionError::ConfirmationDenied(
1580 AssessmentConfirmationDenial::CapabilityPolicy,
1581 ),
1582 )?;
1583 let confidence =
1584 bounded_observation_confidence(capability, knowledge, extraction.evidence_ids.iter())?
1585 .min(outcome.confidence());
1586 Ok(Self::build(
1587 capability,
1588 context.stable_scope_id(),
1589 subject_projection,
1590 target,
1591 confidence,
1592 AssessmentBasis::Verifier(AssessmentVerifierBasis {
1593 case_reference,
1594 outcome_reference,
1595 verifier_rule_id: policy.verifier_rule_id,
1596 stage: policy.stage,
1597 evidence,
1598 }),
1599 ))
1600 }
1601
1602 fn build(
1603 capability: &'static AssessmentCapabilityDescriptor,
1604 stable_scope_id: &StableAssessmentScopeId,
1605 subject: &SubjectProjection,
1606 target: &AssessmentItemTarget,
1607 confidence: Probability,
1608 basis: AssessmentBasis,
1609 ) -> Self {
1610 Self {
1611 capability,
1612 subject_reference: subject.reference,
1613 presentation_target: target.presentation_target(),
1614 confidence,
1615 fingerprint: assessment_fingerprint(
1616 capability.id,
1617 stable_scope_id,
1618 &subject.stable_id,
1619 target,
1620 ),
1621 basis,
1622 }
1623 }
1624}
1625
1626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1628#[non_exhaustive]
1629pub enum AssessmentConfirmationDenial {
1630 CapabilityPolicy,
1631 ActionMismatch,
1632 HypothesisClaimMismatch,
1633 OutcomeNotSuccess,
1634 KnowledgeOnly,
1635 MissingHypothesisWrite,
1636 FinalHypothesisNotConfirmed,
1637 CaseMismatch,
1638 SelectedVerifierMismatch,
1639 MissingEvidence,
1640 EvidenceUnavailable,
1641 EvidenceSubjectMismatch,
1642 EvidenceCaseMismatch,
1643 ReceiptCaseMismatch,
1644 ReceiptStageMismatch,
1645 ReceiptDidNotContribute,
1646 ReceiptEvidenceMismatch,
1647}
1648
1649impl fmt::Display for AssessmentConfirmationDenial {
1650 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1651 formatter.write_str(match self {
1652 Self::CapabilityPolicy => "capability policy does not authorize confirmation",
1653 Self::ActionMismatch => "action identity does not match capability claim policy",
1654 Self::HypothesisClaimMismatch => {
1655 "hypothesis predicate or value does not match capability claim policy"
1656 },
1657 Self::OutcomeNotSuccess => "verifier outcome is not success",
1658 Self::KnowledgeOnly => "verification case is knowledge-only",
1659 Self::MissingHypothesisWrite => "verifier transition write is absent",
1660 Self::FinalHypothesisNotConfirmed => "final hypothesis state is not confirmed",
1661 Self::CaseMismatch => "case and outcome identities do not agree",
1662 Self::SelectedVerifierMismatch => "selected verifier does not match capability policy",
1663 Self::MissingEvidence => "verifier outcome has no evidence",
1664 Self::EvidenceUnavailable => "verifier evidence is absent from committed knowledge",
1665 Self::EvidenceSubjectMismatch => "verifier evidence belongs to another subject",
1666 Self::EvidenceCaseMismatch => "verifier evidence belongs to another case",
1667 Self::ReceiptCaseMismatch => "execution receipt belongs to another case",
1668 Self::ReceiptStageMismatch => "execution receipt stage does not match verification",
1669 Self::ReceiptDidNotContribute => {
1670 "execution receipt does not contain the required contributing evidence"
1671 },
1672 Self::ReceiptEvidenceMismatch => {
1673 "execution receipt evidence differs from the committed knowledge record"
1674 },
1675 })
1676 }
1677}
1678
1679#[derive(Debug, Clone, PartialEq, Eq, Error)]
1681#[non_exhaustive]
1682pub enum AssessmentItemProjectionError {
1683 #[error("assessment capability does not authorize {requested:?}")]
1684 DispositionDenied { requested: AssessmentDisposition },
1685 #[error("assessment item requires at least one evidence reference")]
1686 MissingEvidence,
1687 #[error("assessment item exceeds its evidence-reference limit")]
1688 TooManyEvidenceReferences,
1689 #[error("assessment item contains a duplicate evidence reference")]
1690 DuplicateEvidenceReference,
1691 #[error("differential control and candidate evidence overlap")]
1692 OverlappingDifferentialEvidence,
1693 #[error("API visibility review does not match its canonical committed comparison")]
1694 ApiVisibilityReviewContractMismatch,
1695 #[error("assessment confirmation denied: {0}")]
1696 ConfirmationDenied(AssessmentConfirmationDenial),
1697 #[error("static capability confidence is outside the fixed-point range")]
1698 InvalidCapabilityConfidence,
1699 #[error("host-approved stable subject identity is invalid")]
1700 InvalidStableSubjectIdentity,
1701 #[error("host-approved stable assessment scope identity is invalid")]
1702 InvalidStableScopeIdentity,
1703 #[error("query-parameter target identity is invalid")]
1704 InvalidQueryParameterTarget,
1705 #[error("query-parameter target is not registered for the runtime subject")]
1706 UnknownQueryParameterTarget,
1707 #[error("query-parameter target is duplicated in one subject mapping")]
1708 DuplicateQueryParameterTarget,
1709 #[error("runtime identity is invalid")]
1710 InvalidRuntimeIdentity,
1711 #[error("runtime subject has no projection mapping")]
1712 UnknownSubjectMapping,
1713 #[error("runtime verification case has no projection mapping")]
1714 UnknownCaseMapping,
1715 #[error("runtime verifier outcome has no projection mapping")]
1716 UnknownOutcomeMapping,
1717 #[error("runtime evidence has no projection mapping")]
1718 UnknownEvidenceMapping,
1719 #[error("runtime evidence was not committed to knowledge")]
1720 EvidenceNotCommitted,
1721 #[error("runtime evidence mapping differs from committed knowledge")]
1722 EvidenceMappingMismatch,
1723 #[error("projection context belongs to another knowledge authority")]
1724 KnowledgeAuthorityMismatch,
1725 #[error("runtime subject already has a projection mapping")]
1726 DuplicateSubjectMapping,
1727 #[error("stable subject identity is already bound to another runtime subject")]
1728 DuplicateStableSubjectIdentity,
1729 #[error("runtime verification case already has a projection mapping")]
1730 DuplicateCaseMapping,
1731 #[error("runtime verifier outcome already has a projection mapping")]
1732 DuplicateOutcomeMapping,
1733 #[error("runtime evidence already has a projection mapping")]
1734 DuplicateEvidenceMapping,
1735 #[error("runtime evidence projection belongs to another subject")]
1736 EvidenceSubjectMappingMismatch,
1737 #[error("assessment {kind} reference space is exhausted")]
1738 ReferenceSpaceExhausted { kind: &'static str },
1739 #[error("assessment projection {dimension} exceeds compiled maximum {maximum}")]
1740 ProjectionContextLimit {
1741 dimension: &'static str,
1742 maximum: usize,
1743 },
1744}
1745
1746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1747enum AssessmentClaimPolicy {
1748 ObservationOnly,
1749 DifferentialReview,
1750 #[cfg(test)]
1751 VerifierTransition(VerifierClaimPolicy),
1752}
1753
1754#[cfg(test)]
1755impl AssessmentClaimPolicy {
1756 const fn maximum_disposition(self) -> AssessmentDisposition {
1757 match self {
1758 Self::ObservationOnly => AssessmentDisposition::Informational,
1759 Self::DifferentialReview => AssessmentDisposition::NeedsReview,
1760 Self::VerifierTransition(_) => AssessmentDisposition::Confirmed,
1761 }
1762 }
1763}
1764
1765#[cfg(test)]
1766#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1767struct VerifierClaimPolicy {
1768 action_id: &'static str,
1769 hypothesis_namespace: &'static str,
1770 hypothesis_name: &'static str,
1771 hypothesis_value: StaticEvidenceValue,
1772 verifier_rule_id: &'static str,
1773 stage: VerificationStage,
1774}
1775
1776#[cfg(test)]
1777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1778enum StaticEvidenceValue {
1779 Boolean(bool),
1780 Unsigned(u64),
1781 Text(&'static str),
1782}
1783
1784#[cfg(test)]
1785impl StaticEvidenceValue {
1786 fn matches(self, value: &EvidenceValue) -> bool {
1787 match (self, value) {
1788 (Self::Boolean(expected), EvidenceValue::Boolean(actual)) => expected == *actual,
1789 (Self::Unsigned(expected), EvidenceValue::Unsigned(actual)) => expected == *actual,
1790 (Self::Text(expected), EvidenceValue::Text(actual)) => expected == actual.as_str(),
1791 _ => false,
1792 }
1793 }
1794}
1795
1796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1799pub(crate) struct AssessmentCapabilityDescriptor {
1800 id: &'static str,
1801 title: &'static str,
1802 category: &'static str,
1803 redacted_summary: &'static str,
1804 severity: Option<SecuritySeverity>,
1805 confidence_ppm: u32,
1806 cwe: Option<&'static str>,
1807 remediation: AssessmentRemediation,
1808 claim_policy: AssessmentClaimPolicy,
1809}
1810
1811impl AssessmentCapabilityDescriptor {
1812 #[allow(clippy::too_many_arguments)]
1815 pub(crate) const fn informational(
1816 id: &'static str,
1817 title: &'static str,
1818 category: &'static str,
1819 redacted_summary: &'static str,
1820 confidence_ppm: u32,
1821 remediation_id: &'static str,
1822 remediation_summary: &'static str,
1823 ) -> Self {
1824 Self::new(
1825 id,
1826 title,
1827 category,
1828 redacted_summary,
1829 None,
1830 confidence_ppm,
1831 None,
1832 AssessmentRemediation {
1833 id: remediation_id,
1834 summary: remediation_summary,
1835 },
1836 AssessmentClaimPolicy::ObservationOnly,
1837 )
1838 }
1839
1840 #[allow(clippy::too_many_arguments)]
1845 pub(super) const fn differential_review(
1846 id: &'static str,
1847 title: &'static str,
1848 category: &'static str,
1849 redacted_summary: &'static str,
1850 severity: Option<SecuritySeverity>,
1851 confidence_ppm: u32,
1852 cwe: Option<&'static str>,
1853 remediation_id: &'static str,
1854 remediation_summary: &'static str,
1855 ) -> Self {
1856 Self::new(
1857 id,
1858 title,
1859 category,
1860 redacted_summary,
1861 severity,
1862 confidence_ppm,
1863 cwe,
1864 AssessmentRemediation {
1865 id: remediation_id,
1866 summary: remediation_summary,
1867 },
1868 AssessmentClaimPolicy::DifferentialReview,
1869 )
1870 }
1871
1872 #[allow(clippy::too_many_arguments)]
1873 const fn new(
1874 id: &'static str,
1875 title: &'static str,
1876 category: &'static str,
1877 redacted_summary: &'static str,
1878 severity: Option<SecuritySeverity>,
1879 confidence_ppm: u32,
1880 cwe: Option<&'static str>,
1881 remediation: AssessmentRemediation,
1882 claim_policy: AssessmentClaimPolicy,
1883 ) -> Self {
1884 assert!(!id.is_empty() && id.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES);
1885 assert!(!title.is_empty() && title.len() <= MAX_ASSESSMENT_DISPLAY_BYTES);
1886 assert!(!category.is_empty() && category.len() <= MAX_ASSESSMENT_DISPLAY_BYTES);
1887 assert!(
1888 !redacted_summary.is_empty() && redacted_summary.len() <= MAX_ASSESSMENT_DISPLAY_BYTES
1889 );
1890 assert!(
1891 !remediation.id.is_empty()
1892 && remediation.id.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1893 );
1894 assert!(
1895 !remediation.summary.is_empty()
1896 && remediation.summary.len() <= MAX_ASSESSMENT_DISPLAY_BYTES
1897 );
1898 assert!(confidence_ppm <= 1_000_000);
1899 if let Some(cwe) = cwe {
1900 assert!(!cwe.is_empty() && cwe.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES);
1901 }
1902 match claim_policy {
1903 AssessmentClaimPolicy::ObservationOnly => {},
1904 AssessmentClaimPolicy::DifferentialReview => {},
1905 #[cfg(test)]
1906 AssessmentClaimPolicy::VerifierTransition(policy) => {
1907 assert!(
1908 !policy.action_id.is_empty()
1909 && policy.action_id.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1910 );
1911 assert!(
1912 !policy.hypothesis_namespace.is_empty()
1913 && policy.hypothesis_namespace.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1914 );
1915 assert!(
1916 !policy.hypothesis_name.is_empty()
1917 && policy.hypothesis_name.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1918 );
1919 assert!(
1920 !policy.verifier_rule_id.is_empty()
1921 && policy.verifier_rule_id.len() <= MAX_ASSESSMENT_CAPABILITY_ID_BYTES
1922 );
1923 if let StaticEvidenceValue::Text(value) = policy.hypothesis_value {
1924 assert!(!value.is_empty() && value.len() <= MAX_ASSESSMENT_DISPLAY_BYTES);
1925 }
1926 },
1927 }
1928 Self {
1929 id,
1930 title,
1931 category,
1932 redacted_summary,
1933 severity,
1934 confidence_ppm,
1935 cwe,
1936 remediation,
1937 claim_policy,
1938 }
1939 }
1940
1941 fn confidence(self) -> Result<Probability, AssessmentItemProjectionError> {
1942 Probability::from_parts_per_million(self.confidence_ppm)
1943 .map_err(|_| AssessmentItemProjectionError::InvalidCapabilityConfidence)
1944 }
1945
1946 #[cfg(test)]
1947 const fn maximum_disposition(self) -> AssessmentDisposition {
1948 self.claim_policy.maximum_disposition()
1949 }
1950
1951 const fn allows_differential_review(self) -> bool {
1952 match self.claim_policy {
1953 AssessmentClaimPolicy::ObservationOnly => false,
1954 AssessmentClaimPolicy::DifferentialReview => true,
1955 #[cfg(test)]
1956 AssessmentClaimPolicy::VerifierTransition(_) => true,
1957 }
1958 }
1959
1960 #[cfg(test)]
1961 const fn verifier_policy(self) -> Option<VerifierClaimPolicy> {
1962 match self.claim_policy {
1963 AssessmentClaimPolicy::VerifierTransition(policy) => Some(policy),
1964 AssessmentClaimPolicy::ObservationOnly | AssessmentClaimPolicy::DifferentialReview => {
1965 None
1966 },
1967 }
1968 }
1969}
1970
1971fn validate_reference_set(
1972 _kind: &'static str,
1973 mut references: Vec<AssessmentEvidenceReference>,
1974) -> Result<Vec<AssessmentEvidenceReference>, AssessmentItemProjectionError> {
1975 if references.is_empty() {
1976 return Err(AssessmentItemProjectionError::MissingEvidence);
1977 }
1978 if references.len() > MAX_ASSESSMENT_ITEM_EVIDENCE_REFERENCES {
1979 return Err(AssessmentItemProjectionError::TooManyEvidenceReferences);
1980 }
1981 references.sort_unstable();
1982 if references.windows(2).any(|pair| pair[0] == pair[1]) {
1983 return Err(AssessmentItemProjectionError::DuplicateEvidenceReference);
1984 }
1985 Ok(references)
1986}
1987
1988fn bounded_observation_confidence<'a>(
1989 capability: &'static AssessmentCapabilityDescriptor,
1990 knowledge: &KnowledgeBase,
1991 evidence_ids: impl IntoIterator<Item = &'a EvidenceId>,
1992) -> Result<Probability, AssessmentItemProjectionError> {
1993 let mut confidence = capability.confidence()?;
1994 for evidence_id in evidence_ids {
1995 let evidence = knowledge
1996 .evidence(evidence_id)
1997 .ok_or(AssessmentItemProjectionError::EvidenceNotCommitted)?;
1998 let evidence_confidence =
1999 Probability::from_basis_points(evidence.reliability().basis_points())
2000 .map_err(|_| AssessmentItemProjectionError::InvalidCapabilityConfidence)?;
2001 confidence = confidence.min(evidence_confidence);
2002 }
2003 Ok(confidence)
2004}
2005
2006fn assessment_fingerprint(
2007 capability_id: &str,
2008 stable_scope_id: &StableAssessmentScopeId,
2009 stable_subject_id: &StableAssessmentSubjectId,
2010 target: &AssessmentItemTarget,
2011) -> String {
2012 let mut digest = Sha256::new();
2013 digest.update(FINGERPRINT_DOMAIN);
2014 digest_field(&mut digest, ASSESSMENT_ITEM_SCHEMA);
2015 digest_field(&mut digest, stable_scope_id.as_str());
2016 digest_field(&mut digest, capability_id);
2017 digest_field(&mut digest, stable_subject_id.as_str());
2018 match target {
2019 AssessmentItemTarget::Subject => digest_field(&mut digest, "subject"),
2020 AssessmentItemTarget::QueryParameter(name) => {
2021 digest_field(&mut digest, "query_parameter");
2022 digest_field(&mut digest, name);
2023 },
2024 #[cfg(feature = "ssrf-oast-review")]
2025 AssessmentItemTarget::SsrfOastQuery(identity) => {
2026 digest_field(&mut digest, "ssrf_oast_query");
2027 digest_field(&mut digest, identity);
2028 },
2029 #[cfg(feature = "authorization-review")]
2030 AssessmentItemTarget::AuthorizationResource(identity) => {
2031 digest_field(&mut digest, "authorization_resource");
2032 digest_field(&mut digest, identity);
2033 },
2034 #[cfg(feature = "openapi-review")]
2035 AssessmentItemTarget::OpenApiDocument(identity) => {
2036 digest_field(&mut digest, "openapi_document");
2037 digest_field(&mut digest, identity);
2038 },
2039 #[cfg(feature = "rest-review")]
2040 AssessmentItemTarget::RestOperation(identity) => {
2041 digest_field(&mut digest, "rest_operation");
2042 digest_field(&mut digest, identity);
2043 },
2044 }
2045 format!("sha256:{:x}", digest.finalize())
2046}
2047
2048fn assessment_subject_fingerprint(
2049 stable_scope_id: &StableAssessmentScopeId,
2050 stable_subject_id: &StableAssessmentSubjectId,
2051) -> String {
2052 let mut digest = Sha256::new();
2053 digest.update(SUBJECT_IDENTITY_DOMAIN);
2054 digest_field(&mut digest, stable_scope_id.as_str());
2055 digest_field(&mut digest, stable_subject_id.as_str());
2056 format!("sha256:{:x}", digest.finalize())
2057}
2058
2059fn digest_field(digest: &mut Sha256, value: &str) {
2060 digest.update((value.len() as u64).to_be_bytes());
2061 digest.update(value.as_bytes());
2062}
2063
2064#[cfg(test)]
2065#[derive(Debug, Clone, Copy)]
2066struct ConfirmationProof {
2067 capability_policy: bool,
2068 action_matches: bool,
2069 hypothesis_claim_matches: bool,
2070 outcome_success: bool,
2071 transition_authorized: bool,
2072 hypothesis_write: bool,
2073 final_hypothesis_confirmed: bool,
2074 case_matches: bool,
2075 selected_verifier_matches: bool,
2076 evidence_nonempty: bool,
2077 evidence_resolved: bool,
2078 evidence_subject_matches: bool,
2079 evidence_case_matches: bool,
2080 receipt_case_matches: bool,
2081 receipt_stage_matches: bool,
2082 receipt_contributed: bool,
2083 receipt_evidence_matches: bool,
2084}
2085
2086#[cfg(test)]
2087impl ConfirmationProof {
2088 fn authorize(self) -> Result<(), AssessmentItemProjectionError> {
2089 let denied = if !self.capability_policy {
2090 Some(AssessmentConfirmationDenial::CapabilityPolicy)
2091 } else if !self.action_matches {
2092 Some(AssessmentConfirmationDenial::ActionMismatch)
2093 } else if !self.hypothesis_claim_matches {
2094 Some(AssessmentConfirmationDenial::HypothesisClaimMismatch)
2095 } else if !self.outcome_success {
2096 Some(AssessmentConfirmationDenial::OutcomeNotSuccess)
2097 } else if !self.transition_authorized {
2098 Some(AssessmentConfirmationDenial::KnowledgeOnly)
2099 } else if !self.hypothesis_write {
2100 Some(AssessmentConfirmationDenial::MissingHypothesisWrite)
2101 } else if !self.final_hypothesis_confirmed {
2102 Some(AssessmentConfirmationDenial::FinalHypothesisNotConfirmed)
2103 } else if !self.case_matches {
2104 Some(AssessmentConfirmationDenial::CaseMismatch)
2105 } else if !self.selected_verifier_matches {
2106 Some(AssessmentConfirmationDenial::SelectedVerifierMismatch)
2107 } else if !self.evidence_nonempty {
2108 Some(AssessmentConfirmationDenial::MissingEvidence)
2109 } else if !self.evidence_resolved {
2110 Some(AssessmentConfirmationDenial::EvidenceUnavailable)
2111 } else if !self.evidence_subject_matches {
2112 Some(AssessmentConfirmationDenial::EvidenceSubjectMismatch)
2113 } else if !self.evidence_case_matches {
2114 Some(AssessmentConfirmationDenial::EvidenceCaseMismatch)
2115 } else if !self.receipt_case_matches {
2116 Some(AssessmentConfirmationDenial::ReceiptCaseMismatch)
2117 } else if !self.receipt_stage_matches {
2118 Some(AssessmentConfirmationDenial::ReceiptStageMismatch)
2119 } else if !self.receipt_contributed {
2120 Some(AssessmentConfirmationDenial::ReceiptDidNotContribute)
2121 } else if !self.receipt_evidence_matches {
2122 Some(AssessmentConfirmationDenial::ReceiptEvidenceMismatch)
2123 } else {
2124 None
2125 };
2126 denied.map_or(Ok(()), |reason| {
2127 Err(AssessmentItemProjectionError::ConfirmationDenied(reason))
2128 })
2129 }
2130}
2131
2132#[cfg(test)]
2133struct ConfirmationExtraction<'a> {
2134 proof: ConfirmationProof,
2135 evidence_ids: &'a BTreeSet<EvidenceId>,
2136}
2137
2138#[cfg(test)]
2139fn extract_confirmation_proof<'a>(
2140 capability: &'static AssessmentCapabilityDescriptor,
2141 receipt: &DecisionEvidenceReceipt,
2142 decision: &'a DecisionOutcomeReport,
2143 knowledge: &KnowledgeBase,
2144) -> ConfirmationExtraction<'a> {
2145 let verification = decision.verification();
2146 let case = verification.case();
2147 let outcome = verification.outcome();
2148 let policy = capability.verifier_policy();
2149 let selected = verification
2150 .evaluations()
2151 .iter()
2152 .filter(|evaluation| evaluation.selected())
2153 .collect::<Vec<_>>();
2154 let selected_verifier_matches = policy.is_some_and(|policy| {
2155 selected.len() == 1
2156 && selected[0].rule_id() == policy.verifier_rule_id
2157 && selected[0].stage() == policy.stage
2158 && selected[0].action_matched()
2159 && selected[0].eligible()
2160 && selected[0].condition().evidence_ids() == outcome.evidence_ids()
2161 && outcome.verifier_rule_id() == Some(policy.verifier_rule_id)
2162 && outcome.stage() == policy.stage
2163 && verification.stage() == policy.stage
2164 });
2165
2166 let final_hypothesis = knowledge.hypothesis(outcome.hypothesis_id());
2167 let hypothesis_claim_matches = policy.is_some_and(|policy| {
2168 final_hypothesis.as_ref().is_some_and(|hypothesis| {
2169 hypothesis.subject() == case.subject()
2170 && predicate_matches(
2171 hypothesis.predicate(),
2172 policy.hypothesis_namespace,
2173 policy.hypothesis_name,
2174 )
2175 && policy.hypothesis_value.matches(hypothesis.value())
2176 })
2177 });
2178 let final_hypothesis_confirmed = final_hypothesis
2179 .as_ref()
2180 .is_some_and(|hypothesis| hypothesis.state() == HypothesisState::Confirmed);
2181 let case_matches = outcome.case_id() == case.id()
2182 && outcome.subject() == case.subject()
2183 && outcome.action_id() == case.action_id()
2184 && outcome.hypothesis_id() == case.hypothesis_id();
2185 let action_matches = policy.is_some_and(|policy| {
2186 case.action_id() == policy.action_id && outcome.action_id() == policy.action_id
2187 });
2188 let (evidence_resolved, evidence_subject_matches, evidence_case_matches) =
2189 validate_correlated_evidence(knowledge, outcome.evidence_ids(), case.subject(), case.id());
2190 let receipt_case_matches = receipt.case() == case;
2191 let receipt_stage_matches = execution_stage_matches(receipt.stage(), outcome.stage());
2192 let receipt_contributed = selected.first().is_some_and(|evaluation| {
2193 receipt_contributed_evidence(
2194 receipt,
2195 outcome.stage(),
2196 outcome.evidence_ids(),
2197 evaluation.fresh_evidence_ids(),
2198 )
2199 });
2200 let receipt_evidence_matches = selected.first().is_some_and(|evaluation| {
2201 receipt_evidence_matches_knowledge(
2202 receipt,
2203 knowledge,
2204 outcome.stage(),
2205 outcome.evidence_ids(),
2206 evaluation.fresh_evidence_ids(),
2207 )
2208 });
2209
2210 ConfirmationExtraction {
2211 proof: ConfirmationProof {
2212 capability_policy: policy.is_some(),
2213 action_matches,
2214 hypothesis_claim_matches,
2215 outcome_success: is_confirmation_outcome(outcome.status()),
2216 transition_authorized: case.applies_hypothesis_transition(),
2217 hypothesis_write: decision.hypothesis_write().is_some(),
2218 final_hypothesis_confirmed,
2219 case_matches,
2220 selected_verifier_matches,
2221 evidence_nonempty: !outcome.evidence_ids().is_empty(),
2222 evidence_resolved,
2223 evidence_subject_matches,
2224 evidence_case_matches,
2225 receipt_case_matches,
2226 receipt_stage_matches,
2227 receipt_contributed,
2228 receipt_evidence_matches,
2229 },
2230 evidence_ids: outcome.evidence_ids(),
2231 }
2232}
2233
2234#[cfg(test)]
2235const fn is_confirmation_outcome(status: OutcomeStatus) -> bool {
2236 matches!(status, OutcomeStatus::Success)
2237}
2238
2239#[cfg(test)]
2240fn predicate_matches(predicate: &KnowledgePredicate, namespace: &str, name: &str) -> bool {
2241 predicate.namespace() == namespace && predicate.name() == name
2242}
2243
2244#[cfg(test)]
2245fn validate_correlated_evidence(
2246 knowledge: &KnowledgeBase,
2247 evidence_ids: &BTreeSet<EvidenceId>,
2248 subject: &termivar_core::EntityId,
2249 case_id: &str,
2250) -> (bool, bool, bool) {
2251 let mut resolved = true;
2252 let mut subject_matches = true;
2253 let mut case_matches = true;
2254 for evidence_id in evidence_ids {
2255 match knowledge.evidence(evidence_id) {
2256 Some(evidence) => {
2257 subject_matches &= evidence.subject() == subject;
2258 case_matches &= evidence.source().correlation_id() == Some(case_id);
2259 },
2260 None => {
2261 resolved = false;
2262 subject_matches = false;
2263 case_matches = false;
2264 },
2265 }
2266 }
2267 (resolved, subject_matches, case_matches)
2268}
2269
2270#[cfg(test)]
2271fn execution_stage_matches(stage: DecisionExecutionStage, verification: VerificationStage) -> bool {
2272 matches!(
2273 (stage, verification),
2274 (DecisionExecutionStage::Passive, VerificationStage::Passive)
2275 | (DecisionExecutionStage::Active, VerificationStage::Active)
2276 )
2277}
2278
2279#[cfg(test)]
2280fn receipt_contributed_evidence(
2281 receipt: &DecisionEvidenceReceipt,
2282 stage: VerificationStage,
2283 outcome_evidence: &BTreeSet<EvidenceId>,
2284 fresh_evidence: &BTreeSet<EvidenceId>,
2285) -> bool {
2286 let receipt_contains = |wanted: &EvidenceId| {
2287 receipt
2288 .evidence()
2289 .iter()
2290 .any(|evidence| evidence.id() == wanted)
2291 };
2292 match stage {
2293 VerificationStage::Passive => {
2294 !outcome_evidence.is_empty() && outcome_evidence.iter().all(&receipt_contains)
2295 },
2296 VerificationStage::Active => {
2297 !fresh_evidence.is_empty()
2298 && fresh_evidence.is_subset(outcome_evidence)
2299 && fresh_evidence.iter().all(receipt_contains)
2300 },
2301 _ => false,
2302 }
2303}
2304
2305#[cfg(test)]
2306fn receipt_evidence_matches_knowledge(
2307 receipt: &DecisionEvidenceReceipt,
2308 knowledge: &KnowledgeBase,
2309 stage: VerificationStage,
2310 outcome_evidence: &BTreeSet<EvidenceId>,
2311 fresh_evidence: &BTreeSet<EvidenceId>,
2312) -> bool {
2313 let required = match stage {
2314 VerificationStage::Passive => outcome_evidence,
2315 VerificationStage::Active => fresh_evidence,
2316 _ => return false,
2317 };
2318 !required.is_empty()
2319 && required.iter().all(|evidence_id| {
2320 let receipt_evidence = receipt
2321 .evidence()
2322 .iter()
2323 .find(|evidence| evidence.id() == evidence_id);
2324 let committed_evidence = knowledge.evidence(evidence_id);
2325 receipt_evidence.is_some_and(|receipt_evidence| {
2326 committed_evidence
2327 .as_ref()
2328 .is_some_and(|committed| committed == receipt_evidence)
2329 })
2330 })
2331}
2332
2333#[cfg(test)]
2334#[path = "assessment_item_tests.rs"]
2335mod tests;