Skip to main content

termivar_scanner/web_runtime/
assessment_report.rs

1//! Bounded product report envelope for typed assessment items.
2//!
3//! This module owns no projection, verification, rendering, or persistence
4//! authority. It mints the generic [`RunReport`] envelope only from consumed
5//! runtime-owned completion truth, then binds the exact [`ScanProfileV1`] that
6//! governed the run to assessment items minted by the closed claim boundary.
7//! The public type deliberately has no Serde implementation; the reporting
8//! adapter performs a separate explicit, redacted wire projection.
9
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    fmt,
13    time::{Duration, SystemTime},
14};
15
16use sha2::{Digest, Sha256};
17use termivar_core::{
18    EvidenceId, ResourceAccounting, RunAccounting, RunReport, RunReportInput, RunStatus,
19    RunStepReport, RunStepStatus, RunStopCode, RunStopReason,
20};
21use thiserror::Error;
22use url::Url;
23
24#[cfg(feature = "jwt-policy-review")]
25use crate::jwt_policy_review::{
26    JwtExternalOperationStatus, JwtLocalSignatureStatus, JwtParsingStatus, JwtPolicyReviewAudit,
27    JwtPolicyStatus, JwtPolicyViolation, JwtTargetAcceptanceStatus, MAX_CLOCK_SKEW_SECONDS,
28    MAX_REQUIRED_CLAIMS,
29};
30#[cfg(feature = "jwt-target-acceptance-review")]
31use crate::jwt_target_acceptance::{
32    JwtTargetAcceptanceAudit, JwtTargetAcceptanceConclusion, JwtTargetAcceptanceIncompleteReason,
33    JwtTargetAcceptanceNotEligibleReason, JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA,
34    JWT_TARGET_ACCEPTANCE_POLICY_ID, MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS,
35    MAX_JWT_TARGET_ACCEPTANCE_REQUESTS,
36};
37#[cfg(feature = "template-evaluation-review")]
38use crate::template_evaluation_review::{
39    TemplateEvaluationReviewAudit, TemplateEvaluationReviewOutcome,
40    TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID, TEMPLATE_EVALUATION_REVIEW_CATEGORY,
41    TEMPLATE_EVALUATION_REVIEW_REMEDIATION_ID, TEMPLATE_EVALUATION_REVIEW_REMEDIATION_SUMMARY,
42    TEMPLATE_EVALUATION_REVIEW_SUMMARY, TEMPLATE_EVALUATION_REVIEW_TITLE,
43};
44#[cfg(feature = "xml-external-entity-review")]
45use crate::xml_external_entity_review::{
46    XmlExternalEntityReviewAudit, XmlExternalEntityReviewOutcome,
47    MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS, XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS,
48    XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID, XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM,
49    XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA, XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID,
50    XML_EXTERNAL_ENTITY_TARGET_REQUESTS,
51};
52
53#[cfg(any(
54    feature = "secret-exposure-review",
55    feature = "template-evaluation-review"
56))]
57use super::assessment_item::AssessmentBasis;
58#[cfg(feature = "template-evaluation-review")]
59use super::assessment_item::AssessmentDisposition;
60#[cfg(feature = "openapi-review")]
61use super::openapi_runtime::{
62    OpenApiRuntimeOutcome, WebAssessmentOpenApiAudit, MAX_OPENAPI_REVIEW_REQUESTS,
63    OPENAPI_REVIEW_CAPABILITY_ID,
64};
65#[cfg(feature = "recon-ct-provider")]
66use super::recon_ct_runtime::WebAssessmentReconCtProviderAudit;
67#[cfg(feature = "authorization-review")]
68use super::resource_authorization_runtime::{
69    WebAssessmentAuthorizationAudit, MAX_AUTHORIZATION_REVIEW_REQUESTS,
70    RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID,
71};
72#[cfg(feature = "rest-review")]
73use super::rest_runtime::{
74    RestRuntimeOutcome, WebAssessmentRestAudit, MAX_REST_REVIEW_ACTIVE_VERIFICATIONS,
75    MAX_REST_REVIEW_REQUESTS, REST_REVIEW_CAPABILITY_ID,
76};
77#[cfg(feature = "secret-exposure-review")]
78use super::secret_exposure::{
79    WebAssessmentSecretExposureAudit, MAX_SECRET_EXPOSURE_BODY_BYTES,
80    MAX_SECRET_EXPOSURE_OCCURRENCES, MAX_SECRET_EXPOSURE_RESPONSES,
81    MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS, MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES,
82};
83#[cfg(feature = "ssrf-oast-review")]
84use super::ssrf_oast_runtime::{
85    SsrfOastRuntimeOutcome, WebAssessmentSsrfOastAudit, MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS,
86    MAX_SSRF_OAST_REVIEW_PROVIDER_REQUESTS, MAX_SSRF_OAST_REVIEW_REQUESTS,
87    SSRF_OAST_REVIEW_CAPABILITY_ID,
88};
89#[cfg(feature = "tls-negotiation-review")]
90use super::tls_negotiation_review::{
91    WebAssessmentTlsNegotiationReviewAudit, TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE,
92    TLS_NEGOTIATION_REVIEW_ACTION_TLS12, TLS_NEGOTIATION_REVIEW_ACTION_TLS13,
93    TLS_NEGOTIATION_REVIEW_AUDIT_SCHEMA, TLS_NEGOTIATION_REVIEW_POLICY_ID,
94};
95#[cfg(feature = "tls-observation")]
96use super::tls_observation::{
97    WebAssessmentTlsObservationAudit, TLS_OBSERVATION_AUDIT_SCHEMA, TLS_OBSERVATION_BACKEND_LIMIT,
98    TLS_OBSERVATION_CLOCK_ASSURANCE, TLS_OBSERVATION_POLICY_ID, TLS_OBSERVATION_REVOCATION_STATUS,
99    TLS_OBSERVATION_SOURCE_SCOPE, TLS_OBSERVATION_VALIDATION_SCOPE,
100};
101#[cfg(feature = "template-evaluation-review")]
102use super::web_assessment::TEMPLATE_EVALUATION_ACTIVE_VERIFICATION_ALLOWANCE;
103#[cfg(feature = "websocket-review")]
104use super::websocket_runtime::{
105    WebAssessmentWebSocketReviewAudit, WebSocketReviewMessageStatus,
106    MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION, MAX_WEBSOCKET_REVIEW_CONNECTIONS,
107    WEBSOCKET_REVIEW_AUDIT_SCHEMA, WEBSOCKET_REVIEW_AUTHENTICATION, WEBSOCKET_REVIEW_CAPABILITY_ID,
108    WEBSOCKET_REVIEW_HTTP2_EXTENDED_CONNECT, WEBSOCKET_REVIEW_TRANSPORT,
109    WEBSOCKET_REVIEW_TRANSPORT_BYTE_SCOPE,
110};
111#[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
112use super::websocket_runtime::{
113    WebSocketSuppliedSessionHealthAudit, WebSocketSuppliedSessionQualification,
114    WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA, WEBSOCKET_SUPPLIED_SESSION_CONTEXT,
115};
116#[cfg(feature = "wordpress-review")]
117use super::wordpress_fingerprint_runtime::{
118    WordPressAssetFingerprintExecution, WordPressAssetFingerprintResourceReceipt,
119};
120#[cfg(feature = "wordpress-review")]
121use super::wordpress_runtime::{
122    WebAssessmentWordPressAudit, WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID,
123    WORDPRESS_REVIEW_CAPABILITY_ID,
124};
125use super::{
126    assessment_item::{
127        AssessmentEvidenceReference, AssessmentItem, AssessmentItemSet,
128        AssessmentSubjectInventoryEntry, MAX_ASSESSMENT_ITEM_SET_ITEMS,
129    },
130    assessment_passive::selected_application_stable_subject_identity,
131    scan_profile::{BuiltInScanProfile, ScanProfileV1},
132    web_assessment::{
133        WebAssessmentCompletion, WebAssessmentDefenseMode, WebAssessmentLimits,
134        WebAssessmentMethod, WebAssessmentSubject, WebAssessmentSubjectOrigin, WebAssessmentUsage,
135    },
136};
137#[cfg(feature = "supplied-session-review")]
138use super::{
139    SuppliedSessionAuditOutcome, SuppliedSessionBodyState, SuppliedSessionHealthCheckpointPhase,
140    SuppliedSessionHealthOutcome, SuppliedSessionLoginFormOutcome,
141    SuppliedSessionLoginSubmitOutcome, SuppliedSessionOAuthCodeOutcome,
142    SuppliedSessionResourceOutcome, WebAssessmentSuppliedSessionAudit,
143    MAX_SUPPLIED_SESSION_CHECKPOINTS, MAX_SUPPLIED_SESSION_REQUESTS,
144    MAX_SUPPLIED_SESSION_RESOURCES, SUPPLIED_SESSION_AUDIT_SCHEMA, SUPPLIED_SESSION_CAPABILITY_ID,
145    SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA, SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA,
146    SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA, SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA,
147    SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA, SUPPLIED_SESSION_RENEWAL_AUDIT_SCHEMA,
148};
149#[cfg(feature = "authorization-review")]
150use crate::authorization_review::{
151    AuthorizationReviewOutcome, HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS,
152    HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS,
153};
154#[cfg(feature = "control-reference-mapping")]
155use crate::control_reference_mapping::{map_control_references, ControlReferenceMappingAudit};
156#[cfg(feature = "recon-snapshot-import")]
157use crate::recon_snapshot::ReconSnapshot;
158#[cfg(feature = "supplied-session-review")]
159use crate::supplied_session_review::{
160    SuppliedSessionCredentialAcquisition, SuppliedSessionCredentialMechanism,
161    MAX_SUPPLIED_SESSION_COOKIES, MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES,
162};
163#[cfg(feature = "websocket-review")]
164use crate::websocket_review::{
165    MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES, MAX_WEBSOCKET_REVIEW_MESSAGES,
166    MAX_WEBSOCKET_REVIEW_WALL_TIME_MS,
167};
168#[cfg(feature = "wordpress-review")]
169use crate::wordpress_review::{
170    WordPressCatalogStatus, MAX_WORDPRESS_ADVISORY_RECORDS, MAX_WORDPRESS_RESULT_COMPONENTS,
171    MAX_WORDPRESS_RESULT_VERSION_EVIDENCE, MAX_WORDPRESS_SIGNALS,
172};
173use crate::{
174    DecisionExecutionStage, RuntimeBudget, TransportDispatchAudit, TransportDispatchOutcome,
175};
176
177/// Stable schema for the typed assessment-run product envelope.
178pub const ASSESSMENT_RUN_REPORT_SCHEMA: &str = "venom-assessment-run/v1";
179
180/// Maximum number of typed assessment items retained by one run report.
181pub const MAX_ASSESSMENT_RUN_ITEMS: usize = MAX_ASSESSMENT_ITEM_SET_ITEMS;
182
183const ASSESSMENT_RUN_TARGET_DOMAIN: &[u8] = b"venom.assessment-run.target.v1\0";
184const WEB_ASSESSMENT_RUN_STEP_ID: &str = "web-review";
185const WEB_ASSESSMENT_STOP_DETAIL: &str = "bounded web assessment completed";
186
187/// Runtime-only extension of the profile limits for explicitly enabled child work.
188#[derive(Clone, Copy)]
189pub(crate) struct AssessmentRuntimeLimits {
190    profile: WebAssessmentLimits,
191    active_verification_limit: u16,
192    optional_active_verification_allowance: u16,
193}
194
195impl AssessmentRuntimeLimits {
196    pub(super) const fn new(
197        profile: WebAssessmentLimits,
198        active_verification_limit: u16,
199        optional_active_verification_allowance: u16,
200    ) -> Self {
201        Self {
202            profile,
203            active_verification_limit,
204            optional_active_verification_allowance,
205        }
206    }
207}
208
209/// Checked bridge between one completed origin assessment and its product
210/// report envelope.
211///
212/// The token owns the exact profile label and retains only a domain-separated
213/// digest of the canonical starting resource. It can be minted only from the
214/// runtime's typed completion, configured limits, defense mode, and accounting
215/// snapshot. This prevents a caller from pairing a successful-looking generic
216/// [`RunReport`] with an incomplete assessment or a profile that did not govern
217/// the run.
218pub(crate) struct CompletedWebAssessmentTruth {
219    run_started_at: SystemTime,
220    target: String,
221    authorized_origin: String,
222    target_identity: [u8; 32],
223    expected_accounting: RunAccounting,
224    expected_elapsed_ms: u64,
225    profile: ScanProfileV1,
226}
227
228impl CompletedWebAssessmentTruth {
229    pub(crate) fn new(
230        run_started_at: SystemTime,
231        authorized_root: &WebAssessmentSubject,
232        runtime_limits: AssessmentRuntimeLimits,
233        usage: WebAssessmentUsage,
234        completion: &WebAssessmentCompletion,
235        defense_mode: WebAssessmentDefenseMode,
236        profile: ScanProfileV1,
237    ) -> Result<Self, AssessmentRunReportError> {
238        let limits = runtime_limits.profile;
239        let runtime_active_verification_limit = runtime_limits.active_verification_limit;
240        validate_completed_assessment_truth_with_active_limit(
241            authorized_root,
242            runtime_limits,
243            AssessmentUsageTruth::from(usage),
244            completion,
245            defense_mode,
246            &profile,
247        )?;
248        let usage = AssessmentUsageTruth::from(usage);
249        let target = authorized_root.url().to_string();
250        Ok(Self {
251            run_started_at,
252            authorized_origin: authorized_root.url().origin().ascii_serialization(),
253            target_identity: assessment_target_identity(authorized_root.url()),
254            target,
255            expected_accounting: expected_run_accounting_with_active_limit(
256                limits,
257                runtime_active_verification_limit,
258                usage,
259            ),
260            expected_elapsed_ms: usage.elapsed_ms,
261            profile,
262        })
263    }
264}
265
266impl fmt::Debug for CompletedWebAssessmentTruth {
267    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
268        formatter
269            .debug_struct("CompletedWebAssessmentTruth")
270            .field("run_started_at", &"<runtime-owned>")
271            .field("target", &"<redacted>")
272            .field("authorized_origin", &"<redacted>")
273            .field("target_identity", &"<stable-digest>")
274            .field("accounting", &"<bounded>")
275            .field("elapsed_ms", &self.expected_elapsed_ms)
276            .field("profile", &self.profile.profile().id())
277            .finish()
278    }
279}
280
281/// A validated, bounded assessment-run product envelope.
282///
283/// Construction preserves the already validated runtime and profile contracts.
284/// Items are sorted by their stable, non-secret fingerprint so the same set of
285/// runtime truths has one deterministic report order. Duplicate fingerprints
286/// fail closed instead of silently collapsing two projections.
287///
288/// This type intentionally does not implement `Serialize` or `Deserialize`.
289/// Renderers must consume its read-only accessors through a separately reviewed
290/// redacted wire projection.
291#[derive(Clone, PartialEq, Eq)]
292pub struct AssessmentRunReport {
293    run_report: RunReport,
294    profile: ScanProfileV1,
295    subjects: Vec<AssessmentSubjectInventoryEntry>,
296    items: Vec<AssessmentItem>,
297    evidence_references: BTreeMap<EvidenceId, AssessmentEvidenceReference>,
298    #[cfg(feature = "supplied-session-review")]
299    supplied_session: Option<WebAssessmentSuppliedSessionAudit>,
300    #[cfg(feature = "authorization-review")]
301    authorization_review: Option<WebAssessmentAuthorizationAudit>,
302    #[cfg(feature = "websocket-review")]
303    websocket_review: Option<WebAssessmentWebSocketReviewAudit>,
304    #[cfg(feature = "jwt-target-acceptance-review")]
305    jwt_target_acceptance: Option<JwtTargetAcceptanceAudit>,
306    #[cfg(feature = "openapi-review")]
307    openapi_review: Option<WebAssessmentOpenApiAudit>,
308    #[cfg(feature = "rest-review")]
309    rest_review: Option<WebAssessmentRestAudit>,
310    #[cfg(feature = "ssrf-oast-review")]
311    ssrf_oast_review: Option<WebAssessmentSsrfOastAudit>,
312    #[cfg(feature = "xml-external-entity-review")]
313    xml_external_entity_review: Option<XmlExternalEntityReviewAudit>,
314    #[cfg(feature = "template-evaluation-review")]
315    template_evaluation_review: Option<TemplateEvaluationReviewAudit>,
316    #[cfg(feature = "wordpress-review")]
317    wordpress_review: Option<WebAssessmentWordPressAudit>,
318    #[cfg(feature = "secret-exposure-review")]
319    secret_exposure_review: Option<WebAssessmentSecretExposureAudit>,
320    #[cfg(feature = "tls-observation")]
321    tls_observation: Option<WebAssessmentTlsObservationAudit>,
322    #[cfg(feature = "tls-negotiation-review")]
323    tls_negotiation_review: Option<WebAssessmentTlsNegotiationReviewAudit>,
324    #[cfg(feature = "recon-ct-provider")]
325    recon_ct_provider: Option<WebAssessmentReconCtProviderAudit>,
326    #[cfg(feature = "jwt-policy-review")]
327    jwt_policy_review: Option<JwtPolicyReviewAudit>,
328    #[cfg(feature = "control-reference-mapping")]
329    control_reference_mapping: Option<ControlReferenceMappingAudit>,
330    #[cfg(feature = "recon-snapshot-import")]
331    recon_snapshot: Option<ReconSnapshot>,
332}
333
334#[derive(Default)]
335struct AssessmentReviewAudits {
336    #[cfg(feature = "supplied-session-review")]
337    supplied_session: Option<WebAssessmentSuppliedSessionAudit>,
338    #[cfg(feature = "authorization-review")]
339    authorization_review: Option<WebAssessmentAuthorizationAudit>,
340    #[cfg(feature = "websocket-review")]
341    websocket_review: Option<WebAssessmentWebSocketReviewAudit>,
342    #[cfg(feature = "jwt-target-acceptance-review")]
343    jwt_target_acceptance: Option<JwtTargetAcceptanceAudit>,
344    #[cfg(feature = "openapi-review")]
345    openapi_review: Option<WebAssessmentOpenApiAudit>,
346    #[cfg(feature = "rest-review")]
347    rest_review: Option<WebAssessmentRestAudit>,
348    #[cfg(feature = "ssrf-oast-review")]
349    ssrf_oast_review: Option<WebAssessmentSsrfOastAudit>,
350    #[cfg(feature = "xml-external-entity-review")]
351    xml_external_entity_review: Option<XmlExternalEntityReviewAudit>,
352    #[cfg(feature = "template-evaluation-review")]
353    template_evaluation_review: Option<TemplateEvaluationReviewAudit>,
354    #[cfg(feature = "wordpress-review")]
355    wordpress_review: Option<WebAssessmentWordPressAudit>,
356    #[cfg(feature = "secret-exposure-review")]
357    secret_exposure_review: Option<WebAssessmentSecretExposureAudit>,
358    #[cfg(feature = "tls-observation")]
359    tls_observation: Option<WebAssessmentTlsObservationAudit>,
360    #[cfg(feature = "tls-negotiation-review")]
361    tls_negotiation_review: Option<WebAssessmentTlsNegotiationReviewAudit>,
362    #[cfg(feature = "recon-ct-provider")]
363    recon_ct_provider: Option<WebAssessmentReconCtProviderAudit>,
364}
365
366impl AssessmentRunReport {
367    /// Mints the canonical run envelope from runtime-owned completion truth
368    /// and composes it with the closed typed-item set.
369    #[allow(clippy::too_many_arguments)]
370    pub(crate) fn from_completed_truth(
371        items: AssessmentItemSet,
372        truth: CompletedWebAssessmentTruth,
373        #[cfg(feature = "supplied-session-review")] supplied_session: Option<
374            WebAssessmentSuppliedSessionAudit,
375        >,
376        #[cfg(feature = "authorization-review")] authorization_review: Option<
377            WebAssessmentAuthorizationAudit,
378        >,
379        #[cfg(feature = "websocket-review")] websocket_review: Option<
380            WebAssessmentWebSocketReviewAudit,
381        >,
382        #[cfg(feature = "jwt-target-acceptance-review")] jwt_target_acceptance: Option<
383            JwtTargetAcceptanceAudit,
384        >,
385        #[cfg(feature = "openapi-review")] openapi_review: Option<WebAssessmentOpenApiAudit>,
386        #[cfg(feature = "rest-review")] rest_review: Option<WebAssessmentRestAudit>,
387        #[cfg(feature = "ssrf-oast-review")] ssrf_oast_review: Option<WebAssessmentSsrfOastAudit>,
388        #[cfg(feature = "xml-external-entity-review")] xml_external_entity_review: Option<
389            XmlExternalEntityReviewAudit,
390        >,
391        #[cfg(feature = "template-evaluation-review")] template_evaluation_review: Option<
392            TemplateEvaluationReviewAudit,
393        >,
394        #[cfg(feature = "wordpress-review")] wordpress_review: Option<WebAssessmentWordPressAudit>,
395        #[cfg(feature = "secret-exposure-review")] secret_exposure_review: Option<
396            WebAssessmentSecretExposureAudit,
397        >,
398        #[cfg(feature = "tls-observation")] tls_observation: Option<
399            WebAssessmentTlsObservationAudit,
400        >,
401        #[cfg(feature = "tls-negotiation-review")] tls_negotiation_review: Option<
402            WebAssessmentTlsNegotiationReviewAudit,
403        >,
404        #[cfg(feature = "recon-ct-provider")] recon_ct_provider: Option<
405            WebAssessmentReconCtProviderAudit,
406        >,
407        transport: &TransportDispatchAudit,
408    ) -> Result<Self, AssessmentRunReportError> {
409        let run_report = build_run_report(&truth)?;
410        Self::new_validated(
411            run_report,
412            items,
413            truth,
414            AssessmentReviewAudits {
415                #[cfg(feature = "supplied-session-review")]
416                supplied_session,
417                #[cfg(feature = "authorization-review")]
418                authorization_review,
419                #[cfg(feature = "websocket-review")]
420                websocket_review,
421                #[cfg(feature = "jwt-target-acceptance-review")]
422                jwt_target_acceptance,
423                #[cfg(feature = "openapi-review")]
424                openapi_review,
425                #[cfg(feature = "rest-review")]
426                rest_review,
427                #[cfg(feature = "ssrf-oast-review")]
428                ssrf_oast_review,
429                #[cfg(feature = "xml-external-entity-review")]
430                xml_external_entity_review,
431                #[cfg(feature = "template-evaluation-review")]
432                template_evaluation_review,
433                #[cfg(feature = "wordpress-review")]
434                wordpress_review,
435                #[cfg(feature = "secret-exposure-review")]
436                secret_exposure_review,
437                #[cfg(feature = "tls-observation")]
438                tls_observation,
439                #[cfg(feature = "tls-negotiation-review")]
440                tls_negotiation_review,
441                #[cfg(feature = "recon-ct-provider")]
442                recon_ct_provider,
443            },
444            transport,
445        )
446    }
447
448    #[cfg(test)]
449    fn new(
450        run_report: RunReport,
451        items: AssessmentItemSet,
452        truth: CompletedWebAssessmentTruth,
453    ) -> Result<Self, AssessmentRunReportError> {
454        Self::new_validated(
455            run_report,
456            items,
457            truth,
458            AssessmentReviewAudits::default(),
459            &TransportDispatchAudit::default(),
460        )
461    }
462
463    fn new_validated(
464        run_report: RunReport,
465        items: AssessmentItemSet,
466        truth: CompletedWebAssessmentTruth,
467        audits: AssessmentReviewAudits,
468        transport: &TransportDispatchAudit,
469    ) -> Result<Self, AssessmentRunReportError> {
470        let AssessmentReviewAudits {
471            #[cfg(feature = "supplied-session-review")]
472            supplied_session,
473            #[cfg(feature = "authorization-review")]
474            authorization_review,
475            #[cfg(feature = "websocket-review")]
476            websocket_review,
477            #[cfg(feature = "jwt-target-acceptance-review")]
478            jwt_target_acceptance,
479            #[cfg(feature = "openapi-review")]
480            openapi_review,
481            #[cfg(feature = "rest-review")]
482            rest_review,
483            #[cfg(feature = "ssrf-oast-review")]
484            ssrf_oast_review,
485            #[cfg(feature = "xml-external-entity-review")]
486            xml_external_entity_review,
487            #[cfg(feature = "template-evaluation-review")]
488            template_evaluation_review,
489            #[cfg(feature = "wordpress-review")]
490            wordpress_review,
491            #[cfg(feature = "secret-exposure-review")]
492            secret_exposure_review,
493            #[cfg(feature = "tls-observation")]
494            tls_observation,
495            #[cfg(feature = "tls-negotiation-review")]
496            tls_negotiation_review,
497            #[cfg(feature = "recon-ct-provider")]
498            recon_ct_provider,
499        } = audits;
500        validate_run_identity(&run_report, truth.target_identity)?;
501        validate_run_completion(&run_report)?;
502        validate_run_accounting(
503            &run_report,
504            &truth.expected_accounting,
505            truth.expected_elapsed_ms,
506        )?;
507        if !items.matches_exact_origin(run_report.authorized_origin()) {
508            return Err(AssessmentRunReportError::ScopeAuthorityMismatch);
509        }
510        #[cfg(feature = "supplied-session-review")]
511        let supplied_session_application_reference = supplied_session
512            .as_ref()
513            .map(WebAssessmentSuppliedSessionAudit::application_reference);
514        #[cfg(not(feature = "supplied-session-review"))]
515        let supplied_session_application_reference: Option<&str> = None;
516        let expected_root_subject =
517            selected_application_stable_subject_identity(supplied_session_application_reference);
518        if !items.contains_stable_subject(&expected_root_subject) {
519            return Err(AssessmentRunReportError::SubjectReferenceMismatch);
520        }
521        let (subjects, mut items, evidence_references) = items.into_report_parts();
522        validate_subject_inventory(&subjects, &items)?;
523        validate_and_canonicalize_items(truth.profile.profile(), &mut items)?;
524        #[cfg(feature = "supplied-session-review")]
525        validate_supplied_session_audit(supplied_session.as_ref(), &items)?;
526        #[cfg(feature = "authorization-review")]
527        validate_authorization_audit(authorization_review.as_ref(), &items)?;
528        #[cfg(feature = "websocket-review")]
529        validate_websocket_review_audit(
530            websocket_review.as_ref(),
531            #[cfg(feature = "supplied-session-review")]
532            supplied_session.as_ref(),
533            &items,
534            truth.expected_accounting.requests().consumed(),
535            truth.expected_accounting.request_body_bytes().consumed(),
536            truth.expected_accounting.response_body_bytes().consumed(),
537        )?;
538        #[cfg(feature = "jwt-target-acceptance-review")]
539        validate_jwt_target_acceptance_audit(
540            jwt_target_acceptance.as_ref(),
541            truth.expected_accounting.requests().consumed(),
542            truth.expected_accounting.response_body_bytes().consumed(),
543        )?;
544        #[cfg(feature = "openapi-review")]
545        validate_openapi_audit(openapi_review.as_ref(), &items)?;
546        #[cfg(feature = "rest-review")]
547        {
548            #[cfg(feature = "secret-exposure-review")]
549            let response_privacy = secret_exposure_review.is_some();
550            #[cfg(not(feature = "secret-exposure-review"))]
551            let response_privacy = false;
552            validate_rest_audit(rest_review.as_ref(), &items, response_privacy)?;
553        }
554        #[cfg(feature = "ssrf-oast-review")]
555        validate_ssrf_oast_audit(ssrf_oast_review.as_ref(), &items)?;
556        #[cfg(feature = "xml-external-entity-review")]
557        validate_xml_external_entity_audit(
558            xml_external_entity_review.as_ref(),
559            &items,
560            truth.expected_accounting.requests().consumed(),
561            truth.expected_accounting.request_body_bytes().consumed(),
562            truth.expected_accounting.response_body_bytes().consumed(),
563        )?;
564        #[cfg(feature = "template-evaluation-review")]
565        validate_template_evaluation_review_audit(
566            template_evaluation_review.as_ref(),
567            &items,
568            truth.expected_accounting.requests().consumed(),
569        )?;
570        #[cfg(feature = "wordpress-review")]
571        validate_wordpress_audit(wordpress_review.as_ref(), &items)?;
572        #[cfg(feature = "secret-exposure-review")]
573        validate_secret_exposure_audit(
574            secret_exposure_review.as_ref(),
575            &items,
576            &evidence_references,
577        )?;
578        #[cfg(feature = "tls-observation")]
579        validate_tls_observation_audit(
580            tls_observation.as_ref(),
581            &truth.target,
582            truth.expected_accounting.requests().consumed(),
583        )?;
584        #[cfg(feature = "tls-negotiation-review")]
585        validate_tls_negotiation_review_audit(
586            tls_negotiation_review.as_ref(),
587            transport,
588            truth.expected_accounting.requests().consumed(),
589            truth.expected_accounting.response_body_bytes().consumed(),
590        )?;
591        #[cfg(feature = "recon-ct-provider")]
592        validate_recon_ct_provider_audit(
593            recon_ct_provider.as_ref(),
594            truth.expected_accounting.requests().consumed(),
595            truth.expected_accounting.response_body_bytes().consumed(),
596        )?;
597
598        Ok(Self {
599            run_report,
600            profile: truth.profile,
601            subjects,
602            items,
603            evidence_references,
604            #[cfg(feature = "supplied-session-review")]
605            supplied_session,
606            #[cfg(feature = "authorization-review")]
607            authorization_review,
608            #[cfg(feature = "websocket-review")]
609            websocket_review,
610            #[cfg(feature = "jwt-target-acceptance-review")]
611            jwt_target_acceptance,
612            #[cfg(feature = "openapi-review")]
613            openapi_review,
614            #[cfg(feature = "rest-review")]
615            rest_review,
616            #[cfg(feature = "ssrf-oast-review")]
617            ssrf_oast_review,
618            #[cfg(feature = "xml-external-entity-review")]
619            xml_external_entity_review,
620            #[cfg(feature = "template-evaluation-review")]
621            template_evaluation_review,
622            #[cfg(feature = "wordpress-review")]
623            wordpress_review,
624            #[cfg(feature = "secret-exposure-review")]
625            secret_exposure_review,
626            #[cfg(feature = "tls-observation")]
627            tls_observation,
628            #[cfg(feature = "tls-negotiation-review")]
629            tls_negotiation_review,
630            #[cfg(feature = "recon-ct-provider")]
631            recon_ct_provider,
632            #[cfg(feature = "jwt-policy-review")]
633            jwt_policy_review: None,
634            #[cfg(feature = "control-reference-mapping")]
635            control_reference_mapping: None,
636            #[cfg(feature = "recon-snapshot-import")]
637            recon_snapshot: None,
638        })
639    }
640
641    /// Returns the stable assessment-run schema.
642    pub const fn schema(&self) -> &'static str {
643        ASSESSMENT_RUN_REPORT_SCHEMA
644    }
645
646    /// Returns the exact validated profile that governed the run.
647    pub const fn profile(&self) -> &ScanProfileV1 {
648        &self.profile
649    }
650
651    /// Returns assessment items in canonical fingerprint order.
652    pub fn items(&self) -> &[AssessmentItem] {
653        &self.items
654    }
655
656    pub(crate) const fn run_report(&self) -> &RunReport {
657        &self.run_report
658    }
659
660    /// Returns the optional redaction-safe supplied-session assessment audit.
661    #[cfg(feature = "supplied-session-review")]
662    pub const fn supplied_session_audit(&self) -> Option<&WebAssessmentSuppliedSessionAudit> {
663        self.supplied_session.as_ref()
664    }
665
666    /// Returns subjects registered by the consumed projection authority.
667    pub const fn subject_count(&self) -> usize {
668        self.subjects.len()
669    }
670
671    /// Returns the bounded assessment-item count.
672    pub fn item_count(&self) -> usize {
673        self.items.len()
674    }
675
676    /// Resolves one runtime-owned evidence identity to the opaque reference
677    /// minted by the same consumed projection authority. This mapping remains
678    /// private to report composition and is never serialized as runtime state.
679    #[cfg(any(
680        feature = "wordpress-review",
681        feature = "secret-exposure-review",
682        feature = "xml-external-entity-review"
683    ))]
684    pub(crate) fn evidence_reference_for(
685        &self,
686        evidence_id: &EvidenceId,
687    ) -> Option<AssessmentEvidenceReference> {
688        self.evidence_references.get(evidence_id).copied()
689    }
690
691    /// Returns the optional redaction-safe resource-authorization audit.
692    #[cfg(feature = "authorization-review")]
693    pub const fn authorization_review_audit(&self) -> Option<&WebAssessmentAuthorizationAudit> {
694        self.authorization_review.as_ref()
695    }
696    /// Returns the optional value-free WebSocket protocol review audit.
697    #[cfg(feature = "websocket-review")]
698    pub const fn websocket_review_audit(&self) -> Option<&WebAssessmentWebSocketReviewAudit> {
699        self.websocket_review.as_ref()
700    }
701    /// Returns the optional value-free target-side JWT control audit.
702    #[cfg(feature = "jwt-target-acceptance-review")]
703    pub const fn jwt_target_acceptance_audit(&self) -> Option<&JwtTargetAcceptanceAudit> {
704        self.jwt_target_acceptance.as_ref()
705    }
706    #[cfg(feature = "openapi-review")]
707    pub const fn openapi_review_audit(&self) -> Option<&WebAssessmentOpenApiAudit> {
708        self.openapi_review.as_ref()
709    }
710
711    /// Returns the optional redaction-safe REST read-only review audit.
712    #[cfg(feature = "rest-review")]
713    pub const fn rest_review_audit(&self) -> Option<&WebAssessmentRestAudit> {
714        self.rest_review.as_ref()
715    }
716
717    /// Returns the optional redaction-safe query-only SSRF/OAST review audit.
718    #[cfg(feature = "ssrf-oast-review")]
719    pub const fn ssrf_oast_review_audit(&self) -> Option<&WebAssessmentSsrfOastAudit> {
720        self.ssrf_oast_review.as_ref()
721    }
722
723    /// Returns the optional value-free controlled XML interaction audit.
724    #[cfg(feature = "xml-external-entity-review")]
725    pub const fn xml_external_entity_review_audit(&self) -> Option<&XmlExternalEntityReviewAudit> {
726        self.xml_external_entity_review.as_ref()
727    }
728
729    /// Returns the optional value-free benign template-evaluation audit.
730    #[cfg(feature = "template-evaluation-review")]
731    pub const fn template_evaluation_review_audit(&self) -> Option<&TemplateEvaluationReviewAudit> {
732        self.template_evaluation_review.as_ref()
733    }
734
735    /// Returns the optional redaction-safe, transport-free WordPress audit.
736    #[cfg(feature = "wordpress-review")]
737    pub const fn wordpress_review_audit(&self) -> Option<&WebAssessmentWordPressAudit> {
738        self.wordpress_review.as_ref()
739    }
740
741    /// Returns the optional value-free passive secret-exposure audit.
742    #[cfg(feature = "secret-exposure-review")]
743    pub const fn secret_exposure_review_audit(&self) -> Option<&WebAssessmentSecretExposureAudit> {
744        self.secret_exposure_review.as_ref()
745    }
746
747    /// Returns the optional passive existing-connection TLS audit.
748    #[cfg(feature = "tls-observation")]
749    pub const fn tls_observation_audit(&self) -> Option<&WebAssessmentTlsObservationAudit> {
750        self.tls_observation.as_ref()
751    }
752
753    /// Returns the explicitly selected bounded active TLS negotiation audit.
754    #[cfg(feature = "tls-negotiation-review")]
755    pub const fn tls_negotiation_review_audit(
756        &self,
757    ) -> Option<&WebAssessmentTlsNegotiationReviewAudit> {
758        self.tls_negotiation_review.as_ref()
759    }
760
761    /// Returns the optional bounded certificate-transparency provider audit.
762    #[cfg(feature = "recon-ct-provider")]
763    pub const fn recon_ct_provider_audit(&self) -> Option<&WebAssessmentReconCtProviderAudit> {
764        self.recon_ct_provider.as_ref()
765    }
766
767    /// Attaches one independently evaluated, transport-free local JWT audit
768    /// after the ordinary web assessment has completed.
769    #[cfg(feature = "jwt-policy-review")]
770    pub(crate) fn with_jwt_policy_review_audit(
771        mut self,
772        audit: Option<JwtPolicyReviewAudit>,
773    ) -> Result<Self, AssessmentRunReportError> {
774        if self.jwt_policy_review.is_some() {
775            return Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch);
776        }
777        #[cfg(feature = "jwt-target-acceptance-review")]
778        if self.jwt_target_acceptance.is_some() && audit.is_none() {
779            return Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch);
780        }
781        #[cfg(feature = "jwt-target-acceptance-review")]
782        if self.jwt_target_acceptance.is_none()
783            && audit
784                .as_ref()
785                .is_some_and(|audit| audit.target_preparation_binding().is_some())
786        {
787            return Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch);
788        }
789        if let Some(audit) = audit.as_ref() {
790            validate_jwt_policy_review_audit(audit)?;
791            #[cfg(feature = "jwt-target-acceptance-review")]
792            if let Some(target) = self.jwt_target_acceptance.as_ref() {
793                validate_jwt_target_local_link(audit, target)?;
794            }
795        }
796        self.jwt_policy_review = audit;
797        Ok(self)
798    }
799
800    /// Returns the optional value-free, transport-free local JWT audit.
801    #[cfg(feature = "jwt-policy-review")]
802    pub const fn jwt_policy_review_audit(&self) -> Option<&JwtPolicyReviewAudit> {
803        self.jwt_policy_review.as_ref()
804    }
805
806    /// Attaches the one built-in, bounded, transport-free control-reference
807    /// projection over this completed report's immutable assessment items.
808    #[cfg(feature = "control-reference-mapping")]
809    pub(crate) fn with_control_reference_mapping(
810        mut self,
811    ) -> Result<Self, AssessmentRunReportError> {
812        if self.control_reference_mapping.is_some() {
813            return Err(AssessmentRunReportError::ControlReferenceMappingAuditMismatch);
814        }
815        self.control_reference_mapping = Some(
816            map_control_references(&self.items)
817                .map_err(|_| AssessmentRunReportError::ControlReferenceMappingAuditMismatch)?,
818        );
819        Ok(self)
820    }
821
822    /// Returns the optional transport-free control-reference mapping audit.
823    #[cfg(feature = "control-reference-mapping")]
824    pub const fn control_reference_mapping_audit(&self) -> Option<&ControlReferenceMappingAudit> {
825        self.control_reference_mapping.as_ref()
826    }
827
828    /// Attaches one previously bounded, transport-free local reconnaissance
829    /// snapshot. The imported records remain inert source-qualified hypotheses
830    /// and cannot mint assessment items, subjects, or network authority.
831    #[cfg(feature = "recon-snapshot-import")]
832    pub(crate) fn with_recon_snapshot(
833        mut self,
834        audit: ReconSnapshot,
835    ) -> Result<Self, AssessmentRunReportError> {
836        if self.recon_snapshot.is_some() {
837            return Err(AssessmentRunReportError::ReconSnapshotAuditMismatch);
838        }
839        self.recon_snapshot = Some(audit);
840        Ok(self)
841    }
842
843    /// Returns the optional transport-free reconnaissance snapshot audit.
844    #[cfg(feature = "recon-snapshot-import")]
845    pub const fn recon_snapshot_audit(&self) -> Option<&ReconSnapshot> {
846        self.recon_snapshot.as_ref()
847    }
848}
849
850#[cfg(feature = "jwt-target-acceptance-review")]
851fn validate_jwt_target_local_link(
852    local: &JwtPolicyReviewAudit,
853    target: &JwtTargetAcceptanceAudit,
854) -> Result<(), AssessmentRunReportError> {
855    if local.target_preparation_binding().is_none()
856        || local.target_preparation_binding() != target.preparation_binding()
857    {
858        return Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch);
859    }
860    let local_eligible = local.parsing_status() == JwtParsingStatus::Parsed
861        && local.policy_status() == JwtPolicyStatus::Consistent
862        && local.local_signature_status() == JwtLocalSignatureStatus::Verified;
863    let linked = match target.conclusion() {
864        JwtTargetAcceptanceConclusion::Incomplete(
865            JwtTargetAcceptanceIncompleteReason::NotEligible(reason),
866        ) if target.legs().is_empty() => match reason {
867            JwtTargetAcceptanceNotEligibleReason::LocalParsingNotEstablished => {
868                matches!(local.parsing_status(), JwtParsingStatus::Rejected(_))
869            },
870            JwtTargetAcceptanceNotEligibleReason::LocalPolicyNotEstablished => {
871                local.parsing_status() == JwtParsingStatus::Parsed
872                    && local.policy_status() == JwtPolicyStatus::Inconsistent
873            },
874            JwtTargetAcceptanceNotEligibleReason::LocalSignatureNotEstablished => {
875                local.parsing_status() == JwtParsingStatus::Parsed
876                    && local.policy_status() == JwtPolicyStatus::Consistent
877                    && local.local_signature_status() == JwtLocalSignatureStatus::Invalid
878            },
879            JwtTargetAcceptanceNotEligibleReason::RuntimeAuthorityUnavailable
880            | JwtTargetAcceptanceNotEligibleReason::RequestBudgetUnavailable => local_eligible,
881        },
882        _ => !target.legs().is_empty() && local_eligible,
883    };
884    if linked {
885        Ok(())
886    } else {
887        Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch)
888    }
889}
890
891#[cfg(feature = "jwt-policy-review")]
892fn validate_jwt_policy_review_audit(
893    audit: &JwtPolicyReviewAudit,
894) -> Result<(), AssessmentRunReportError> {
895    let methodology = audit.methodology();
896    let external = audit.external_activity();
897    let base_valid = audit.schema() == crate::jwt_policy_review::JWT_POLICY_REVIEW_AUDIT_SCHEMA
898        && audit.policy() == crate::jwt_policy_review::JWT_POLICY_REVIEW_POLICY_ID
899        && audit.selected()
900        && valid_jwt_methodology_reference(methodology.operator_policy_reference())
901        && valid_jwt_methodology_reference(methodology.operator_policy_revision())
902        && valid_jwt_public_key_sha256(methodology.local_public_key_sha256())
903        && methodology.expected_type_selected()
904        && methodology.expected_issuer_selected()
905        && methodology.expected_audience_selected()
906        && methodology.required_claim_count() as usize <= MAX_REQUIRED_CLAIMS
907        && methodology.allowed_clock_skew_seconds() <= MAX_CLOCK_SKEW_SECONDS
908        && external.target_request_count() == 0
909        && external.remote_key_retrieval() == JwtExternalOperationStatus::NotPerformed
910        && external.token_forwarding() == JwtExternalOperationStatus::NotPerformed
911        && audit.target_acceptance_status() == JwtTargetAcceptanceStatus::NotPerformed;
912    if !base_valid {
913        return Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch);
914    }
915
916    let state_valid = match audit.parsing_status() {
917        JwtParsingStatus::Rejected(_) => {
918            audit.policy_status() == JwtPolicyStatus::NotEvaluated
919                && audit.local_signature_status() == JwtLocalSignatureStatus::NotEvaluated
920                && audit.policy_violations().is_empty()
921        },
922        JwtParsingStatus::Parsed => {
923            matches!(
924                audit.local_signature_status(),
925                JwtLocalSignatureStatus::Verified | JwtLocalSignatureStatus::Invalid
926            ) && match audit.policy_status() {
927                JwtPolicyStatus::Consistent => audit.policy_violations().is_empty(),
928                JwtPolicyStatus::Inconsistent => !audit.policy_violations().is_empty(),
929                JwtPolicyStatus::NotEvaluated => false,
930            }
931        },
932    };
933    let mut required_ordinals = BTreeSet::new();
934    let ordinals_valid = audit.policy_violations().iter().all(|violation| {
935        let JwtPolicyViolation::MissingRequiredClaim { ordinal } = violation else {
936            return true;
937        };
938        *ordinal < methodology.required_claim_count() && required_ordinals.insert(*ordinal)
939    });
940    if state_valid && ordinals_valid {
941        Ok(())
942    } else {
943        Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch)
944    }
945}
946
947#[cfg(feature = "jwt-target-acceptance-review")]
948fn validate_jwt_target_acceptance_audit(
949    audit: Option<&JwtTargetAcceptanceAudit>,
950    assessment_request_count: Option<u64>,
951    assessment_response_bytes: Option<u64>,
952) -> Result<(), AssessmentRunReportError> {
953    let Some(audit) = audit else {
954        return Ok(());
955    };
956    let accounting = audit.accounting();
957    let dispatched = audit
958        .legs()
959        .iter()
960        .filter(|leg| leg.was_dispatched())
961        .count();
962    let committed = audit
963        .legs()
964        .iter()
965        .filter(|leg| leg.was_committed())
966        .count();
967    let active = audit
968        .legs()
969        .iter()
970        .filter(|leg| leg.was_dispatched() && leg.role().is_active())
971        .count();
972    let passive = dispatched.saturating_sub(active);
973    let accounted_bytes = audit.legs().iter().try_fold(0_u64, |sum, leg| {
974        sum.checked_add(leg.accounted_response_bytes())
975    });
976    let retained_bytes = audit.legs().iter().try_fold(0_u64, |sum, leg| {
977        sum.checked_add(leg.retained_response_bytes())
978    });
979    let zero_request_shape = audit.legs().is_empty()
980        && accounting.dispatched_request_count() == 0
981        && accounting.dispatched_passive_request_count() == 0
982        && accounting.dispatched_active_request_count() == 0
983        && accounting.committed_response_count() == 0
984        && accounting.retained_response_bytes() == 0
985        && accounting.accounted_response_bytes() == 0;
986    let selected_shape = audit.legs().len() == usize::from(MAX_JWT_TARGET_ACCEPTANCE_REQUESTS)
987        && dispatched == usize::from(accounting.dispatched_request_count())
988        && passive == usize::from(accounting.dispatched_passive_request_count())
989        && active == usize::from(accounting.dispatched_active_request_count())
990        && committed == usize::from(accounting.committed_response_count())
991        && retained_bytes == Some(accounting.retained_response_bytes())
992        && accounted_bytes == Some(accounting.accounted_response_bytes());
993    let valid = audit.schema() == JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA
994        && audit.policy() == JWT_TARGET_ACCEPTANCE_POLICY_ID
995        && valid_jwt_methodology_reference(audit.operator_policy_reference())
996        && valid_jwt_methodology_reference(audit.operator_policy_revision())
997        && valid_jwt_methodology_reference(audit.resource_reference())
998        && audit.preparation_binding().is_some()
999        && (zero_request_shape || selected_shape)
1000        && accounting.dispatched_request_count() <= MAX_JWT_TARGET_ACCEPTANCE_REQUESTS
1001        && accounting.dispatched_active_request_count()
1002            <= MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS
1003        && assessment_request_count
1004            .is_some_and(|count| u64::from(accounting.dispatched_request_count()) <= count)
1005        && assessment_response_bytes
1006            .is_some_and(|count| accounting.accounted_response_bytes() <= count);
1007    if valid {
1008        Ok(())
1009    } else {
1010        Err(AssessmentRunReportError::JwtTargetAcceptanceAuditMismatch)
1011    }
1012}
1013
1014#[cfg(feature = "jwt-policy-review")]
1015fn valid_jwt_methodology_reference(value: &str) -> bool {
1016    !value.is_empty()
1017        && value.len() <= 64
1018        && value.bytes().enumerate().all(|(index, byte)| {
1019            byte.is_ascii_lowercase()
1020                || byte.is_ascii_digit()
1021                || (index > 0 && matches!(byte, b'-' | b'_' | b'.'))
1022        })
1023}
1024
1025#[cfg(feature = "jwt-policy-review")]
1026fn valid_jwt_public_key_sha256(value: &str) -> bool {
1027    value.strip_prefix("sha256:").is_some_and(|digest| {
1028        digest.len() == 64
1029            && digest
1030                .bytes()
1031                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1032    })
1033}
1034
1035#[cfg(feature = "tls-observation")]
1036fn validate_tls_observation_audit(
1037    audit: Option<&WebAssessmentTlsObservationAudit>,
1038    target: &str,
1039    assessment_request_count: Option<u64>,
1040) -> Result<(), AssessmentRunReportError> {
1041    let Some(audit) = audit else {
1042        return Ok(());
1043    };
1044    let target_scheme = Url::parse(target)
1045        .map_err(|_| AssessmentRunReportError::TlsObservationAuditMismatch)?
1046        .scheme()
1047        .to_owned();
1048    let observed_response_count = audit
1049        .successful_https_response_count()
1050        .checked_add(audit.plaintext_response_count())
1051        .ok_or(AssessmentRunReportError::TlsObservationAuditMismatch)?;
1052    let https_partition = audit
1053        .tls_info_unavailable_count()
1054        .checked_add(audit.malformed_certificate_count())
1055        .and_then(|count| count.checked_add(audit.certificate_limit_rejection_count()))
1056        .and_then(|count| count.checked_add(audit.unretained_leaf_response_count()))
1057        .and_then(|count| {
1058            audit
1059                .leaf_observations()
1060                .iter()
1061                .try_fold(count, |sum, row| {
1062                    sum.checked_add(row.response_occurrence_count())
1063                })
1064        })
1065        .ok_or(AssessmentRunReportError::TlsObservationAuditMismatch)?;
1066    let valid = audit.schema() == TLS_OBSERVATION_AUDIT_SCHEMA
1067        && audit.policy() == TLS_OBSERVATION_POLICY_ID
1068        && audit.selected()
1069        && audit.additional_request_count() == 0
1070        && audit.target_scheme().as_str() == target_scheme
1071        && audit.observation_source_scope() == TLS_OBSERVATION_SOURCE_SCOPE
1072        && audit.observation_clock_assurance() == TLS_OBSERVATION_CLOCK_ASSURANCE
1073        && audit.protocol() == TLS_OBSERVATION_BACKEND_LIMIT
1074        && audit.cipher_suite() == TLS_OBSERVATION_BACKEND_LIMIT
1075        && audit.alpn_protocol() == TLS_OBSERVATION_BACKEND_LIMIT
1076        && audit.full_chain() == TLS_OBSERVATION_BACKEND_LIMIT
1077        && audit.connection_reuse() == TLS_OBSERVATION_BACKEND_LIMIT
1078        && audit.session_resumption() == TLS_OBSERVATION_BACKEND_LIMIT
1079        && audit.handshake_kind() == TLS_OBSERVATION_BACKEND_LIMIT
1080        && audit.revocation() == TLS_OBSERVATION_REVOCATION_STATUS
1081        && audit.standard_transport_validation_scope() == TLS_OBSERVATION_VALIDATION_SCOPE
1082        && https_partition == audit.successful_https_response_count()
1083        && (target_scheme == "https" || audit.successful_https_response_count() == 0)
1084        && (target_scheme == "http" || audit.plaintext_response_count() == 0)
1085        && matches!(target_scheme.as_str(), "http" | "https")
1086        && assessment_request_count.is_some_and(|count| observed_response_count <= count);
1087    if valid {
1088        Ok(())
1089    } else {
1090        Err(AssessmentRunReportError::TlsObservationAuditMismatch)
1091    }
1092}
1093
1094#[cfg(feature = "tls-negotiation-review")]
1095fn validate_tls_negotiation_review_audit(
1096    audit: Option<&WebAssessmentTlsNegotiationReviewAudit>,
1097    transport: &TransportDispatchAudit,
1098    assessment_request_count: Option<u64>,
1099    assessment_response_bytes: Option<u64>,
1100) -> Result<(), AssessmentRunReportError> {
1101    let receipts = transport
1102        .receipts()
1103        .iter()
1104        .filter(|receipt| {
1105            receipt.action_id() == TLS_NEGOTIATION_REVIEW_ACTION_TLS13
1106                || receipt.action_id() == TLS_NEGOTIATION_REVIEW_ACTION_TLS12
1107        })
1108        .collect::<Vec<_>>();
1109    let Some(audit) = audit else {
1110        return if receipts.is_empty() {
1111            Ok(())
1112        } else {
1113            Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch)
1114        };
1115    };
1116    if audit.schema() != TLS_NEGOTIATION_REVIEW_AUDIT_SCHEMA
1117        || audit.policy() != TLS_NEGOTIATION_REVIEW_POLICY_ID
1118        || !audit.selected()
1119        || !audit.is_valid()
1120        || !audit.network_matrix_complete()
1121        || assessment_request_count
1122            .is_none_or(|count| u64::from(audit.attempted_connection_count()) > count)
1123        || assessment_response_bytes.is_none_or(|count| audit.accounted_ingress_tls_bytes() > count)
1124    {
1125        return Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch);
1126    }
1127
1128    if receipts.len() != usize::from(audit.attempted_connection_count())
1129        || receipts.windows(2).any(|pair| {
1130            pair[0].sequence() >= pair[1].sequence() || pair[0].action_id() == pair[1].action_id()
1131        })
1132        || receipts
1133            .first()
1134            .is_some_and(|receipt| receipt.action_id() != TLS_NEGOTIATION_REVIEW_ACTION_TLS13)
1135        || receipts
1136            .get(1)
1137            .is_some_and(|receipt| receipt.action_id() != TLS_NEGOTIATION_REVIEW_ACTION_TLS12)
1138    {
1139        return Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch);
1140    }
1141
1142    for cell in audit.cells().iter().take(2) {
1143        let action_id = match cell.id() {
1144            "tls13_default_provider" => TLS_NEGOTIATION_REVIEW_ACTION_TLS13,
1145            "tls12_default_provider" => TLS_NEGOTIATION_REVIEW_ACTION_TLS12,
1146            _ => return Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch),
1147        };
1148        let matches = receipts
1149            .iter()
1150            .copied()
1151            .filter(|receipt| receipt.action_id() == action_id)
1152            .collect::<Vec<_>>();
1153        if cell.dispatch_status() == "not_dispatched" {
1154            if !matches.is_empty()
1155                || cell.accounted_ingress_tls_bytes() != 0
1156                || cell.observed_egress_tls_bytes() != 0
1157            {
1158                return Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch);
1159            }
1160            continue;
1161        }
1162        if cell.dispatch_status() != "dispatched" || matches.len() != 1 {
1163            return Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch);
1164        }
1165        let receipt = matches[0];
1166        let expected_outcome = match cell.outcome() {
1167            "negotiated" => TransportDispatchOutcome::Completed,
1168            "peer_alert_received"
1169            | "certificate_validation_failed"
1170            | "handshake_failed"
1171            | "transport_failed" => TransportDispatchOutcome::TransportFailure,
1172            "budget_exhausted" => TransportDispatchOutcome::ResponseBudgetReached,
1173            "cancelled" => TransportDispatchOutcome::Cancelled,
1174            "deadline_reached" => TransportDispatchOutcome::RequestTimeout,
1175            _ => return Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch),
1176        };
1177        if receipt.stage() != DecisionExecutionStage::Active
1178            || receipt.origin().is_some()
1179            || receipt.request_body_bytes() != 0
1180            || receipt.response_bytes() != cell.accounted_ingress_tls_bytes()
1181            || receipt.outcome() != expected_outcome
1182        {
1183            return Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch);
1184        }
1185    }
1186    Ok(())
1187}
1188
1189#[cfg(feature = "recon-ct-provider")]
1190fn validate_recon_ct_provider_audit(
1191    audit: Option<&WebAssessmentReconCtProviderAudit>,
1192    total_request_count: Option<u64>,
1193    total_response_bytes: Option<u64>,
1194) -> Result<(), AssessmentRunReportError> {
1195    let Some(audit) = audit else {
1196        return Ok(());
1197    };
1198    let admitted = u64::try_from(audit.request_admitted_count())
1199        .map_err(|_| AssessmentRunReportError::ReconCtProviderAuditMismatch)?;
1200    if !audit.is_consistent()
1201        || total_request_count.is_none_or(|total| admitted > total)
1202        || total_response_bytes.is_none_or(|total| audit.observed_response_bytes() > total)
1203    {
1204        return Err(AssessmentRunReportError::ReconCtProviderAuditMismatch);
1205    }
1206    Ok(())
1207}
1208
1209#[cfg(feature = "secret-exposure-review")]
1210fn validate_secret_exposure_audit(
1211    audit: Option<&WebAssessmentSecretExposureAudit>,
1212    items: &[AssessmentItem],
1213    evidence_references: &BTreeMap<EvidenceId, AssessmentEvidenceReference>,
1214) -> Result<(), AssessmentRunReportError> {
1215    let is_secret_item = |item: &AssessmentItem| {
1216        matches!(
1217            item.capability_id(),
1218            "exposure.response-private-key-material@1"
1219                | "exposure.response-aws-access-key-pair@1"
1220                | "exposure.response-stripe-live-secret@1"
1221                | "exposure.response-bearer-authorization@1"
1222        )
1223    };
1224    let projected = items
1225        .iter()
1226        .filter(|item| is_secret_item(item))
1227        .collect::<Vec<_>>();
1228    let Some(audit) = audit else {
1229        return if projected.is_empty() {
1230            Ok(())
1231        } else {
1232            Err(AssessmentRunReportError::SecretExposureAuditMismatch)
1233        };
1234    };
1235    let outcome_sum = audit
1236        .outcomes()
1237        .iter()
1238        .try_fold(0_u32, |count, row| count.checked_add(row.count()));
1239    let evaluated = audit
1240        .outcomes()
1241        .iter()
1242        .find(|row| row.outcome().as_str() == "evaluated")
1243        .map_or(0, |row| row.count());
1244    let retained_occurrences = audit
1245        .observations()
1246        .iter()
1247        .try_fold(0_u64, |count, observation| {
1248            count.checked_add(u64::from(observation.occurrence_count()))
1249        });
1250    if usize::try_from(audit.response_count()).unwrap_or(usize::MAX) > MAX_SECRET_EXPOSURE_RESPONSES
1251        || audit.evaluated_response_count() != evaluated
1252        || audit.not_evaluated_response_count()
1253            != audit
1254                .response_count()
1255                .saturating_sub(audit.evaluated_response_count())
1256        || outcome_sum != Some(audit.response_count())
1257        || audit.interpreted_byte_count() > MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES
1258        || audit.interpreted_byte_count()
1259            > u64::from(audit.evaluated_response_count())
1260                .saturating_mul(MAX_SECRET_EXPOSURE_BODY_BYTES as u64)
1261        || audit.observations().len() > MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS
1262        || projected.len() != audit.observations().len()
1263        || audit.match_occurrence_count()
1264            > u64::from(audit.response_count())
1265                .saturating_mul(u64::from(MAX_SECRET_EXPOSURE_OCCURRENCES))
1266        || retained_occurrences.is_none_or(|count| {
1267            if audit.omitted_observation_count() == 0 {
1268                count != audit.match_occurrence_count()
1269            } else {
1270                count >= audit.match_occurrence_count()
1271            }
1272        })
1273    {
1274        return Err(AssessmentRunReportError::SecretExposureAuditMismatch);
1275    }
1276
1277    let mut paired_items = BTreeSet::new();
1278    let mut paired_evidence = BTreeSet::new();
1279    for observation in audit.observations() {
1280        if observation.occurrence_count() == 0
1281            || observation.occurrence_count() > MAX_SECRET_EXPOSURE_OCCURRENCES
1282            || observation.evidence_ids().len() != 1
1283        {
1284            return Err(AssessmentRunReportError::SecretExposureAuditMismatch);
1285        }
1286        let evidence_id = &observation.evidence_ids()[0];
1287        let evidence_reference = evidence_references
1288            .get(evidence_id)
1289            .copied()
1290            .ok_or(AssessmentRunReportError::SecretExposureAuditMismatch)?;
1291        if !paired_evidence.insert(evidence_reference) {
1292            return Err(AssessmentRunReportError::SecretExposureAuditMismatch);
1293        }
1294        let matches = projected
1295            .iter()
1296            .enumerate()
1297            .filter(|(_, item)| {
1298                if item.capability_id() != observation.detector_class().capability_id() {
1299                    return false;
1300                }
1301                matches!(
1302                    item.basis(),
1303                    AssessmentBasis::Observation(basis)
1304                        if basis.evidence() == [evidence_reference]
1305                )
1306            })
1307            .map(|(index, _)| index)
1308            .collect::<Vec<_>>();
1309        if matches.len() != 1 || !paired_items.insert(matches[0]) {
1310            return Err(AssessmentRunReportError::SecretExposureAuditMismatch);
1311        }
1312    }
1313    if paired_items.len() != projected.len() {
1314        return Err(AssessmentRunReportError::SecretExposureAuditMismatch);
1315    }
1316    Ok(())
1317}
1318
1319#[cfg(feature = "wordpress-review")]
1320fn validate_wordpress_audit(
1321    audit: Option<&WebAssessmentWordPressAudit>,
1322    items: &[AssessmentItem],
1323) -> Result<(), AssessmentRunReportError> {
1324    let projected = items
1325        .iter()
1326        .filter(|item| item.capability_id() == WORDPRESS_REVIEW_CAPABILITY_ID)
1327        .count();
1328    if projected > 1 {
1329        return Err(AssessmentRunReportError::WordPressAuditMismatch);
1330    }
1331    let discovery_items = items
1332        .iter()
1333        .filter(|item| item.capability_id() == WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID)
1334        .collect::<Vec<_>>();
1335    if discovery_items.len() > 1 {
1336        return Err(AssessmentRunReportError::WordPressAuditMismatch);
1337    }
1338    let Some(audit) = audit else {
1339        return if projected == 0 && discovery_items.is_empty() {
1340            Ok(())
1341        } else {
1342            Err(AssessmentRunReportError::WordPressAuditMismatch)
1343        };
1344    };
1345    let result = audit.result();
1346    let fingerprint_attempted_request_count = audit.asset_fingerprints().map_or(
1347        0,
1348        WordPressAssetFingerprintExecution::attempted_request_count,
1349    );
1350    let fingerprint_evidence_reference_count = audit
1351        .asset_fingerprints()
1352        .into_iter()
1353        .flat_map(WordPressAssetFingerprintExecution::resources)
1354        .flat_map(WordPressAssetFingerprintResourceReceipt::evidence_ids)
1355        .collect::<std::collections::BTreeSet<_>>()
1356        .len();
1357    let discovery_consistent = match audit.discovery() {
1358        Some(discovery) => {
1359            let page_attempted_request_count = discovery
1360                .page_scope()
1361                .map_or(0, |pages| pages.attempted_request_count());
1362            let page_committed_response_count = discovery
1363                .page_scope()
1364                .map_or(0, |pages| pages.committed_response_count());
1365            let supplied_session_page_evidence_count = discovery
1366                .supplied_session_pages()
1367                .into_iter()
1368                .flat_map(|pages| pages.pages())
1369                .flat_map(|page| page.evidence_ids())
1370                .try_fold(0_usize, |count, _| count.checked_add(1));
1371            let total_attempted_request_count = discovery
1372                .attempted_request_count()
1373                .checked_add(page_attempted_request_count)
1374                .and_then(|count| count.checked_add(fingerprint_attempted_request_count));
1375            let total_committed_evidence_count = usize::from(discovery.committed_response_count())
1376                .checked_add(usize::from(page_committed_response_count))
1377                .and_then(|count| {
1378                    supplied_session_page_evidence_count
1379                        .and_then(|session_pages| count.checked_add(session_pages))
1380                })
1381                .and_then(|count| count.checked_add(fingerprint_evidence_reference_count));
1382            let expected_item = total_committed_evidence_count.is_some_and(|count| count > 0);
1383            discovery.is_internally_consistent()
1384                && total_attempted_request_count == Some(audit.additional_request_count())
1385                && (discovery_items.len() == 1) == expected_item
1386                && discovery_items.first().is_none_or(|item| {
1387                    total_committed_evidence_count
1388                        .is_some_and(|count| item.evidence_count() == count)
1389                })
1390        },
1391        None => {
1392            audit.asset_fingerprints().is_none()
1393                && audit.additional_request_count() == 0
1394                && discovery_items.is_empty()
1395        },
1396    };
1397    let version_evidence_count = result
1398        .components()
1399        .iter()
1400        .try_fold(0_usize, |count, component| {
1401            count.checked_add(component.versions().len())
1402        });
1403    let catalog_consistent = matches!(
1404        (
1405            result.catalog_status(),
1406            result.catalog_metadata(),
1407            result.external_review(),
1408        ),
1409        (WordPressCatalogStatus::CatalogNotSupplied, None, None)
1410            | (WordPressCatalogStatus::Evaluated, Some(_), None)
1411            | (WordPressCatalogStatus::Evaluated, None, Some(_))
1412    );
1413    if usize::from(audit.signal_count()) > MAX_WORDPRESS_SIGNALS
1414        || result.components().len() > MAX_WORDPRESS_RESULT_COMPONENTS
1415        || version_evidence_count.is_none_or(|count| count > MAX_WORDPRESS_RESULT_VERSION_EVIDENCE)
1416        || result.advisories().len() > MAX_WORDPRESS_ADVISORY_RECORDS
1417        || !discovery_consistent
1418        || audit.item_projected() != (projected == 1)
1419        || audit.item_projected() != (audit.signal_count() > 0)
1420        || !catalog_consistent
1421    {
1422        return Err(AssessmentRunReportError::WordPressAuditMismatch);
1423    }
1424    Ok(())
1425}
1426
1427#[cfg(feature = "ssrf-oast-review")]
1428fn validate_ssrf_oast_audit(
1429    audit: Option<&WebAssessmentSsrfOastAudit>,
1430    items: &[AssessmentItem],
1431) -> Result<(), AssessmentRunReportError> {
1432    let projected = items
1433        .iter()
1434        .filter(|item| item.capability_id() == SSRF_OAST_REVIEW_CAPABILITY_ID)
1435        .count();
1436    if projected > 1 {
1437        return Err(AssessmentRunReportError::SsrfOastAuditMismatch);
1438    }
1439    let Some(audit) = audit else {
1440        return if projected == 0 {
1441            Ok(())
1442        } else {
1443            Err(AssessmentRunReportError::SsrfOastAuditMismatch)
1444        };
1445    };
1446    let positive = audit.outcome() == SsrfOastRuntimeOutcome::RepeatedCallbacksObserved;
1447    if usize::from(audit.target_request_count()) > MAX_SSRF_OAST_REVIEW_REQUESTS
1448        || usize::from(audit.provider_request_count()) > MAX_SSRF_OAST_REVIEW_PROVIDER_REQUESTS
1449        || usize::from(audit.active_verification_count())
1450            > MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS
1451        || audit.item_projected() != (projected == 1)
1452        || positive != audit.item_projected()
1453        || (positive
1454            && (usize::from(audit.target_request_count()) != MAX_SSRF_OAST_REVIEW_REQUESTS
1455                || usize::from(audit.active_verification_count())
1456                    != MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS
1457                || !audit.preflight_clean()
1458                || !audit.candidate_callback_observed()
1459                || !audit.replay_callback_observed()
1460                || !audit.cleanup_verified()))
1461    {
1462        return Err(AssessmentRunReportError::SsrfOastAuditMismatch);
1463    }
1464    Ok(())
1465}
1466
1467#[cfg(feature = "xml-external-entity-review")]
1468fn validate_xml_external_entity_audit(
1469    audit: Option<&XmlExternalEntityReviewAudit>,
1470    items: &[AssessmentItem],
1471    assessment_request_count: Option<u64>,
1472    assessment_request_body_bytes: Option<u64>,
1473    assessment_response_bytes: Option<u64>,
1474) -> Result<(), AssessmentRunReportError> {
1475    let projected = items
1476        .iter()
1477        .filter(|item| item.capability_id() == XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID)
1478        .count();
1479    if projected > 1 {
1480        return Err(AssessmentRunReportError::XmlExternalEntityAuditMismatch);
1481    }
1482    let Some(audit) = audit else {
1483        return if projected == 0 {
1484            Ok(())
1485        } else {
1486            Err(AssessmentRunReportError::XmlExternalEntityAuditMismatch)
1487        };
1488    };
1489
1490    let target_requests = usize::from(audit.target_request_count());
1491    let provider_requests = usize::from(audit.provider_request_count());
1492    let active_verifications = usize::from(audit.active_verification_count());
1493    let positive =
1494        audit.outcome() == XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved;
1495    let request_total = target_requests.checked_add(provider_requests);
1496    let parent_counts_cover_child = request_total.is_some_and(|count| {
1497        assessment_request_count
1498            .is_some_and(|parent| u64::try_from(count).is_ok_and(|count| count <= parent))
1499    }) && assessment_request_body_bytes
1500        .is_some_and(|parent| audit.target_request_body_bytes() <= parent)
1501        && assessment_response_bytes.is_some_and(|parent| audit.target_response_bytes() <= parent);
1502    let positive_shape = target_requests == XML_EXTERNAL_ENTITY_TARGET_REQUESTS
1503        && active_verifications == XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS
1504        && audit.target_complete()
1505        && audit.preflight_clean()
1506        && !audit.control_callback_observed()
1507        && audit.candidate_callback_observed()
1508        && audit.replay_callback_observed()
1509        && audit.callback_targets_distinct()
1510        && audit.event_identities_distinct()
1511        && audit.cleanup_verified()
1512        && audit.target_accounting_complete()
1513        && audit.provider_accounting_complete();
1514    let valid = audit.schema() == XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA
1515        && audit.algorithm() == XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM
1516        && audit.action_id() == XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID
1517        && audit.capability_id() == XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID
1518        && target_requests <= XML_EXTERNAL_ENTITY_TARGET_REQUESTS
1519        && provider_requests <= MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS
1520        && active_verifications <= XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS
1521        && parent_counts_cover_child
1522        && audit.item_projected() == (projected == 1)
1523        && positive == audit.item_projected()
1524        && (!positive || positive_shape);
1525    if valid {
1526        Ok(())
1527    } else {
1528        Err(AssessmentRunReportError::XmlExternalEntityAuditMismatch)
1529    }
1530}
1531
1532#[cfg(feature = "template-evaluation-review")]
1533fn validate_template_evaluation_review_audit(
1534    audit: Option<&TemplateEvaluationReviewAudit>,
1535    items: &[AssessmentItem],
1536    assessment_request_count: Option<u64>,
1537) -> Result<(), AssessmentRunReportError> {
1538    let matching_items = items
1539        .iter()
1540        .filter(|item| item.capability_id() == TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID)
1541        .collect::<Vec<_>>();
1542    if matching_items.len() > 1 {
1543        return Err(AssessmentRunReportError::TemplateEvaluationReviewAuditMismatch);
1544    }
1545    let Some(audit) = audit else {
1546        return if matching_items.is_empty() {
1547            Ok(())
1548        } else {
1549            Err(AssessmentRunReportError::TemplateEvaluationReviewAuditMismatch)
1550        };
1551    };
1552
1553    let projected_count = usize::from(audit.projected_item_count());
1554    if audit.capability_id() != TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID
1555        || projected_count != matching_items.len()
1556        || assessment_request_count
1557            .is_none_or(|parent| u64::from(audit.attempted_request_count()) > parent)
1558        || (audit.outcome() == TemplateEvaluationReviewOutcome::CandidateSpecificEvaluation)
1559            != (projected_count == 1)
1560    {
1561        return Err(AssessmentRunReportError::TemplateEvaluationReviewAuditMismatch);
1562    }
1563
1564    if let Some(item) = matching_items.first() {
1565        let remediation = item.remediation();
1566        let AssessmentBasis::Differential(basis) = item.basis() else {
1567            return Err(AssessmentRunReportError::TemplateEvaluationReviewAuditMismatch);
1568        };
1569        if item.presentation_target_kind() != "query_parameter"
1570            || item.title() != TEMPLATE_EVALUATION_REVIEW_TITLE
1571            || item.disposition() != AssessmentDisposition::NeedsReview
1572            || item.severity().is_some()
1573            || item.redacted_summary() != TEMPLATE_EVALUATION_REVIEW_SUMMARY
1574            || item.category() != TEMPLATE_EVALUATION_REVIEW_CATEGORY
1575            || item.cwe() != Some("CWE-1336")
1576            || remediation.id() != TEMPLATE_EVALUATION_REVIEW_REMEDIATION_ID
1577            || remediation.summary() != TEMPLATE_EVALUATION_REVIEW_REMEDIATION_SUMMARY
1578            || basis.control().is_empty()
1579            || basis.candidate().is_empty()
1580            || basis.paired_comparison().is_some()
1581        {
1582            return Err(AssessmentRunReportError::TemplateEvaluationReviewAuditMismatch);
1583        }
1584    }
1585    Ok(())
1586}
1587
1588#[cfg(feature = "openapi-review")]
1589fn validate_openapi_audit(
1590    audit: Option<&WebAssessmentOpenApiAudit>,
1591    items: &[AssessmentItem],
1592) -> Result<(), AssessmentRunReportError> {
1593    let projected = items
1594        .iter()
1595        .filter(|item| item.capability_id() == OPENAPI_REVIEW_CAPABILITY_ID)
1596        .count();
1597    if projected > 1 {
1598        return Err(AssessmentRunReportError::OpenApiAuditMismatch);
1599    }
1600    let Some(audit) = audit else {
1601        return if projected == 0 {
1602            Ok(())
1603        } else {
1604            Err(AssessmentRunReportError::OpenApiAuditMismatch)
1605        };
1606    };
1607    if usize::from(audit.request_count()) > MAX_OPENAPI_REVIEW_REQUESTS
1608        || audit.active_verification_count() > 1
1609        || audit.item_projected() != (projected == 1)
1610        || (audit.outcome() == OpenApiRuntimeOutcome::DocumentObserved) != (projected == 1)
1611    {
1612        return Err(AssessmentRunReportError::OpenApiAuditMismatch);
1613    }
1614    Ok(())
1615}
1616
1617#[cfg(feature = "rest-review")]
1618fn validate_rest_audit(
1619    audit: Option<&WebAssessmentRestAudit>,
1620    items: &[AssessmentItem],
1621    response_privacy: bool,
1622) -> Result<(), AssessmentRunReportError> {
1623    let projected = items
1624        .iter()
1625        .filter(|item| item.capability_id() == REST_REVIEW_CAPABILITY_ID)
1626        .count();
1627    if projected > 1 {
1628        return Err(AssessmentRunReportError::RestAuditMismatch);
1629    }
1630    let Some(audit) = audit else {
1631        return if projected == 0 {
1632            Ok(())
1633        } else {
1634            Err(AssessmentRunReportError::RestAuditMismatch)
1635        };
1636    };
1637    if !RestAuditFacts::from_audit(audit).is_valid_for_privacy(projected, response_privacy) {
1638        return Err(AssessmentRunReportError::RestAuditMismatch);
1639    }
1640    Ok(())
1641}
1642
1643#[cfg(feature = "rest-review")]
1644#[derive(Clone, Copy)]
1645struct RestAuditFacts {
1646    outcome: RestRuntimeOutcome,
1647    request_count: usize,
1648    active_verification_count: usize,
1649    eligible_operation_count: u32,
1650    selected_operation_present: bool,
1651    replay_stable: bool,
1652    item_projected: bool,
1653}
1654
1655#[cfg(feature = "rest-review")]
1656impl RestAuditFacts {
1657    fn from_audit(audit: &WebAssessmentRestAudit) -> Self {
1658        Self {
1659            outcome: audit.outcome(),
1660            request_count: usize::from(audit.request_count()),
1661            active_verification_count: usize::from(audit.active_verification_count()),
1662            eligible_operation_count: audit.eligible_operation_count(),
1663            selected_operation_present: audit.selected_operation_identity().is_some(),
1664            replay_stable: audit.replay_stable(),
1665            item_projected: audit.item_projected(),
1666        }
1667    }
1668
1669    #[cfg(test)]
1670    fn is_valid(self, projected: usize) -> bool {
1671        self.is_valid_for_privacy(projected, false)
1672    }
1673
1674    fn is_valid_for_privacy(self, projected: usize, response_privacy: bool) -> bool {
1675        let positive = self.outcome == RestRuntimeOutcome::SurfaceObserved;
1676        self.request_count <= MAX_REST_REVIEW_REQUESTS
1677            && self.active_verification_count <= MAX_REST_REVIEW_ACTIVE_VERIFICATIONS
1678            && self.active_verification_count
1679                == usize::from(self.request_count == MAX_REST_REVIEW_REQUESTS)
1680            && self.item_projected == (projected == 1)
1681            && positive == self.item_projected
1682            && self.replay_stable == positive
1683            && (!response_privacy || !self.selected_operation_present)
1684            && (!positive
1685                || (self.request_count == MAX_REST_REVIEW_REQUESTS
1686                    && self.active_verification_count == MAX_REST_REVIEW_ACTIVE_VERIFICATIONS
1687                    && self.eligible_operation_count > 0
1688                    && self.selected_operation_present != response_privacy))
1689    }
1690}
1691
1692impl fmt::Debug for AssessmentRunReport {
1693    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1694        let mut debug = formatter.debug_struct("AssessmentRunReport");
1695        debug
1696            .field("schema", &ASSESSMENT_RUN_REPORT_SCHEMA)
1697            .field("run_report", &"<redacted>")
1698            .field("profile", &self.profile.profile().id())
1699            .field("subject_count", &self.subjects.len())
1700            .field("item_count", &self.items.len());
1701        #[cfg(feature = "supplied-session-review")]
1702        debug.field(
1703            "supplied_session_audit_present",
1704            &self.supplied_session.is_some(),
1705        );
1706        #[cfg(feature = "authorization-review")]
1707        debug.field(
1708            "authorization_review_audit_present",
1709            &self.authorization_review.is_some(),
1710        );
1711        #[cfg(feature = "websocket-review")]
1712        debug.field(
1713            "websocket_review_audit_present",
1714            &self.websocket_review.is_some(),
1715        );
1716        #[cfg(feature = "jwt-target-acceptance-review")]
1717        debug.field(
1718            "jwt_target_acceptance_audit_present",
1719            &self.jwt_target_acceptance.is_some(),
1720        );
1721        #[cfg(feature = "openapi-review")]
1722        debug.field(
1723            "openapi_review_audit_present",
1724            &self.openapi_review.is_some(),
1725        );
1726        #[cfg(feature = "rest-review")]
1727        debug.field("rest_review_audit_present", &self.rest_review.is_some());
1728        #[cfg(feature = "ssrf-oast-review")]
1729        debug.field(
1730            "ssrf_oast_review_audit_present",
1731            &self.ssrf_oast_review.is_some(),
1732        );
1733        #[cfg(feature = "xml-external-entity-review")]
1734        debug.field(
1735            "xml_external_entity_review_audit_present",
1736            &self.xml_external_entity_review.is_some(),
1737        );
1738        #[cfg(feature = "template-evaluation-review")]
1739        debug.field(
1740            "template_evaluation_review_audit_present",
1741            &self.template_evaluation_review.is_some(),
1742        );
1743        #[cfg(feature = "wordpress-review")]
1744        debug.field(
1745            "wordpress_review_audit_present",
1746            &self.wordpress_review.is_some(),
1747        );
1748        #[cfg(feature = "secret-exposure-review")]
1749        debug.field(
1750            "secret_exposure_review_audit_present",
1751            &self.secret_exposure_review.is_some(),
1752        );
1753        #[cfg(feature = "tls-observation")]
1754        debug.field(
1755            "tls_observation_audit_present",
1756            &self.tls_observation.is_some(),
1757        );
1758        #[cfg(feature = "tls-negotiation-review")]
1759        debug.field(
1760            "tls_negotiation_review_audit_present",
1761            &self.tls_negotiation_review.is_some(),
1762        );
1763        #[cfg(feature = "recon-ct-provider")]
1764        debug.field(
1765            "recon_ct_provider_audit_present",
1766            &self.recon_ct_provider.is_some(),
1767        );
1768        #[cfg(feature = "jwt-policy-review")]
1769        debug.field(
1770            "jwt_policy_review_audit_present",
1771            &self.jwt_policy_review.is_some(),
1772        );
1773        #[cfg(feature = "control-reference-mapping")]
1774        debug.field(
1775            "control_reference_mapping_audit_present",
1776            &self.control_reference_mapping.is_some(),
1777        );
1778        #[cfg(feature = "recon-snapshot-import")]
1779        debug.field(
1780            "recon_snapshot_audit_present",
1781            &self.recon_snapshot.is_some(),
1782        );
1783        debug.finish()
1784    }
1785}
1786
1787#[cfg(feature = "supplied-session-review")]
1788const fn valid_v1_supplied_session_outcome(outcome: SuppliedSessionAuditOutcome) -> bool {
1789    !matches!(
1790        outcome,
1791        SuppliedSessionAuditOutcome::CredentialUpdateRequired
1792            | SuppliedSessionAuditOutcome::CredentialUpdateUnusable
1793            | SuppliedSessionAuditOutcome::LoginFormUnavailable
1794            | SuppliedSessionAuditOutcome::LoginSubmitUnavailable
1795            | SuppliedSessionAuditOutcome::LoginCookieUnavailable
1796            | SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable
1797            | SuppliedSessionAuditOutcome::OAuthRefreshUnavailable
1798    )
1799}
1800
1801#[cfg(feature = "supplied-session-review")]
1802const fn valid_v2_cookie_lifecycle_outcome(
1803    outcome: SuppliedSessionAuditOutcome,
1804    selected_update_response_count: u8,
1805    update_classification_failure_count: u8,
1806) -> bool {
1807    match outcome {
1808        SuppliedSessionAuditOutcome::CredentialUpdateRequired => {
1809            selected_update_response_count == 1 && update_classification_failure_count == 0
1810        },
1811        SuppliedSessionAuditOutcome::CredentialUpdateUnusable => {
1812            selected_update_response_count == 0 && update_classification_failure_count == 1
1813        },
1814        SuppliedSessionAuditOutcome::LoginFormUnavailable
1815        | SuppliedSessionAuditOutcome::LoginSubmitUnavailable
1816        | SuppliedSessionAuditOutcome::LoginCookieUnavailable => false,
1817        _ => selected_update_response_count == 0 && update_classification_failure_count == 0,
1818    }
1819}
1820
1821#[cfg(feature = "supplied-session-review")]
1822const fn valid_v2_cookie_lifecycle_accounting(
1823    selected_update_response_count: u8,
1824    unselected_update_response_count: u8,
1825    update_classification_failure_count: u8,
1826    dispatched_request_count: u8,
1827) -> bool {
1828    selected_update_response_count <= dispatched_request_count
1829        && (unselected_update_response_count as u16 + update_classification_failure_count as u16)
1830            <= dispatched_request_count as u16
1831}
1832
1833#[cfg(feature = "supplied-session-review")]
1834const fn valid_v3_cookie_lifecycle_accounting(
1835    selected_update_response_count: u8,
1836    unselected_update_response_count: u8,
1837    update_classification_failure_count: u8,
1838    dispatched_request_count: u8,
1839    login_request_count: u8,
1840) -> bool {
1841    let Some(update_eligible_request_count) =
1842        dispatched_request_count.checked_sub(login_request_count)
1843    else {
1844        return false;
1845    };
1846    valid_v2_cookie_lifecycle_accounting(
1847        selected_update_response_count,
1848        unselected_update_response_count,
1849        update_classification_failure_count,
1850        update_eligible_request_count,
1851    )
1852}
1853
1854#[cfg(feature = "supplied-session-review")]
1855const fn valid_v3_cookie_lifecycle_outcome(
1856    outcome: SuppliedSessionAuditOutcome,
1857    selected_update_response_count: u8,
1858    update_classification_failure_count: u8,
1859) -> bool {
1860    match outcome {
1861        SuppliedSessionAuditOutcome::CredentialUpdateRequired => {
1862            selected_update_response_count == 1 && update_classification_failure_count == 0
1863        },
1864        SuppliedSessionAuditOutcome::CredentialUpdateUnusable => {
1865            selected_update_response_count == 0 && update_classification_failure_count == 1
1866        },
1867        _ => selected_update_response_count == 0 && update_classification_failure_count == 0,
1868    }
1869}
1870
1871#[cfg(feature = "supplied-session-review")]
1872fn valid_v3_login_audit(
1873    audit: &WebAssessmentSuppliedSessionAudit,
1874    lifecycle_initial_epoch: u8,
1875    lifecycle_final_epoch: u8,
1876) -> bool {
1877    let Some(login) = audit.login() else {
1878        return false;
1879    };
1880    let page_contract = valid_v3_login_page_contract(
1881        login.form_outcome(),
1882        login.form_error_code(),
1883        login.page_dispatched(),
1884        login.page_status(),
1885        login.page_body_state(),
1886    );
1887    let submit_contract = valid_v3_login_submit_contract(
1888        login.submit_outcome(),
1889        login.submit_dispatched(),
1890        login.attempt_count(),
1891        login.submit_status(),
1892        login.submit_body_state(),
1893        login.acquired_cookie_count(),
1894    );
1895    let startup_authenticated = audit.checkpoints().first().is_some_and(|checkpoint| {
1896        checkpoint.phase() == SuppliedSessionHealthCheckpointPhase::Startup
1897            && checkpoint.outcome() == SuppliedSessionHealthOutcome::Healthy
1898    });
1899    let startup_cookie_update_stopped_transition = audit.checkpoints().len() == 1
1900        && audit
1901            .resources()
1902            .iter()
1903            .all(|resource| resource.outcome() == SuppliedSessionResourceOutcome::NotDispatched)
1904        && matches!(
1905            audit.outcome(),
1906            SuppliedSessionAuditOutcome::CredentialUpdateRequired
1907                | SuppliedSessionAuditOutcome::CredentialUpdateUnusable
1908        );
1909    let epoch_transition_is_valid = valid_v3_login_epoch_transition(
1910        login.final_epoch(),
1911        startup_authenticated,
1912        startup_cookie_update_stopped_transition,
1913    );
1914    let outcome_contract = valid_v3_login_outcome_contract(
1915        audit.outcome(),
1916        login.form_outcome(),
1917        login.submit_outcome(),
1918    );
1919    let dispatch_contract = valid_v3_login_dispatch_contract(
1920        login.form_outcome(),
1921        login.page_dispatched(),
1922        login.submit_dispatched(),
1923        login.response_bytes(),
1924    );
1925    let outcome_epoch_contract = match audit.outcome() {
1926        SuppliedSessionAuditOutcome::LoginFormUnavailable => login.final_epoch() == 0,
1927        SuppliedSessionAuditOutcome::LoginSubmitUnavailable => login.final_epoch() == 0,
1928        SuppliedSessionAuditOutcome::LoginCookieUnavailable => login.final_epoch() == 0,
1929        _ => true,
1930    };
1931    valid_supplied_session_reference(login.login_reference(), "supplied-session-login-sha256:")
1932        && login.max_attempts() == 1
1933        && login.attempt_count() <= login.max_attempts()
1934        && login.initial_epoch() == 0
1935        && epoch_transition_is_valid
1936        && lifecycle_initial_epoch == login.initial_epoch()
1937        && lifecycle_final_epoch == login.final_epoch()
1938        && !login.pre_session_cookie_applied()
1939        && page_contract
1940        && submit_contract
1941        && outcome_contract
1942        && dispatch_contract
1943        && outcome_epoch_contract
1944}
1945
1946#[cfg(feature = "supplied-session-review")]
1947fn valid_v3_login_page_contract(
1948    form_outcome: SuppliedSessionLoginFormOutcome,
1949    form_error_code: Option<&str>,
1950    page_dispatched: bool,
1951    page_status: Option<u16>,
1952    page_body_state: SuppliedSessionBodyState,
1953) -> bool {
1954    if page_status.is_some_and(|status| !(100..=599).contains(&status)) {
1955        return false;
1956    }
1957    match form_outcome {
1958        SuppliedSessionLoginFormOutcome::Matched => {
1959            page_dispatched
1960                && page_status == Some(200)
1961                && page_body_state == SuppliedSessionBodyState::Complete
1962                && form_error_code.is_none()
1963        },
1964        SuppliedSessionLoginFormOutcome::IneligibleResponse => {
1965            page_dispatched
1966                && page_status.is_some()
1967                && matches!(
1968                    page_body_state,
1969                    SuppliedSessionBodyState::Complete | SuppliedSessionBodyState::Incomplete
1970                )
1971                && matches!(
1972                    form_error_code,
1973                    Some(
1974                        "ineligible_response"
1975                            | "pre_session_cookie_unsupported"
1976                            | "inexact_final_target"
1977                    )
1978                )
1979        },
1980        SuppliedSessionLoginFormOutcome::Missing => {
1981            page_dispatched
1982                && page_status == Some(200)
1983                && page_body_state == SuppliedSessionBodyState::Complete
1984                && matches!(form_error_code, Some("missing_form" | "missing_csrf"))
1985        },
1986        SuppliedSessionLoginFormOutcome::Ambiguous => {
1987            page_dispatched
1988                && page_status == Some(200)
1989                && page_body_state == SuppliedSessionBodyState::Complete
1990                && matches!(form_error_code, Some("ambiguous_form" | "ambiguous_csrf"))
1991        },
1992        SuppliedSessionLoginFormOutcome::Invalid
1993            if form_error_code == Some("invalid_descriptor") =>
1994        {
1995            !page_dispatched
1996                && page_status.is_none()
1997                && page_body_state == SuppliedSessionBodyState::Unavailable
1998        },
1999        SuppliedSessionLoginFormOutcome::Invalid => {
2000            page_dispatched
2001                && page_status == Some(200)
2002                && page_body_state == SuppliedSessionBodyState::Complete
2003                && matches!(
2004                    form_error_code,
2005                    Some("invalid_form_contract" | "invalid_csrf" | "invalid_form_body")
2006                )
2007        },
2008        SuppliedSessionLoginFormOutcome::TransportFailed => {
2009            page_status.is_none()
2010                && page_body_state == SuppliedSessionBodyState::Unavailable
2011                && form_error_code == Some("transport_failed")
2012        },
2013        SuppliedSessionLoginFormOutcome::RuntimeLimit => {
2014            page_status.is_none()
2015                && page_body_state == SuppliedSessionBodyState::Unavailable
2016                && form_error_code == Some("runtime_limit")
2017        },
2018        SuppliedSessionLoginFormOutcome::Cancelled => {
2019            page_status.is_none()
2020                && page_body_state == SuppliedSessionBodyState::Unavailable
2021                && form_error_code == Some("cancelled")
2022        },
2023        SuppliedSessionLoginFormOutcome::NotEvaluated => false,
2024    }
2025}
2026
2027#[cfg(feature = "supplied-session-review")]
2028fn valid_v3_login_submit_contract(
2029    submit_outcome: SuppliedSessionLoginSubmitOutcome,
2030    submit_dispatched: bool,
2031    attempt_count: u8,
2032    submit_status: Option<u16>,
2033    submit_body_state: SuppliedSessionBodyState,
2034    acquired_cookie_count: u8,
2035) -> bool {
2036    if submit_status.is_some_and(|status| !(100..=599).contains(&status)) {
2037        return false;
2038    }
2039    match submit_outcome {
2040        SuppliedSessionLoginSubmitOutcome::CookieAcquired => {
2041            submit_dispatched
2042                && attempt_count == 1
2043                && submit_status == Some(200)
2044                && submit_body_state == SuppliedSessionBodyState::Complete
2045                && acquired_cookie_count > 0
2046        },
2047        SuppliedSessionLoginSubmitOutcome::CookieUnavailable => {
2048            submit_dispatched
2049                && attempt_count == 1
2050                && submit_status == Some(200)
2051                && submit_body_state == SuppliedSessionBodyState::Complete
2052                && acquired_cookie_count == 0
2053        },
2054        SuppliedSessionLoginSubmitOutcome::HttpError => {
2055            submit_dispatched
2056                && attempt_count == 1
2057                && submit_status
2058                    .is_some_and(|status| status != 200 && !(300..400).contains(&status))
2059                && submit_body_state == SuppliedSessionBodyState::Complete
2060                && acquired_cookie_count == 0
2061        },
2062        SuppliedSessionLoginSubmitOutcome::RedirectRefused => {
2063            submit_dispatched
2064                && attempt_count == 1
2065                && submit_status.is_some_and(|status| (300..400).contains(&status))
2066                && matches!(
2067                    submit_body_state,
2068                    SuppliedSessionBodyState::Complete | SuppliedSessionBodyState::Incomplete
2069                )
2070                && acquired_cookie_count == 0
2071        },
2072        SuppliedSessionLoginSubmitOutcome::Incomplete => {
2073            submit_dispatched
2074                && attempt_count == 1
2075                && submit_status.is_some_and(|status| !(300..400).contains(&status))
2076                && submit_body_state == SuppliedSessionBodyState::Incomplete
2077                && acquired_cookie_count == 0
2078        },
2079        SuppliedSessionLoginSubmitOutcome::TransportFailed
2080        | SuppliedSessionLoginSubmitOutcome::RuntimeLimit
2081        | SuppliedSessionLoginSubmitOutcome::Cancelled => {
2082            attempt_count == u8::from(submit_dispatched)
2083                && submit_status.is_none()
2084                && submit_body_state == SuppliedSessionBodyState::Unavailable
2085                && acquired_cookie_count == 0
2086        },
2087        SuppliedSessionLoginSubmitOutcome::NotDispatched => {
2088            !submit_dispatched
2089                && attempt_count == 0
2090                && submit_status.is_none()
2091                && submit_body_state == SuppliedSessionBodyState::Unavailable
2092                && acquired_cookie_count == 0
2093        },
2094    }
2095}
2096
2097#[cfg(feature = "supplied-session-review")]
2098fn valid_v3_login_outcome_contract(
2099    audit_outcome: SuppliedSessionAuditOutcome,
2100    form_outcome: SuppliedSessionLoginFormOutcome,
2101    submit_outcome: SuppliedSessionLoginSubmitOutcome,
2102) -> bool {
2103    match audit_outcome {
2104        SuppliedSessionAuditOutcome::LoginFormUnavailable => {
2105            form_outcome != SuppliedSessionLoginFormOutcome::Matched
2106                && form_outcome != SuppliedSessionLoginFormOutcome::NotEvaluated
2107                && submit_outcome == SuppliedSessionLoginSubmitOutcome::NotDispatched
2108        },
2109        SuppliedSessionAuditOutcome::LoginSubmitUnavailable => {
2110            form_outcome == SuppliedSessionLoginFormOutcome::Matched
2111                && !matches!(
2112                    submit_outcome,
2113                    SuppliedSessionLoginSubmitOutcome::CookieAcquired
2114                        | SuppliedSessionLoginSubmitOutcome::CookieUnavailable
2115                )
2116        },
2117        SuppliedSessionAuditOutcome::LoginCookieUnavailable => {
2118            form_outcome == SuppliedSessionLoginFormOutcome::Matched
2119                && submit_outcome == SuppliedSessionLoginSubmitOutcome::CookieUnavailable
2120        },
2121        SuppliedSessionAuditOutcome::RuntimeLimit => {
2122            form_outcome == SuppliedSessionLoginFormOutcome::RuntimeLimit
2123                || submit_outcome == SuppliedSessionLoginSubmitOutcome::RuntimeLimit
2124                || submit_outcome == SuppliedSessionLoginSubmitOutcome::CookieAcquired
2125        },
2126        SuppliedSessionAuditOutcome::Cancelled => {
2127            form_outcome == SuppliedSessionLoginFormOutcome::Cancelled
2128                || submit_outcome == SuppliedSessionLoginSubmitOutcome::Cancelled
2129                || submit_outcome == SuppliedSessionLoginSubmitOutcome::CookieAcquired
2130        },
2131        _ => submit_outcome == SuppliedSessionLoginSubmitOutcome::CookieAcquired,
2132    }
2133}
2134
2135#[cfg(feature = "supplied-session-review")]
2136fn valid_v3_form_cookie_count_contract(
2137    declared_count: u8,
2138    secure_count: u8,
2139    submit_outcome: SuppliedSessionLoginSubmitOutcome,
2140    acquired_cookie_count: u8,
2141) -> bool {
2142    (secure_count == 0 || secure_count == declared_count)
2143        && (submit_outcome != SuppliedSessionLoginSubmitOutcome::CookieAcquired
2144            || acquired_cookie_count == declared_count)
2145}
2146
2147#[cfg(feature = "supplied-session-review")]
2148fn valid_v3_login_dispatch_contract(
2149    form_outcome: SuppliedSessionLoginFormOutcome,
2150    page_dispatched: bool,
2151    submit_dispatched: bool,
2152    response_bytes: u64,
2153) -> bool {
2154    (!submit_dispatched || form_outcome == SuppliedSessionLoginFormOutcome::Matched)
2155        && (page_dispatched || submit_dispatched || response_bytes == 0)
2156}
2157
2158#[cfg(feature = "supplied-session-review")]
2159const fn valid_v3_login_epoch_transition(
2160    final_epoch: u8,
2161    startup_authenticated: bool,
2162    startup_cookie_update_stopped_transition: bool,
2163) -> bool {
2164    match final_epoch {
2165        1 => startup_authenticated,
2166        0 => !startup_authenticated || startup_cookie_update_stopped_transition,
2167        _ => false,
2168    }
2169}
2170
2171#[cfg(feature = "supplied-session-review")]
2172fn validate_supplied_session_audit(
2173    audit: Option<&WebAssessmentSuppliedSessionAudit>,
2174    items: &[AssessmentItem],
2175) -> Result<(), AssessmentRunReportError> {
2176    let projected_count = items
2177        .iter()
2178        .filter(|item| item.capability_id() == SUPPLIED_SESSION_CAPABILITY_ID)
2179        .count();
2180    if projected_count != 0 {
2181        return Err(AssessmentRunReportError::SuppliedSessionAuditMismatch);
2182    }
2183    let Some(audit) = audit else {
2184        return Ok(());
2185    };
2186    let oauth_code = audit.oauth_code();
2187    let authorization_schema = audit.schema() == SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA;
2188    let authorization = audit.oauth_authorization();
2189    let initiated_exchange = audit.oauth_token_exchange();
2190    if authorization_schema != authorization.is_some()
2191        || authorization_schema != initiated_exchange.is_some()
2192        || authorization
2193            .zip(initiated_exchange)
2194            .is_some_and(|(a, e)| !e.is_consistent_for(audit, a))
2195    {
2196        return Err(AssessmentRunReportError::SuppliedSessionAuditMismatch);
2197    }
2198    let oauth_refresh_schema = audit.schema() == SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA;
2199    let oauth_refresh = audit.oauth_refresh();
2200    if oauth_refresh_schema != oauth_refresh.is_some()
2201        || oauth_refresh.is_some_and(|refresh| !refresh.is_consistent_for(audit))
2202    {
2203        return Err(AssessmentRunReportError::SuppliedSessionAuditMismatch);
2204    }
2205    let oauth_schema = matches!(
2206        audit.schema(),
2207        SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA | SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA
2208    );
2209    if oauth_schema != oauth_code.is_some() {
2210        return Err(AssessmentRunReportError::SuppliedSessionAuditMismatch);
2211    }
2212    let oauth_request_count = oauth_code
2213        .map_or(0, |code| usize::from(code.request_admitted_count()))
2214        + initiated_exchange.map_or(0, |code| usize::from(code.request_admitted_count()));
2215    let oauth_refresh_health = oauth_refresh.and_then(|refresh| refresh.health());
2216    let oauth_refresh_requests = oauth_refresh.map_or(0, |refresh| {
2217        usize::from(refresh.request_admitted_count()) + usize::from(refresh.health().is_some())
2218    });
2219    let oauth_refresh_applied =
2220        oauth_refresh.is_some_and(|refresh| refresh.credential_commit_count() == 1);
2221    let renewal = audit.renewal();
2222    let renewal_schema = audit.schema() == SUPPLIED_SESSION_RENEWAL_AUDIT_SCHEMA;
2223    if renewal_schema != renewal.is_some() {
2224        return Err(AssessmentRunReportError::SuppliedSessionAuditMismatch);
2225    }
2226    let renewal_health = renewal.and_then(|renewal| renewal.health());
2227    let renewal_request_count = renewal.map_or(0, |renewal| {
2228        usize::from(renewal.attempt_count()) + usize::from(renewal.health().is_some())
2229    });
2230    let renewal_applied = renewal.is_some_and(|renewal| renewal.applied_cookie_count() > 0);
2231    let selected_resource_count = usize::from(audit.selected_resource_count());
2232    let dispatched_resource_count = usize::from(audit.dispatched_resource_count());
2233    let committed_resource_count = usize::from(audit.committed_resource_count());
2234    let dispatched_request_count = usize::from(audit.dispatched_request_count());
2235    let actual_dispatched_resource_count = audit
2236        .resources()
2237        .iter()
2238        .filter(|resource| resource.outcome() != SuppliedSessionResourceOutcome::NotDispatched)
2239        .count();
2240    let actual_committed_resource_count = audit
2241        .resources()
2242        .iter()
2243        .filter(|resource| resource.outcome() == SuppliedSessionResourceOutcome::Committed)
2244        .count();
2245    let child_response_bytes = audit
2246        .login()
2247        .map_or(0, |login| login.response_bytes())
2248        .checked_add(
2249            audit
2250                .checkpoints()
2251                .iter()
2252                .map(|checkpoint| checkpoint.response_bytes())
2253                .chain(
2254                    audit
2255                        .resources()
2256                        .iter()
2257                        .map(|resource| resource.response_bytes()),
2258                )
2259                .try_fold(0_u64, u64::checked_add)
2260                .ok_or(AssessmentRunReportError::SuppliedSessionAuditMismatch)?,
2261        )
2262        .and_then(|bytes| {
2263            bytes
2264                .checked_add(renewal.map_or(0, |renewal| renewal.response_bytes()))
2265                .and_then(|bytes| {
2266                    bytes.checked_add(renewal_health.map_or(0, |health| health.response_bytes()))
2267                })
2268        });
2269    let child_response_bytes = child_response_bytes
2270        .and_then(|bytes| bytes.checked_add(oauth_code.map_or(0, |code| code.response_bytes())))
2271        .and_then(|bytes| {
2272            bytes.checked_add(initiated_exchange.map_or(0, |code| code.response_bytes()))
2273        })
2274        .and_then(|bytes| {
2275            bytes.checked_add(oauth_refresh.map_or(0, |refresh| refresh.response_bytes()))
2276        })
2277        .and_then(|bytes| {
2278            bytes.checked_add(oauth_refresh_health.map_or(0, |health| health.response_bytes()))
2279        });
2280    let login_request_count = audit.login().map_or(0_u8, |login| {
2281        u8::from(login.page_dispatched()).saturating_add(u8::from(login.submit_dispatched()))
2282    });
2283    let cookie_contract_is_valid = match (
2284        audit.schema(),
2285        audit.credential_mechanism(),
2286        audit.cookie_policy(),
2287        audit.cookie_lifecycle(),
2288    ) {
2289        (
2290            SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA,
2291            SuppliedSessionCredentialMechanism::AuthorizationHeader,
2292            None,
2293            None,
2294        ) => {
2295            audit.credential_acquisition()
2296                == SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce
2297                && audit.login().is_none()
2298                && renewal.is_none()
2299                && oauth_code.is_none()
2300                && oauth_refresh.is_none()
2301                && (1..=3).contains(&selected_resource_count)
2302                && audit.checkpoints().len() <= selected_resource_count + 1
2303                && dispatched_request_count <= 2 + 2 * selected_resource_count
2304                && authorization.is_some_and(|a| {
2305                    dispatched_request_count + usize::from(a.reservation_count()) <= 9
2306                })
2307        },
2308        (
2309            SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA | SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA,
2310            SuppliedSessionCredentialMechanism::AuthorizationHeader,
2311            None,
2312            None,
2313        ) => {
2314            audit.credential_acquisition()
2315                == SuppliedSessionCredentialAcquisition::ImportedOAuthCodePkce
2316                && audit.login().is_none()
2317                && renewal.is_none()
2318                && if oauth_refresh_schema {
2319                    selected_resource_count == 2
2320                } else {
2321                    (1..=3).contains(&selected_resource_count)
2322                }
2323                && oauth_code.is_some_and(|code| {
2324                    code.is_consistent()
2325                        && code.configured_token_response_byte_limit()
2326                            <= audit.response_byte_limit()
2327                        && audit.checkpoints().len() <= selected_resource_count + 1
2328                        && if code.credential_commit_count() == 1 {
2329                            (oauth_refresh_schema
2330                                && audit.outcome()
2331                                    == SuppliedSessionAuditOutcome::OAuthRefreshUnavailable)
2332                                || matches!(
2333                                    audit.outcome(),
2334                                    SuppliedSessionAuditOutcome::Complete
2335                                        | SuppliedSessionAuditOutcome::StartupUnhealthy
2336                                        | SuppliedSessionAuditOutcome::SessionLost
2337                                        | SuppliedSessionAuditOutcome::ResourceUnavailable
2338                                        | SuppliedSessionAuditOutcome::RuntimeLimit
2339                                        | SuppliedSessionAuditOutcome::Cancelled
2340                                )
2341                        } else {
2342                            audit.checkpoints().is_empty()
2343                            && audit.resources().iter().all(|resource| {
2344                                resource.outcome() == SuppliedSessionResourceOutcome::NotDispatched
2345                            })
2346                            && audit.coverage() == super::SuppliedSessionCoverage::None
2347                            && match code.outcome() {
2348                                SuppliedSessionOAuthCodeOutcome::Cancelled => {
2349                                    audit.outcome() == SuppliedSessionAuditOutcome::Cancelled
2350                                },
2351                                SuppliedSessionOAuthCodeOutcome::RuntimeLimit => {
2352                                    audit.outcome() == SuppliedSessionAuditOutcome::RuntimeLimit
2353                                },
2354                                SuppliedSessionOAuthCodeOutcome::GrantUnavailable
2355                                | SuppliedSessionOAuthCodeOutcome::TransportFailed
2356                                | SuppliedSessionOAuthCodeOutcome::Throttled
2357                                | SuppliedSessionOAuthCodeOutcome::ResponseRejected
2358                                | SuppliedSessionOAuthCodeOutcome::CommitFailed => {
2359                                    audit.outcome()
2360                                        == SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable
2361                                },
2362                                _ => false,
2363                            }
2364                        }
2365                })
2366        },
2367        (
2368            SUPPLIED_SESSION_RENEWAL_AUDIT_SCHEMA,
2369            SuppliedSessionCredentialMechanism::CookieJar,
2370            Some(policy),
2371            Some(lifecycle),
2372        ) => {
2373            let declared = policy.declared_count();
2374            let renewal = renewal.expect("V4 presence was checked");
2375            let boundary = usize::from(renewal.after_resource_count());
2376            audit.credential_acquisition()
2377                == SuppliedSessionCredentialAcquisition::SuppliedCookieJar
2378                && audit.login().is_none()
2379                && (2..=3).contains(&selected_resource_count)
2380                && boundary > 0
2381                && boundary < selected_resource_count
2382                && (1..=MAX_SUPPLIED_SESSION_COOKIES).contains(&usize::from(declared))
2383                && policy.host_only_count() == declared
2384                && policy.domain_count() == 0
2385                && (policy.secure_count() == 0 || policy.secure_count() == declared)
2386                && policy.http_only_count() == declared
2387                && policy.session_count() == declared
2388                && policy.persistent_count() == 0
2389                && policy.same_site_missing_count() == 0
2390                && policy.same_site_none_count() == 0
2391                && policy
2392                    .same_site_strict_count()
2393                    .checked_add(policy.same_site_lax_count())
2394                    == Some(declared)
2395                && policy.update_policy().as_str() == "stop_on_selected_cookie"
2396                && policy.browser_semantics() == "attributes_preserved_not_browser_csrf_emulation"
2397                && renewal.initial_epoch() == 1
2398                && renewal.final_epoch() == if renewal_applied { 2 } else { 1 }
2399                && lifecycle.initial_epoch() == 1
2400                && lifecycle.final_epoch() == renewal.final_epoch()
2401                && lifecycle.updates_applied() == u8::from(renewal_applied)
2402                && renewal.attempt_count() <= 1
2403                && valid_supplied_session_reference(
2404                    renewal.renewal_reference(),
2405                    "supplied-session-resource-sha256:",
2406                )
2407                && audit
2408                    .resources()
2409                    .iter()
2410                    .all(|resource| resource.resource_reference() != renewal.renewal_reference())
2411                && (!renewal_applied
2412                    || (renewal.attempt_count() == 1
2413                        && renewal.status() == Some(200)
2414                        && renewal.body_state() == SuppliedSessionBodyState::Complete
2415                        && renewal.applied_cookie_count() == declared))
2416                && (!renewal_applied || committed_resource_count >= boundary)
2417                && (renewal_applied || renewal_health.is_none())
2418                && renewal_health.is_none_or(|health| {
2419                    health.phase() == SuppliedSessionHealthCheckpointPhase::Renewal
2420                        && usize::from(health.after_subject_count()) == boundary
2421                        && health.epoch() == 2
2422                        && usize::from(health.sequence()) == boundary + 1
2423                        && (health.outcome() == SuppliedSessionHealthOutcome::Indeterminate
2424                            || health.evidence_reference().is_some())
2425                })
2426                && (committed_resource_count <= boundary
2427                    || renewal_health.is_some_and(|health| {
2428                        health.outcome() == SuppliedSessionHealthOutcome::Healthy
2429                    }))
2430                && valid_v2_cookie_lifecycle_accounting(
2431                    lifecycle.selected_update_response_count(),
2432                    lifecycle.unselected_update_response_count(),
2433                    lifecycle.update_classification_failure_count(),
2434                    audit.dispatched_request_count(),
2435                )
2436                && lifecycle.selected_update_response_count() >= u8::from(renewal_applied)
2437                && match audit.outcome() {
2438                    SuppliedSessionAuditOutcome::CredentialUpdateRequired => {
2439                        lifecycle.selected_update_response_count() == u8::from(renewal_applied) + 1
2440                            && lifecycle.update_classification_failure_count() == 0
2441                    },
2442                    SuppliedSessionAuditOutcome::CredentialUpdateUnusable => {
2443                        lifecycle.selected_update_response_count() == u8::from(renewal_applied)
2444                            && lifecycle.update_classification_failure_count() == 1
2445                    },
2446                    SuppliedSessionAuditOutcome::RenewalUnavailable => {
2447                        committed_resource_count == boundary && !renewal_applied
2448                    },
2449                    _ => {
2450                        lifecycle.selected_update_response_count() == u8::from(renewal_applied)
2451                            && lifecycle.update_classification_failure_count() == 0
2452                    },
2453                }
2454        },
2455        (
2456            SUPPLIED_SESSION_AUDIT_SCHEMA,
2457            SuppliedSessionCredentialMechanism::AuthorizationHeader,
2458            None,
2459            None,
2460        ) => {
2461            audit.credential_acquisition()
2462                == SuppliedSessionCredentialAcquisition::SuppliedAuthorization
2463                && audit.login().is_none()
2464                && valid_v1_supplied_session_outcome(audit.outcome())
2465        },
2466        (
2467            SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA,
2468            SuppliedSessionCredentialMechanism::CookieJar,
2469            Some(policy),
2470            Some(lifecycle),
2471        ) => {
2472            let declared = usize::from(policy.declared_count());
2473            audit.credential_acquisition()
2474                == SuppliedSessionCredentialAcquisition::SuppliedCookieJar
2475                && audit.login().is_none()
2476                && (1..=MAX_SUPPLIED_SESSION_COOKIES).contains(&declared)
2477                && usize::from(policy.host_only_count())
2478                    .checked_add(usize::from(policy.domain_count()))
2479                    == Some(declared)
2480                && usize::from(policy.secure_count()) <= declared
2481                && usize::from(policy.same_site_none_count()) <= usize::from(policy.secure_count())
2482                && usize::from(policy.http_only_count()) <= declared
2483                && usize::from(policy.session_count())
2484                    .checked_add(usize::from(policy.persistent_count()))
2485                    == Some(declared)
2486                && usize::from(policy.same_site_missing_count())
2487                    .checked_add(usize::from(policy.same_site_strict_count()))
2488                    .and_then(|count| count.checked_add(usize::from(policy.same_site_lax_count())))
2489                    .and_then(|count| count.checked_add(usize::from(policy.same_site_none_count())))
2490                    == Some(declared)
2491                && policy.update_policy().as_str() == "stop_on_selected_cookie"
2492                && policy.browser_semantics() == "attributes_preserved_not_browser_csrf_emulation"
2493                && lifecycle.initial_epoch() == 1
2494                && lifecycle.final_epoch() == 1
2495                && valid_v2_cookie_lifecycle_accounting(
2496                    lifecycle.selected_update_response_count(),
2497                    lifecycle.unselected_update_response_count(),
2498                    lifecycle.update_classification_failure_count(),
2499                    audit.dispatched_request_count(),
2500                )
2501                && lifecycle.updates_applied() == 0
2502                && valid_v2_cookie_lifecycle_outcome(
2503                    audit.outcome(),
2504                    lifecycle.selected_update_response_count(),
2505                    lifecycle.update_classification_failure_count(),
2506                )
2507        },
2508        (
2509            SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA,
2510            SuppliedSessionCredentialMechanism::CookieJar,
2511            Some(policy),
2512            Some(lifecycle),
2513        ) => {
2514            let declared = usize::from(policy.declared_count());
2515            audit.credential_acquisition() == SuppliedSessionCredentialAcquisition::BoundedFormLogin
2516                && selected_resource_count <= MAX_SUPPLIED_SESSION_RESOURCES.saturating_sub(1)
2517                && (1..=MAX_SUPPLIED_SESSION_COOKIES).contains(&declared)
2518                && usize::from(policy.host_only_count()) == declared
2519                && policy.domain_count() == 0
2520                && audit.login().is_some_and(|login| {
2521                    valid_v3_form_cookie_count_contract(
2522                        policy.declared_count(),
2523                        policy.secure_count(),
2524                        login.submit_outcome(),
2525                        login.acquired_cookie_count(),
2526                    )
2527                })
2528                && usize::from(policy.http_only_count()) == declared
2529                && usize::from(policy.session_count()) == declared
2530                && policy.persistent_count() == 0
2531                && policy.same_site_missing_count() == 0
2532                && policy.same_site_none_count() == 0
2533                && usize::from(policy.same_site_strict_count())
2534                    .checked_add(usize::from(policy.same_site_lax_count()))
2535                    == Some(declared)
2536                && policy.update_policy().as_str() == "stop_on_selected_cookie"
2537                && policy.browser_semantics() == "attributes_preserved_not_browser_csrf_emulation"
2538                && valid_v3_cookie_lifecycle_accounting(
2539                    lifecycle.selected_update_response_count(),
2540                    lifecycle.unselected_update_response_count(),
2541                    lifecycle.update_classification_failure_count(),
2542                    audit.dispatched_request_count(),
2543                    login_request_count,
2544                )
2545                && lifecycle.updates_applied() == 0
2546                && valid_v3_cookie_lifecycle_outcome(
2547                    audit.outcome(),
2548                    lifecycle.selected_update_response_count(),
2549                    lifecycle.update_classification_failure_count(),
2550                )
2551                && valid_v3_login_audit(audit, lifecycle.initial_epoch(), lifecycle.final_epoch())
2552        },
2553        _ => false,
2554    };
2555    if !cookie_contract_is_valid
2556        || audit.capability_id() != SUPPLIED_SESSION_CAPABILITY_ID
2557        || !valid_supplied_session_principal_alias(audit.principal_alias())
2558        || audit.resources().is_empty()
2559        || audit.resources().len() > MAX_SUPPLIED_SESSION_RESOURCES
2560        || audit.checkpoints().len()
2561            + usize::from(renewal_health.is_some())
2562            + usize::from(oauth_refresh_health.is_some())
2563            > MAX_SUPPLIED_SESSION_CHECKPOINTS
2564        || selected_resource_count != audit.resources().len()
2565        || dispatched_resource_count != actual_dispatched_resource_count
2566        || committed_resource_count != actual_committed_resource_count
2567        || committed_resource_count > dispatched_resource_count
2568        || dispatched_resource_count > selected_resource_count
2569        || dispatched_request_count > usize::from(MAX_SUPPLIED_SESSION_REQUESTS)
2570        || dispatched_request_count
2571            != audit
2572                .checkpoints()
2573                .len()
2574                .checked_add(dispatched_resource_count)
2575                .and_then(|count| count.checked_add(usize::from(login_request_count)))
2576                .and_then(|count| count.checked_add(renewal_request_count))
2577                .and_then(|count| count.checked_add(oauth_request_count))
2578                .and_then(|count| count.checked_add(oauth_refresh_requests))
2579                .ok_or(AssessmentRunReportError::SuppliedSessionAuditMismatch)?
2580        || child_response_bytes != Some(audit.response_bytes())
2581        || !valid_supplied_session_response_limit(
2582            audit.response_bytes(),
2583            audit.response_byte_limit(),
2584            audit.response_byte_limit_exceeded(),
2585        )
2586        || audit
2587            .checkpoints()
2588            .iter()
2589            .enumerate()
2590            .any(|(index, checkpoint)| {
2591                let expected_phase = if index == 0 {
2592                    SuppliedSessionHealthCheckpointPhase::Startup
2593                } else if index == selected_resource_count {
2594                    SuppliedSessionHealthCheckpointPhase::Terminal
2595                } else {
2596                    SuppliedSessionHealthCheckpointPhase::SubjectBoundary
2597                };
2598                usize::from(checkpoint.sequence()) != index
2599                    || usize::from(checkpoint.after_subject_count()) != index
2600                    || checkpoint.phase() != expected_phase
2601            })
2602        || audit.checkpoints().iter().any(|checkpoint| {
2603            let evidence = checkpoint.evidence_reference();
2604            let evidence_is_valid = evidence.is_some_and(|reference| {
2605                valid_supplied_session_reference(
2606                    reference,
2607                    "supplied-session-checkpoint-evidence-sha256:",
2608                )
2609            });
2610            (evidence.is_some()
2611                || checkpoint.outcome() != SuppliedSessionHealthOutcome::Indeterminate)
2612                && !evidence_is_valid
2613        })
2614        || audit.resources().iter().any(|resource| {
2615            let evidence_is_valid = resource.evidence_reference().is_some_and(|reference| {
2616                valid_supplied_session_reference(
2617                    reference,
2618                    "supplied-session-resource-evidence-sha256:",
2619                )
2620            });
2621            supplied_session_resource_outcome_requires_evidence(resource.outcome())
2622                != evidence_is_valid
2623        })
2624        || audit
2625            .resources()
2626            .iter()
2627            .enumerate()
2628            .any(|(index, resource)| {
2629                usize::from(resource.sequence()) != index
2630                    || resource.epoch()
2631                        != if oauth_refresh_applied && resource.sequence() >= 1
2632                            || renewal_applied
2633                                && renewal.is_some_and(|renewal| {
2634                                    resource.sequence() >= renewal.after_resource_count()
2635                                })
2636                        {
2637                            2
2638                        } else {
2639                            1
2640                        }
2641                    || !valid_supplied_session_reference(
2642                        resource.resource_reference(),
2643                        "supplied-session-resource-sha256:",
2644                    )
2645            })
2646        || audit.refresh_performed() != (renewal_applied || oauth_refresh_applied)
2647        || audit.anonymous_fallback_performed()
2648        || audit.continuous_authentication_established()
2649        || (matches!(
2650            audit.outcome(),
2651            SuppliedSessionAuditOutcome::CredentialUpdateRequired
2652                | SuppliedSessionAuditOutcome::CredentialUpdateUnusable
2653        ) && audit.coverage() == super::SuppliedSessionCoverage::Complete)
2654        || audit.exploit_execution_performed()
2655        || audit.impact_validation_performed()
2656    {
2657        return Err(AssessmentRunReportError::SuppliedSessionAuditMismatch);
2658    }
2659    Ok(())
2660}
2661
2662#[cfg(feature = "supplied-session-review")]
2663const fn valid_supplied_session_response_limit(
2664    response_bytes: u64,
2665    response_byte_limit: u64,
2666    response_byte_limit_exceeded: bool,
2667) -> bool {
2668    response_byte_limit > 0
2669        && response_byte_limit <= MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES
2670        && response_byte_limit_exceeded == (response_bytes > response_byte_limit)
2671}
2672
2673#[cfg(feature = "supplied-session-review")]
2674const fn supplied_session_resource_outcome_requires_evidence(
2675    outcome: SuppliedSessionResourceOutcome,
2676) -> bool {
2677    matches!(
2678        outcome,
2679        SuppliedSessionResourceOutcome::Committed
2680            | SuppliedSessionResourceOutcome::HealthUnqualified
2681    )
2682}
2683
2684#[cfg(feature = "supplied-session-review")]
2685fn valid_supplied_session_reference(value: &str, prefix: &str) -> bool {
2686    value.len() == prefix.len() + 64
2687        && value.starts_with(prefix)
2688        && value[prefix.len()..]
2689            .bytes()
2690            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2691}
2692
2693#[cfg(feature = "supplied-session-review")]
2694fn valid_supplied_session_principal_alias(value: &str) -> bool {
2695    !value.is_empty()
2696        && value.len() <= 128
2697        && value.bytes().all(|byte| {
2698            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'@' | b'-')
2699        })
2700}
2701
2702#[cfg(feature = "authorization-review")]
2703fn validate_authorization_audit(
2704    audit: Option<&WebAssessmentAuthorizationAudit>,
2705    items: &[AssessmentItem],
2706) -> Result<(), AssessmentRunReportError> {
2707    let projected_count = items
2708        .iter()
2709        .filter(|item| item.capability_id() == RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID)
2710        .count();
2711    if projected_count > 1 {
2712        return Err(AssessmentRunReportError::AuthorizationAuditMismatch);
2713    }
2714    let Some(audit) = audit else {
2715        return if projected_count == 0 {
2716            Ok(())
2717        } else {
2718            Err(AssessmentRunReportError::AuthorizationAuditMismatch)
2719        };
2720    };
2721    let positive = audit.outcome() == AuthorizationReviewOutcome::StableCrossPrincipalEquivalence;
2722    if audit.selected_path_count() == 0
2723        || usize::from(audit.selected_path_count()) > HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS
2724        || usize::from(audit.ignored_path_count()) > HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS
2725        || usize::from(audit.request_count()) > MAX_AUTHORIZATION_REVIEW_REQUESTS
2726        || audit.item_projected() != (projected_count == 1)
2727        || positive != audit.item_projected()
2728        || (positive && usize::from(audit.request_count()) != MAX_AUTHORIZATION_REVIEW_REQUESTS)
2729        || (positive
2730            && (audit.primary_stable() != Some(true)
2731                || audit.peer_stable() != Some(true)
2732                || audit.cross_resources_equivalent() != Some(true)))
2733    {
2734        return Err(AssessmentRunReportError::AuthorizationAuditMismatch);
2735    }
2736    Ok(())
2737}
2738
2739#[cfg(feature = "websocket-review")]
2740fn validate_websocket_review_audit(
2741    audit: Option<&WebAssessmentWebSocketReviewAudit>,
2742    #[cfg(feature = "supplied-session-review")] supplied_session: Option<
2743        &WebAssessmentSuppliedSessionAudit,
2744    >,
2745    items: &[AssessmentItem],
2746    assessment_request_count: Option<u64>,
2747    assessment_request_body_bytes: Option<u64>,
2748    assessment_response_bytes: Option<u64>,
2749) -> Result<(), AssessmentRunReportError> {
2750    if items
2751        .iter()
2752        .any(|item| item.capability_id() == WEBSOCKET_REVIEW_CAPABILITY_ID)
2753    {
2754        return Err(AssessmentRunReportError::WebSocketReviewAuditMismatch);
2755    }
2756    let Some(audit) = audit else {
2757        return Ok(());
2758    };
2759    let limits = audit.limits();
2760    let mut message_references = BTreeSet::new();
2761    let messages_are_safe = audit.messages().iter().all(|message| {
2762        valid_websocket_review_reference(
2763            message.message_reference(),
2764            "websocket-message-revision-sha256:",
2765        ) && message_references.insert(message.message_reference())
2766            && valid_websocket_review_reference(
2767                message.expected_response_reference(),
2768                "websocket-expected-response-revision-sha256:",
2769            )
2770            && match message.status() {
2771                WebSocketReviewMessageStatus::NotSent
2772                | WebSocketReviewMessageStatus::SentNoResponse => {
2773                    message.inbound_length().is_none()
2774                        && message.observed_response_reference().is_none()
2775                },
2776                WebSocketReviewMessageStatus::ResponseMatched => {
2777                    message.inbound_length().is_some()
2778                        && message.observed_response_reference()
2779                            == Some(message.expected_response_reference())
2780                },
2781                WebSocketReviewMessageStatus::ResponseMismatched => {
2782                    message.inbound_length().is_some()
2783                        && message.observed_response_reference().is_none()
2784                },
2785            }
2786    });
2787    let claim_limits = audit
2788        .claim_limits()
2789        .iter()
2790        .map(|limit| limit.as_str())
2791        .collect::<Vec<_>>();
2792    #[cfg(feature = "supplied-session-review")]
2793    let claim_limits = {
2794        let mut claim_limits = claim_limits;
2795        if audit.schema() == WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA {
2796            claim_limits.push("continuous_authentication_not_established");
2797        }
2798        claim_limits
2799    };
2800    let context_is_valid = if audit.schema() == WEBSOCKET_REVIEW_AUDIT_SCHEMA {
2801        audit.context() == WEBSOCKET_REVIEW_AUTHENTICATION && {
2802            #[cfg(feature = "supplied-session-review")]
2803            {
2804                audit.supplied_session_context().is_none()
2805            }
2806            #[cfg(not(feature = "supplied-session-review"))]
2807            {
2808                true
2809            }
2810        }
2811    } else {
2812        #[cfg(feature = "supplied-session-review")]
2813        {
2814            audit.schema() == WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA
2815                && audit.context() == WEBSOCKET_SUPPLIED_SESSION_CONTEXT
2816                && validate_websocket_supplied_session_context(audit, supplied_session)
2817        }
2818        #[cfg(not(feature = "supplied-session-review"))]
2819        {
2820            false
2821        }
2822    };
2823    let expected_claim_limits: &[&str] = if audit.schema() == WEBSOCKET_REVIEW_AUDIT_SCHEMA {
2824        &[
2825            "browser_origin_security_not_established",
2826            "authentication_not_established",
2827            "authorization_not_established",
2828            "availability_not_established",
2829            "vulnerability_not_established",
2830            "exploitability_not_established",
2831            "impact_not_established",
2832            "source_authenticity_not_established",
2833        ]
2834    } else {
2835        &[
2836            "browser_origin_security_not_established",
2837            "authentication_not_established",
2838            "authorization_not_established",
2839            "availability_not_established",
2840            "vulnerability_not_established",
2841            "exploitability_not_established",
2842            "impact_not_established",
2843            "source_authenticity_not_established",
2844            "continuous_authentication_not_established",
2845        ]
2846    };
2847    let valid = context_is_valid
2848        && audit.capability_id() == WEBSOCKET_REVIEW_CAPABILITY_ID
2849        && audit.selected()
2850        && audit.transport() == WEBSOCKET_REVIEW_TRANSPORT
2851        && audit.http2_extended_connect() == WEBSOCKET_REVIEW_HTTP2_EXTENDED_CONNECT
2852        && audit.transport_byte_scope() == WEBSOCKET_REVIEW_TRANSPORT_BYTE_SCOPE
2853        && !audit.compression_enabled()
2854        && !audit.reconnect_enabled()
2855        && (!audit.subprotocol_negotiated() || audit.subprotocol_requested())
2856        && valid_websocket_review_reference(audit.policy_reference(), "websocket-policy-sha256:")
2857        && valid_websocket_review_reference(
2858            audit.endpoint_reference(),
2859            "websocket-endpoint-sha256:",
2860        )
2861        && limits.max_connections() == MAX_WEBSOCKET_REVIEW_CONNECTIONS
2862        && limits.max_outbound_application_bytes()
2863            == MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION
2864        && limits.max_inbound_application_bytes()
2865            == MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION
2866        && (1..=MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION)
2867            .contains(&limits.max_outbound_message_bytes())
2868        && (1..=MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION)
2869            .contains(&limits.max_inbound_message_bytes())
2870        && (1..=u64::try_from(MAX_WEBSOCKET_REVIEW_MESSAGES).unwrap_or(u64::MAX))
2871            .contains(&limits.max_messages())
2872        && (1..=u64::from(MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES))
2873            .contains(&limits.max_control_frames())
2874        && (1..=MAX_WEBSOCKET_REVIEW_WALL_TIME_MS).contains(&limits.max_wall_time_ms())
2875        && audit.request_attempt_count() <= MAX_WEBSOCKET_REVIEW_CONNECTIONS
2876        && audit.request_admitted_count() <= audit.request_attempt_count()
2877        && audit.connection_attempt_count() <= audit.request_admitted_count()
2878        && audit.handshake_completed_count() <= audit.connection_attempt_count()
2879        && audit.accounted_request_body_bytes() == 0
2880        && audit.accounted_transport_response_bytes() == audit.transport_read_bytes()
2881        && audit.transport_read_bytes() >= audit.inbound_application_bytes()
2882        && audit.transport_write_bytes() >= audit.outbound_application_bytes()
2883        && assessment_request_count
2884            .is_some_and(|count| u64::from(audit.request_admitted_count()) <= count)
2885        && assessment_request_body_bytes
2886            .is_some_and(|count| audit.accounted_request_body_bytes() <= count)
2887        && assessment_response_bytes
2888            .is_some_and(|count| audit.accounted_transport_response_bytes() <= count)
2889        && messages_are_safe
2890        && claim_limits == expected_claim_limits
2891        && audit.is_consistent();
2892    if valid {
2893        Ok(())
2894    } else {
2895        Err(AssessmentRunReportError::WebSocketReviewAuditMismatch)
2896    }
2897}
2898
2899#[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2900fn validate_websocket_supplied_session_context(
2901    audit: &WebAssessmentWebSocketReviewAudit,
2902    supplied_session: Option<&WebAssessmentSuppliedSessionAudit>,
2903) -> bool {
2904    let Some(context) = audit.supplied_session_context() else {
2905        return false;
2906    };
2907    let Some(session) = supplied_session else {
2908        return false;
2909    };
2910    if session.schema() != SUPPLIED_SESSION_AUDIT_SCHEMA
2911        || session.credential_mechanism() != SuppliedSessionCredentialMechanism::AuthorizationHeader
2912        || session.continuous_authentication_established()
2913        || context.policy_reference() != session.policy_reference()
2914        || context.application_reference() != session.application_reference()
2915        || context.principal_reference() != session.principal_reference()
2916        || context.principal_alias() != session.principal_alias()
2917        || context.principal_assurance() != "operator_declared"
2918        || context.credential_mechanism() != "authorization_header"
2919        || context.epoch() != 1
2920        || context.continuous_authentication_established()
2921    {
2922        return false;
2923    }
2924
2925    let startup = session
2926        .checkpoints()
2927        .iter()
2928        .find(|checkpoint| checkpoint.phase() == SuppliedSessionHealthCheckpointPhase::Startup);
2929    let terminal = session
2930        .checkpoints()
2931        .iter()
2932        .find(|checkpoint| checkpoint.phase() == SuppliedSessionHealthCheckpointPhase::Terminal);
2933    if session
2934        .checkpoints()
2935        .iter()
2936        .filter(|checkpoint| checkpoint.phase() == SuppliedSessionHealthCheckpointPhase::Startup)
2937        .count()
2938        > 1
2939        || session
2940            .checkpoints()
2941            .iter()
2942            .filter(|checkpoint| {
2943                checkpoint.phase() == SuppliedSessionHealthCheckpointPhase::Terminal
2944            })
2945            .count()
2946            > 1
2947    {
2948        return false;
2949    }
2950    let health_matches =
2951        |projected: Option<&WebSocketSuppliedSessionHealthAudit>,
2952         checkpoint: Option<&super::WebAssessmentSuppliedSessionCheckpointAudit>| {
2953            projected
2954                .zip(checkpoint)
2955                .is_none_or(|(projected, checkpoint)| {
2956                    projected.outcome()
2957                        == match checkpoint.outcome() {
2958                            SuppliedSessionHealthOutcome::Healthy => "healthy",
2959                            SuppliedSessionHealthOutcome::Unhealthy => "unhealthy",
2960                            SuppliedSessionHealthOutcome::Indeterminate => "indeterminate",
2961                        }
2962                        && projected.evidence_reference() == checkpoint.evidence_reference()
2963                })
2964                && projected.is_some() == checkpoint.is_some()
2965        };
2966    if !health_matches(context.startup_health(), startup)
2967        || !health_matches(context.terminal_health(), terminal)
2968    {
2969        return false;
2970    }
2971
2972    let startup_healthy = context.startup_health().is_some_and(|health| {
2973        health.outcome() == "healthy" && health.evidence_reference().is_some()
2974    });
2975    let terminal_healthy = context.terminal_health().is_some_and(|health| {
2976        health.outcome() == "healthy" && health.evidence_reference().is_some()
2977    });
2978    match context.qualification() {
2979        WebSocketSuppliedSessionQualification::ContextQualified => {
2980            audit.request_admitted_count() == 1 && startup_healthy && terminal_healthy
2981        },
2982        WebSocketSuppliedSessionQualification::ContextUnqualified => {
2983            audit.request_admitted_count() == 1 && startup_healthy && !terminal_healthy
2984        },
2985        WebSocketSuppliedSessionQualification::NotEstablished => {
2986            audit.request_admitted_count() == 0
2987        },
2988    }
2989}
2990
2991#[cfg(feature = "websocket-review")]
2992fn valid_websocket_review_reference(value: &str, prefix: &str) -> bool {
2993    value.len() == prefix.len() + 64
2994        && value.starts_with(prefix)
2995        && value[prefix.len()..]
2996            .bytes()
2997            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2998}
2999
3000/// Invalid relationship in a typed assessment-run envelope.
3001///
3002/// Error variants retain only fixed classifications and bounded collection
3003/// counts. They never copy a target, subject, fingerprint, evidence identity,
3004/// credential, header, or diagnostic body.
3005#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
3006#[non_exhaustive]
3007pub enum AssessmentRunReportError {
3008    /// Runtime-owned truth could not be represented by the canonical generic
3009    /// run envelope. The underlying error is intentionally not retained.
3010    #[error("runtime assessment truth could not produce a canonical run envelope")]
3011    RunEnvelopeInvalid,
3012    /// The item collection exceeded its compiled retention ceiling.
3013    #[error("assessment item count {actual} exceeds the limit of {limit}")]
3014    TooManyItems {
3015        /// Supplied collection length.
3016        actual: usize,
3017        /// Compiled collection ceiling.
3018        limit: usize,
3019    },
3020    /// Two items declared the same stable fingerprint.
3021    #[error("assessment item fingerprints must be unique")]
3022    DuplicateFingerprint,
3023    /// A single-resource baseline profile cannot label an origin assessment.
3024    #[error("the baseline scan profile cannot label an origin assessment")]
3025    BaselineItemsForbidden,
3026    /// The origin assessment retained one or more typed incomplete reasons.
3027    #[error("only a complete origin assessment can produce this report")]
3028    AssessmentIncomplete,
3029    /// The selected profile limits did not govern the origin assessment.
3030    #[error("the scan profile does not match the assessment runtime authority")]
3031    ProfileAuthorityMismatch,
3032    /// The selected defense mode did not match the governing profile.
3033    #[error("the scan profile does not match the assessment defense mode")]
3034    ProfileDefenseMismatch,
3035    /// Runtime usage was inconsistent with a completed bounded assessment.
3036    #[error("assessment usage is inconsistent with its runtime authority")]
3037    AssessmentUsageMismatch,
3038    /// The run envelope did not represent successful exhaustive completion.
3039    #[error("assessment run status must be complete with a completed stop reason")]
3040    RunNotComplete,
3041    /// The run envelope carried accounting other than the runtime's exact
3042    /// metered limits and usage.
3043    #[error("assessment run accounting does not match runtime usage")]
3044    RunAccountingMismatch,
3045    /// The run timestamps did not encode the runtime's exact elapsed duration.
3046    #[error("assessment run duration does not match runtime usage")]
3047    RunDurationMismatch,
3048    /// The run did not contain exactly the canonical assessment step receipt.
3049    #[error("assessment run step inventory is not canonical")]
3050    RunStepMismatch,
3051    /// Generic run outcomes cannot be injected alongside the closed item set.
3052    #[error("assessment run outcomes must be projected only as assessment items")]
3053    RunOutcomesForbidden,
3054    /// The run identity was not one canonical exact HTTP(S) origin.
3055    #[error("assessment run identity must match the canonical authorized resource")]
3056    RunIdentityNotExactOrigin,
3057    /// The item set was minted for another exact-origin authority.
3058    #[error("assessment item scope does not match the run authority")]
3059    ScopeAuthorityMismatch,
3060    /// An item reference did not belong to the consumed subject inventory.
3061    #[error("assessment item subject reference is outside its projection inventory")]
3062    SubjectReferenceMismatch,
3063    /// The optional supplied-session audit disagreed with its audit-only runtime truth.
3064    #[cfg(feature = "supplied-session-review")]
3065    #[error("supplied-session review audit does not match runtime truth")]
3066    SuppliedSessionAuditMismatch,
3067    /// The optional authorization audit disagreed with projected item truth.
3068    #[cfg(feature = "authorization-review")]
3069    #[error("authorization review audit does not match projected item truth")]
3070    AuthorizationAuditMismatch,
3071    /// The optional WebSocket audit violated its audit-only runtime or parent-accounting contract.
3072    #[cfg(feature = "websocket-review")]
3073    #[error("WebSocket review audit does not match runtime accounting truth")]
3074    WebSocketReviewAuditMismatch,
3075    /// The optional OpenAPI audit disagreed with projected item truth.
3076    #[cfg(feature = "openapi-review")]
3077    #[error("OpenAPI review audit does not match projected item truth")]
3078    OpenApiAuditMismatch,
3079    /// The optional REST review audit disagreed with projected item truth.
3080    #[cfg(feature = "rest-review")]
3081    #[error("REST review audit does not match projected item truth")]
3082    RestAuditMismatch,
3083    /// The optional SSRF/OAST review audit disagreed with projected item truth.
3084    #[cfg(feature = "ssrf-oast-review")]
3085    #[error("SSRF OAST review audit does not match projected item truth")]
3086    SsrfOastAuditMismatch,
3087    /// The optional controlled XML review audit disagreed with projected item truth.
3088    #[cfg(feature = "xml-external-entity-review")]
3089    #[error("controlled XML review audit does not match projected item truth")]
3090    XmlExternalEntityAuditMismatch,
3091    /// The optional benign template-evaluation audit disagreed with projected item truth.
3092    #[cfg(feature = "template-evaluation-review")]
3093    #[error("template-evaluation review audit does not match projected item truth")]
3094    TemplateEvaluationReviewAuditMismatch,
3095    /// The optional WordPress audit disagreed with projected item truth.
3096    #[cfg(feature = "wordpress-review")]
3097    #[error("WordPress review audit does not match projected item truth")]
3098    WordPressAuditMismatch,
3099    /// The optional passive secret-exposure audit disagreed with projected item truth.
3100    #[cfg(feature = "secret-exposure-review")]
3101    #[error("secret-exposure review audit does not match committed item truth")]
3102    SecretExposureAuditMismatch,
3103    /// The optional passive TLS audit disagreed with its transport-owned truth.
3104    #[cfg(feature = "tls-observation")]
3105    #[error("TLS observation audit does not match transport truth")]
3106    TlsObservationAuditMismatch,
3107    /// The optional active TLS matrix disagreed with parent transport receipts.
3108    #[cfg(feature = "tls-negotiation-review")]
3109    #[error("TLS negotiation review audit does not match transport truth")]
3110    TlsNegotiationReviewAuditMismatch,
3111    /// The optional CT provider audit disagreed with runtime-owned request and byte truth.
3112    #[cfg(feature = "recon-ct-provider")]
3113    #[error("certificate-transparency provider audit does not match runtime truth")]
3114    ReconCtProviderAuditMismatch,
3115    /// The optional transport-free JWT audit violated its closed local contract.
3116    #[cfg(feature = "jwt-policy-review")]
3117    #[error("JWT policy review audit does not match its local evaluation contract")]
3118    JwtPolicyReviewAuditMismatch,
3119    /// The optional target-side JWT audit violated its closed six-leg contract.
3120    #[cfg(feature = "jwt-target-acceptance-review")]
3121    #[error("JWT target-acceptance audit does not match its runtime contract")]
3122    JwtTargetAcceptanceAuditMismatch,
3123    /// The optional offline control-reference mapping violated its closed bounds.
3124    #[cfg(feature = "control-reference-mapping")]
3125    #[error("control-reference mapping audit does not match completed assessment items")]
3126    ControlReferenceMappingAuditMismatch,
3127    /// The optional bounded local reconnaissance snapshot was attached more
3128    /// than once or otherwise violated the report composition contract.
3129    #[cfg(feature = "recon-snapshot-import")]
3130    #[error("reconnaissance snapshot audit does not match the report composition contract")]
3131    ReconSnapshotAuditMismatch,
3132}
3133
3134fn build_run_report(
3135    truth: &CompletedWebAssessmentTruth,
3136) -> Result<RunReport, AssessmentRunReportError> {
3137    let completed_at = truth
3138        .run_started_at
3139        .checked_add(Duration::from_millis(truth.expected_elapsed_ms))
3140        .ok_or(AssessmentRunReportError::RunEnvelopeInvalid)?;
3141    let stop_reason = RunStopReason::new(RunStopCode::Completed, WEB_ASSESSMENT_STOP_DETAIL)
3142        .map_err(|_| AssessmentRunReportError::RunEnvelopeInvalid)?;
3143    let step = RunStepReport::new(
3144        1,
3145        WEB_ASSESSMENT_RUN_STEP_ID,
3146        RunStepStatus::Succeeded,
3147        truth.expected_elapsed_ms,
3148        None,
3149    )
3150    .map_err(|_| AssessmentRunReportError::RunEnvelopeInvalid)?;
3151    let input = RunReportInput::new(
3152        RunStatus::Complete,
3153        stop_reason,
3154        truth.target.clone(),
3155        truth.authorized_origin.clone(),
3156        truth.run_started_at.into(),
3157        completed_at.into(),
3158    )
3159    .map_err(|_| AssessmentRunReportError::RunEnvelopeInvalid)?
3160    .with_accounting(truth.expected_accounting.clone())
3161    .with_steps(vec![step])
3162    .with_outcomes(Vec::new());
3163    RunReport::new(input).map_err(|_| AssessmentRunReportError::RunEnvelopeInvalid)
3164}
3165
3166fn validate_run_identity(
3167    run_report: &RunReport,
3168    expected_target_identity: [u8; 32],
3169) -> Result<(), AssessmentRunReportError> {
3170    let authorized_origin = run_report.authorized_origin();
3171    let Ok(target) = Url::parse(run_report.target()) else {
3172        return Err(AssessmentRunReportError::RunIdentityNotExactOrigin);
3173    };
3174    if !is_canonical_http_origin(authorized_origin)
3175        || !matches!(target.scheme(), "http" | "https")
3176        || !target.username().is_empty()
3177        || target.password().is_some()
3178        || target.host().is_none()
3179        || target.query().is_some()
3180        || target.fragment().is_some()
3181        || target.origin().ascii_serialization() != authorized_origin
3182        || target.as_str() != run_report.target()
3183        || assessment_target_identity(&target) != expected_target_identity
3184    {
3185        return Err(AssessmentRunReportError::RunIdentityNotExactOrigin);
3186    }
3187    Ok(())
3188}
3189
3190fn validate_run_completion(run_report: &RunReport) -> Result<(), AssessmentRunReportError> {
3191    if run_report.status() != RunStatus::Complete
3192        || run_report.stop_reason().code() != RunStopCode::Completed
3193    {
3194        return Err(AssessmentRunReportError::RunNotComplete);
3195    }
3196    if !run_report.outcomes().is_empty() {
3197        return Err(AssessmentRunReportError::RunOutcomesForbidden);
3198    }
3199    Ok(())
3200}
3201
3202fn validate_run_accounting(
3203    run_report: &RunReport,
3204    expected: &RunAccounting,
3205    expected_elapsed_ms: u64,
3206) -> Result<(), AssessmentRunReportError> {
3207    if run_report.accounting() != expected {
3208        return Err(AssessmentRunReportError::RunAccountingMismatch);
3209    }
3210
3211    let elapsed = run_report.completed_at() - run_report.started_at();
3212    if u64::try_from(elapsed.num_milliseconds()).ok() != Some(expected_elapsed_ms)
3213        || elapsed.subsec_nanos().rem_euclid(1_000_000) != 0
3214    {
3215        return Err(AssessmentRunReportError::RunDurationMismatch);
3216    }
3217
3218    let [step] = run_report.steps() else {
3219        return Err(AssessmentRunReportError::RunStepMismatch);
3220    };
3221    if step.ordinal() != 1
3222        || step.action_id() != WEB_ASSESSMENT_RUN_STEP_ID
3223        || step.status() != RunStepStatus::Succeeded
3224        || step.duration_ms() != expected_elapsed_ms
3225        || step.detail().is_some()
3226    {
3227        return Err(AssessmentRunReportError::RunStepMismatch);
3228    }
3229    Ok(())
3230}
3231
3232#[derive(Clone, Copy)]
3233struct AssessmentUsageTruth {
3234    retained_subjects: usize,
3235    executed_subjects: usize,
3236    retained_forms: usize,
3237    retained_unique_url_bytes: usize,
3238    total_requests: u32,
3239    active_verifications: u16,
3240    request_body_bytes: u64,
3241    response_bytes: u64,
3242    elapsed_ms: u64,
3243}
3244
3245impl From<WebAssessmentUsage> for AssessmentUsageTruth {
3246    fn from(usage: WebAssessmentUsage) -> Self {
3247        Self {
3248            retained_subjects: usage.retained_subjects(),
3249            executed_subjects: usage.executed_subjects(),
3250            retained_forms: usage.retained_forms(),
3251            retained_unique_url_bytes: usage.retained_unique_url_bytes(),
3252            total_requests: usage.total_requests(),
3253            active_verifications: usage.active_verifications(),
3254            request_body_bytes: usage.request_body_bytes(),
3255            response_bytes: usage.response_bytes(),
3256            elapsed_ms: usage.elapsed_ms(),
3257        }
3258    }
3259}
3260
3261#[cfg(test)]
3262fn validate_completed_assessment_truth(
3263    authorized_root: &WebAssessmentSubject,
3264    limits: WebAssessmentLimits,
3265    usage: AssessmentUsageTruth,
3266    completion: &WebAssessmentCompletion,
3267    defense_mode: WebAssessmentDefenseMode,
3268    profile: &ScanProfileV1,
3269) -> Result<(), AssessmentRunReportError> {
3270    validate_completed_assessment_truth_with_active_limit(
3271        authorized_root,
3272        AssessmentRuntimeLimits::new(limits, limits.max_active_verifications(), 0),
3273        usage,
3274        completion,
3275        defense_mode,
3276        profile,
3277    )
3278}
3279
3280fn validate_completed_assessment_truth_with_active_limit(
3281    authorized_root: &WebAssessmentSubject,
3282    runtime_limits: AssessmentRuntimeLimits,
3283    usage: AssessmentUsageTruth,
3284    completion: &WebAssessmentCompletion,
3285    defense_mode: WebAssessmentDefenseMode,
3286    profile: &ScanProfileV1,
3287) -> Result<(), AssessmentRunReportError> {
3288    let limits = runtime_limits.profile;
3289    let runtime_active_verification_limit = runtime_limits.active_verification_limit;
3290    let optional_active_verification_allowance =
3291        runtime_limits.optional_active_verification_allowance;
3292    if profile.profile() != BuiltInScanProfile::WebReview {
3293        return Err(AssessmentRunReportError::BaselineItemsForbidden);
3294    }
3295    if profile.web_assessment_limits() != limits {
3296        return Err(AssessmentRunReportError::ProfileAuthorityMismatch);
3297    }
3298    let expected_defense = if profile.defense_enforcement_enabled() {
3299        WebAssessmentDefenseMode::Enforced
3300    } else {
3301        WebAssessmentDefenseMode::ObservationOnly
3302    };
3303    if defense_mode != expected_defense {
3304        return Err(AssessmentRunReportError::ProfileDefenseMismatch);
3305    }
3306    if !matches!(completion, WebAssessmentCompletion::Complete) {
3307        return Err(AssessmentRunReportError::AssessmentIncomplete);
3308    }
3309
3310    let root = authorized_root.url();
3311    if authorized_root.origin() != WebAssessmentSubjectOrigin::AuthorizedRoot
3312        || authorized_root.depth() != 0
3313        || authorized_root.method() != WebAssessmentMethod::Get
3314        || !matches!(root.scheme(), "http" | "https")
3315        || root.host().is_none()
3316        || !root.username().is_empty()
3317        || root.password().is_some()
3318        || root.query().is_some()
3319        || root.fragment().is_some()
3320        || root.path().is_empty()
3321        || root.as_str().len() > limits.max_canonical_url_bytes()
3322    {
3323        return Err(AssessmentRunReportError::RunIdentityNotExactOrigin);
3324    }
3325
3326    let request_body_limit = RuntimeBudget::default().max_request_body_bytes();
3327    let compiled_optional_allowance = {
3328        let allowance = 0_u16;
3329        #[cfg(feature = "graphql-review")]
3330        let allowance = allowance.saturating_add(1);
3331        #[cfg(feature = "authorization-review")]
3332        let allowance = allowance.saturating_add(1);
3333        #[cfg(feature = "jwt-target-acceptance-review")]
3334        let allowance =
3335            allowance.saturating_add(u16::from(MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS));
3336        #[cfg(feature = "openapi-review")]
3337        let allowance = allowance.saturating_add(1);
3338        #[cfg(feature = "rest-review")]
3339        let allowance = allowance.saturating_add(1);
3340        #[cfg(feature = "template-evaluation-review")]
3341        let allowance = allowance.saturating_add(TEMPLATE_EVALUATION_ACTIVE_VERIFICATION_ALLOWANCE);
3342        #[cfg(feature = "xml-external-entity-review")]
3343        let allowance = allowance.saturating_add(
3344            u16::try_from(XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS)
3345                .expect("controlled XML active-verification allowance fits u16"),
3346        );
3347        #[cfg(feature = "tls-negotiation-review")]
3348        let allowance = allowance.saturating_add(TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE);
3349        allowance
3350    };
3351    let expected_active_limit = limits
3352        .max_active_verifications()
3353        .checked_add(optional_active_verification_allowance)
3354        .ok_or(AssessmentRunReportError::AssessmentUsageMismatch)?;
3355    if usage.retained_subjects == 0
3356        || usage.executed_subjects != usage.retained_subjects
3357        || usage.retained_subjects > limits.max_subjects()
3358        || usage.retained_forms > limits.max_forms()
3359        || usage.retained_unique_url_bytes < root.as_str().len()
3360        || usage.retained_unique_url_bytes > limits.max_retained_url_bytes()
3361        || usage.total_requests > limits.max_total_requests()
3362        || optional_active_verification_allowance > compiled_optional_allowance
3363        || runtime_active_verification_limit != expected_active_limit
3364        || usage.active_verifications > runtime_active_verification_limit
3365        || usage.request_body_bytes > request_body_limit
3366        || usage.response_bytes > limits.max_total_response_bytes()
3367        || usage.elapsed_ms > u64::try_from(limits.max_wall_time().as_millis()).unwrap_or(u64::MAX)
3368    {
3369        return Err(AssessmentRunReportError::AssessmentUsageMismatch);
3370    }
3371    Ok(())
3372}
3373
3374#[cfg(test)]
3375fn expected_run_accounting(
3376    limits: WebAssessmentLimits,
3377    usage: AssessmentUsageTruth,
3378) -> RunAccounting {
3379    expected_run_accounting_with_active_limit(limits, limits.max_active_verifications(), usage)
3380}
3381
3382fn expected_run_accounting_with_active_limit(
3383    limits: WebAssessmentLimits,
3384    runtime_active_verification_limit: u16,
3385    usage: AssessmentUsageTruth,
3386) -> RunAccounting {
3387    let budget = RuntimeBudget::default()
3388        .with_max_total_requests(limits.max_total_requests())
3389        .with_max_response_bytes(limits.max_total_response_bytes())
3390        .with_max_wall_time(limits.max_wall_time())
3391        .with_max_active_verifications(runtime_active_verification_limit);
3392    RunAccounting::new(
3393        ResourceAccounting::metered(
3394            u64::from(budget.max_total_requests()),
3395            u64::from(usage.total_requests),
3396        ),
3397        ResourceAccounting::metered(budget.max_response_bytes(), usage.response_bytes),
3398        ResourceAccounting::metered(budget.max_request_body_bytes(), usage.request_body_bytes),
3399        ResourceAccounting::metered(budget.max_wall_time_ms(), usage.elapsed_ms),
3400    )
3401}
3402
3403fn assessment_target_identity(target: &Url) -> [u8; 32] {
3404    let mut digest = Sha256::new();
3405    digest.update(ASSESSMENT_RUN_TARGET_DOMAIN);
3406    digest.update((target.as_str().len() as u64).to_be_bytes());
3407    digest.update(target.as_str().as_bytes());
3408    digest.finalize().into()
3409}
3410
3411fn is_canonical_http_origin(value: &str) -> bool {
3412    let Ok(url) = Url::parse(value) else {
3413        return false;
3414    };
3415    matches!(url.scheme(), "http" | "https")
3416        && url.username().is_empty()
3417        && url.password().is_none()
3418        && url.host().is_some()
3419        && url.path() == "/"
3420        && url.query().is_none()
3421        && url.fragment().is_none()
3422        && url.origin().ascii_serialization() == value
3423}
3424
3425fn validate_item_count(item_count: usize) -> Result<(), AssessmentRunReportError> {
3426    if item_count > MAX_ASSESSMENT_RUN_ITEMS {
3427        return Err(AssessmentRunReportError::TooManyItems {
3428            actual: item_count,
3429            limit: MAX_ASSESSMENT_RUN_ITEMS,
3430        });
3431    }
3432    Ok(())
3433}
3434
3435fn validate_profile_item_count(
3436    profile: BuiltInScanProfile,
3437    _item_count: usize,
3438) -> Result<(), AssessmentRunReportError> {
3439    if profile == BuiltInScanProfile::Baseline {
3440        return Err(AssessmentRunReportError::BaselineItemsForbidden);
3441    }
3442    Ok(())
3443}
3444
3445fn validate_subject_inventory(
3446    subjects: &[AssessmentSubjectInventoryEntry],
3447    items: &[AssessmentItem],
3448) -> Result<(), AssessmentRunReportError> {
3449    if subjects.is_empty() {
3450        return Err(AssessmentRunReportError::SubjectReferenceMismatch);
3451    }
3452    let mut fingerprints = BTreeSet::new();
3453    for (ordinal, subject) in subjects.iter().enumerate() {
3454        if subject.reference().ordinal() != u32::try_from(ordinal).unwrap_or(u32::MAX)
3455            || !fingerprints.insert(subject.fingerprint())
3456        {
3457            return Err(AssessmentRunReportError::SubjectReferenceMismatch);
3458        }
3459    }
3460    if items.iter().any(|item| {
3461        usize::try_from(item.subject_reference().ordinal())
3462            .map_or(true, |ordinal| ordinal >= subjects.len())
3463    }) {
3464        return Err(AssessmentRunReportError::SubjectReferenceMismatch);
3465    }
3466    Ok(())
3467}
3468
3469trait CanonicalFingerprint {
3470    fn canonical_fingerprint(&self) -> &str;
3471}
3472
3473impl CanonicalFingerprint for AssessmentItem {
3474    fn canonical_fingerprint(&self) -> &str {
3475        self.fingerprint()
3476    }
3477}
3478
3479fn validate_and_canonicalize_items<T>(
3480    profile: BuiltInScanProfile,
3481    items: &mut [T],
3482) -> Result<(), AssessmentRunReportError>
3483where
3484    T: CanonicalFingerprint,
3485{
3486    validate_item_count(items.len())?;
3487    validate_profile_item_count(profile, items.len())?;
3488    canonicalize_items(items)
3489}
3490
3491fn canonicalize_items<T>(items: &mut [T]) -> Result<(), AssessmentRunReportError>
3492where
3493    T: CanonicalFingerprint,
3494{
3495    items.sort_unstable_by(|left, right| {
3496        left.canonical_fingerprint()
3497            .cmp(right.canonical_fingerprint())
3498    });
3499    if items
3500        .windows(2)
3501        .any(|pair| pair[0].canonical_fingerprint() == pair[1].canonical_fingerprint())
3502    {
3503        return Err(AssessmentRunReportError::DuplicateFingerprint);
3504    }
3505    Ok(())
3506}
3507
3508#[cfg(test)]
3509mod tests {
3510    use std::collections::BTreeSet;
3511
3512    use termivar_core::{
3513        EntityId, RunOutcomeRecord, RunReportInput, RunStatus, RunStepReport, RunStepStatus,
3514        RunStopCode, RunStopReason,
3515    };
3516
3517    use super::super::assessment_item::{
3518        AssessmentProjectionContext, StableAssessmentScopeId, StableAssessmentSubjectId,
3519    };
3520    use super::super::{WebAssessmentIncompleteReason, WebAssessmentRuntime};
3521    use super::*;
3522    use crate::KnowledgeBase;
3523
3524    const PRIVATE_EXACT_ORIGIN: &str = "https://private-target-credential-sentinel.test";
3525    const PRIVATE_CANONICAL_TARGET: &str = "https://private-target-credential-sentinel.test/review";
3526    const PRIVATE_STOP_DETAIL: &str = "private-stop-diagnostic-sentinel";
3527    const TEST_ELAPSED_MS: u64 = 1_000;
3528
3529    #[cfg(feature = "jwt-target-acceptance-review")]
3530    #[test]
3531    fn jwt_target_audit_is_bound_to_the_matching_local_prerequisite_state() {
3532        use crate::{
3533            jwt_policy_review::{
3534                review_and_prepare_target_acceptance, Es256LocalPublicKey, JwtEvaluationTime,
3535                JwtLocalPolicy, SecretCompactJwt,
3536            },
3537            jwt_target_acceptance::{
3538                JwtTargetAcceptanceAudit, JwtTargetAcceptanceNotEligibleReason,
3539                JwtTargetAcceptancePolicy,
3540            },
3541        };
3542
3543        let key = Es256LocalPublicKey::from_jwk_json(
3544            br#"{"kty":"EC","crv":"P-256","alg":"ES256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}"#
3545                .to_vec(),
3546        )
3547        .unwrap();
3548        let policy = JwtLocalPolicy::new(
3549            ("synthetic-policy", "synthetic-policy-v1"),
3550            "JWT",
3551            "owned-issuer",
3552            "owned-api",
3553            &["scope"],
3554            true,
3555            0,
3556        )
3557        .unwrap();
3558        let prepared = review_and_prepare_target_acceptance(
3559            SecretCompactJwt::new(b"not-a.compact.jwt".to_vec()).unwrap(),
3560            &key,
3561            &policy,
3562            JwtEvaluationTime {
3563                unix_seconds: 1_900_000_000,
3564            },
3565        )
3566        .unwrap();
3567        let (rejected, runtime_input) = prepared.into_parts();
3568        let (_, reason, preparation_binding) = runtime_input.into_parts();
3569        assert_eq!(
3570            reason,
3571            Some(JwtTargetAcceptanceNotEligibleReason::LocalParsingNotEstablished)
3572        );
3573        let target_policy = JwtTargetAcceptancePolicy::new(
3574            ("synthetic-target-policy", "synthetic-target-v1"),
3575            Url::parse("https://owned.invalid/app/").unwrap(),
3576            Url::parse("https://owned.invalid/app/canary").unwrap(),
3577            "protected-canary",
3578            "accepted",
3579        )
3580        .unwrap();
3581
3582        let matching = JwtTargetAcceptanceAudit::not_eligible(
3583            &target_policy,
3584            JwtTargetAcceptanceNotEligibleReason::LocalParsingNotEstablished,
3585        )
3586        .bind_to_preparation(preparation_binding.clone());
3587        assert!(validate_jwt_target_local_link(&rejected, &matching).is_ok());
3588
3589        let independently_prepared = review_and_prepare_target_acceptance(
3590            SecretCompactJwt::new(b"not-a.compact.jwt".to_vec()).unwrap(),
3591            &key,
3592            &policy,
3593            JwtEvaluationTime {
3594                unix_seconds: 1_900_000_000,
3595            },
3596        )
3597        .unwrap();
3598        let (independent_rejected, _) = independently_prepared.into_parts();
3599        assert_eq!(
3600            validate_jwt_target_local_link(&independent_rejected, &matching),
3601            Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch),
3602            "identical local states from another preparation must not pair with this target audit"
3603        );
3604
3605        for reason in [
3606            JwtTargetAcceptanceNotEligibleReason::LocalPolicyNotEstablished,
3607            JwtTargetAcceptanceNotEligibleReason::LocalSignatureNotEstablished,
3608            JwtTargetAcceptanceNotEligibleReason::RuntimeAuthorityUnavailable,
3609            JwtTargetAcceptanceNotEligibleReason::RequestBudgetUnavailable,
3610        ] {
3611            let mismatched = JwtTargetAcceptanceAudit::not_eligible(&target_policy, reason)
3612                .bind_to_preparation(preparation_binding.clone());
3613            assert_eq!(
3614                validate_jwt_target_local_link(&rejected, &mismatched),
3615                Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch)
3616            );
3617        }
3618    }
3619
3620    #[cfg(feature = "jwt-target-acceptance-review")]
3621    #[test]
3622    fn jwt_target_transport_bytes_are_bounded_by_parent_response_accounting() {
3623        use crate::jwt_target_acceptance::{
3624            JwtTargetAcceptanceAudit, JwtTargetAcceptanceBinding, JwtTargetAcceptanceCommitStatus,
3625            JwtTargetAcceptanceDispatchStatus, JwtTargetAcceptanceLegFact,
3626            JwtTargetAcceptanceLegRole, JwtTargetAcceptanceMarkerStatus, JwtTargetAcceptancePolicy,
3627            JwtTargetAcceptanceResponseStatus,
3628        };
3629
3630        let policy = JwtTargetAcceptancePolicy::new(
3631            ("synthetic-target-policy", "synthetic-target-v1"),
3632            Url::parse("https://owned.invalid/app/").unwrap(),
3633            Url::parse("https://owned.invalid/app/canary").unwrap(),
3634            "protected-canary",
3635            "accepted",
3636        )
3637        .unwrap();
3638        let roles = JwtTargetAcceptanceLegRole::ORDERED;
3639        let mut legs = roles
3640            .into_iter()
3641            .map(JwtTargetAcceptanceLegFact::not_dispatched)
3642            .collect::<Vec<_>>();
3643        legs[0] = JwtTargetAcceptanceLegFact::new(
3644            roles[0],
3645            JwtTargetAcceptanceDispatchStatus::Dispatched,
3646            JwtTargetAcceptanceCommitStatus::NotCommitted,
3647            JwtTargetAcceptanceResponseStatus::Incomplete,
3648            JwtTargetAcceptanceMarkerStatus::NotEvaluated,
3649            5,
3650            None,
3651        )
3652        .unwrap();
3653        let audit = JwtTargetAcceptanceAudit::from_legs(&policy, legs)
3654            .unwrap()
3655            .bind_to_preparation(JwtTargetAcceptanceBinding::synthetic_for_test(0x3C));
3656
3657        assert!(validate_jwt_target_acceptance_audit(Some(&audit), Some(1), Some(5)).is_ok());
3658        assert_eq!(
3659            validate_jwt_target_acceptance_audit(Some(&audit), Some(1), Some(4)),
3660            Err(AssessmentRunReportError::JwtTargetAcceptanceAuditMismatch)
3661        );
3662        assert_eq!(
3663            validate_jwt_target_acceptance_audit(Some(&audit), Some(1), None),
3664            Err(AssessmentRunReportError::JwtTargetAcceptanceAuditMismatch)
3665        );
3666    }
3667
3668    #[cfg(feature = "tls-observation")]
3669    #[test]
3670    fn tls_observation_response_count_is_bounded_by_run_request_accounting() {
3671        let collector = super::super::tls_observation::TlsObservationCollector::new("https");
3672        let empty = collector.audit();
3673        assert!(
3674            validate_tls_observation_audit(Some(&empty), "https://example.test/", Some(0),).is_ok()
3675        );
3676
3677        collector.observe_https_response(None);
3678        let one_response = collector.audit();
3679        assert!(validate_tls_observation_audit(
3680            Some(&one_response),
3681            "https://example.test/",
3682            Some(1),
3683        )
3684        .is_ok());
3685        assert_eq!(
3686            validate_tls_observation_audit(Some(&one_response), "https://example.test/", Some(0),),
3687            Err(AssessmentRunReportError::TlsObservationAuditMismatch)
3688        );
3689        assert_eq!(
3690            validate_tls_observation_audit(Some(&one_response), "https://example.test/", None,),
3691            Err(AssessmentRunReportError::TlsObservationAuditMismatch)
3692        );
3693    }
3694
3695    #[cfg(feature = "tls-negotiation-review")]
3696    #[test]
3697    fn completed_report_rejects_an_incomplete_tls_negotiation_audit() {
3698        let audit = WebAssessmentTlsNegotiationReviewAudit::parent_authority_unavailable();
3699        assert!(audit.is_valid());
3700        assert!(!audit.network_matrix_complete());
3701        assert_eq!(
3702            validate_tls_negotiation_review_audit(
3703                Some(&audit),
3704                &TransportDispatchAudit::default(),
3705                Some(0),
3706                Some(0),
3707            ),
3708            Err(AssessmentRunReportError::TlsNegotiationReviewAuditMismatch)
3709        );
3710    }
3711
3712    fn usage_truth(target: &str) -> AssessmentUsageTruth {
3713        AssessmentUsageTruth {
3714            retained_subjects: 1,
3715            executed_subjects: 1,
3716            retained_forms: 0,
3717            retained_unique_url_bytes: target.len(),
3718            total_requests: 1,
3719            active_verifications: 0,
3720            request_body_bytes: 0,
3721            response_bytes: 128,
3722            elapsed_ms: TEST_ELAPSED_MS,
3723        }
3724    }
3725
3726    fn completed_truth(target: &str) -> CompletedWebAssessmentTruth {
3727        let target = Url::parse(target).unwrap();
3728        let limits = WebAssessmentLimits::default();
3729        let usage = usage_truth(target.as_str());
3730        CompletedWebAssessmentTruth {
3731            run_started_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000),
3732            authorized_origin: target.origin().ascii_serialization(),
3733            target_identity: assessment_target_identity(&target),
3734            target: target.to_string(),
3735            expected_accounting: expected_run_accounting(limits, usage),
3736            expected_elapsed_ms: usage.elapsed_ms,
3737            profile: ScanProfileV1::web_review().unwrap(),
3738        }
3739    }
3740
3741    fn complete_run_report(target: &str, authorized_origin: &str) -> RunReport {
3742        run_report(
3743            RunStatus::Complete,
3744            RunStopCode::Completed,
3745            RunStepStatus::Succeeded,
3746            target,
3747            authorized_origin,
3748            "2026-08-28T10:00:01Z",
3749            WEB_ASSESSMENT_RUN_STEP_ID,
3750            None,
3751            expected_run_accounting(WebAssessmentLimits::default(), usage_truth(target)),
3752            Vec::new(),
3753        )
3754    }
3755
3756    #[allow(clippy::too_many_arguments)]
3757    fn run_report(
3758        status: RunStatus,
3759        stop_code: RunStopCode,
3760        step_status: RunStepStatus,
3761        target: &str,
3762        authorized_origin: &str,
3763        completed_at: &str,
3764        step_action: &str,
3765        step_detail: Option<String>,
3766        accounting: RunAccounting,
3767        outcomes: Vec<RunOutcomeRecord>,
3768    ) -> RunReport {
3769        let input = RunReportInput::new(
3770            status,
3771            RunStopReason::new(stop_code, PRIVATE_STOP_DETAIL).unwrap(),
3772            target,
3773            authorized_origin,
3774            "2026-08-28T10:00:00Z".parse().unwrap(),
3775            completed_at.parse().unwrap(),
3776        )
3777        .unwrap()
3778        .with_accounting(accounting)
3779        .with_steps(vec![RunStepReport::new(
3780            1,
3781            step_action,
3782            step_status,
3783            TEST_ELAPSED_MS,
3784            step_detail,
3785        )
3786        .unwrap()])
3787        .with_outcomes(outcomes);
3788        RunReport::new(input).unwrap()
3789    }
3790
3791    fn root_item_set(exact_origin: &str) -> AssessmentItemSet {
3792        let knowledge = KnowledgeBase::new();
3793        let mut context = AssessmentProjectionContext::new(
3794            &knowledge,
3795            StableAssessmentScopeId::from_exact_origin(exact_origin).unwrap(),
3796        );
3797        context
3798            .register_subject(
3799                EntityId::new(format!("endpoint:{exact_origin}/")).unwrap(),
3800                StableAssessmentSubjectId::new("authorized-root@1").unwrap(),
3801                Vec::new(),
3802            )
3803            .unwrap();
3804        context.finish()
3805    }
3806
3807    #[derive(Debug, PartialEq, Eq)]
3808    struct TestItem(&'static str);
3809
3810    impl CanonicalFingerprint for TestItem {
3811        fn canonical_fingerprint(&self) -> &str {
3812            self.0
3813        }
3814    }
3815
3816    #[cfg(feature = "supplied-session-review")]
3817    #[test]
3818    fn supplied_session_report_preserves_overrun_accounting_and_qualification_state() {
3819        assert!(valid_supplied_session_response_limit(59, 65_536, false));
3820        assert!(valid_supplied_session_response_limit(65_537, 65_536, true));
3821        assert!(!valid_supplied_session_response_limit(
3822            65_537, 65_536, false
3823        ));
3824        assert!(!valid_supplied_session_response_limit(59, 0, true));
3825        assert!(!valid_supplied_session_response_limit(
3826            59,
3827            MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES + 1,
3828            false
3829        ));
3830
3831        assert!(supplied_session_resource_outcome_requires_evidence(
3832            SuppliedSessionResourceOutcome::Committed
3833        ));
3834        assert!(supplied_session_resource_outcome_requires_evidence(
3835            SuppliedSessionResourceOutcome::HealthUnqualified
3836        ));
3837        for outcome in [
3838            SuppliedSessionResourceOutcome::HttpError,
3839            SuppliedSessionResourceOutcome::RedirectRefused,
3840            SuppliedSessionResourceOutcome::Incomplete,
3841            SuppliedSessionResourceOutcome::TransportFailed,
3842            SuppliedSessionResourceOutcome::NotDispatched,
3843        ] {
3844            assert!(!supplied_session_resource_outcome_requires_evidence(
3845                outcome
3846            ));
3847        }
3848    }
3849
3850    #[cfg(feature = "supplied-session-review")]
3851    #[test]
3852    fn supplied_session_versioned_outcomes_reject_v1_mutations_and_keep_v2_stops() {
3853        for outcome in [
3854            SuppliedSessionAuditOutcome::CredentialUpdateRequired,
3855            SuppliedSessionAuditOutcome::CredentialUpdateUnusable,
3856            SuppliedSessionAuditOutcome::LoginFormUnavailable,
3857            SuppliedSessionAuditOutcome::LoginSubmitUnavailable,
3858            SuppliedSessionAuditOutcome::LoginCookieUnavailable,
3859        ] {
3860            assert!(!valid_v1_supplied_session_outcome(outcome));
3861        }
3862
3863        assert!(valid_v1_supplied_session_outcome(
3864            SuppliedSessionAuditOutcome::Complete
3865        ));
3866        assert!(valid_v2_cookie_lifecycle_outcome(
3867            SuppliedSessionAuditOutcome::CredentialUpdateRequired,
3868            1,
3869            0,
3870        ));
3871        assert!(valid_v2_cookie_lifecycle_outcome(
3872            SuppliedSessionAuditOutcome::CredentialUpdateUnusable,
3873            0,
3874            1,
3875        ));
3876        for outcome in [
3877            SuppliedSessionAuditOutcome::LoginFormUnavailable,
3878            SuppliedSessionAuditOutcome::LoginSubmitUnavailable,
3879            SuppliedSessionAuditOutcome::LoginCookieUnavailable,
3880        ] {
3881            assert!(!valid_v2_cookie_lifecycle_outcome(outcome, 0, 0));
3882        }
3883        assert!(valid_v3_cookie_lifecycle_outcome(
3884            SuppliedSessionAuditOutcome::LoginCookieUnavailable,
3885            0,
3886            0,
3887        ));
3888        assert!(!valid_v3_cookie_lifecycle_outcome(
3889            SuppliedSessionAuditOutcome::LoginCookieUnavailable,
3890            1,
3891            0,
3892        ));
3893        assert!(valid_v2_cookie_lifecycle_accounting(1, 1, 0, 1));
3894        assert!(!valid_v2_cookie_lifecycle_accounting(0, 1, 1, 1));
3895        assert!(valid_v3_cookie_lifecycle_accounting(0, 3, 0, 5, 2));
3896        assert!(!valid_v3_cookie_lifecycle_accounting(0, 4, 0, 5, 2));
3897        assert!(!valid_v3_cookie_lifecycle_accounting(0, 0, 0, 1, 2));
3898
3899        assert!(valid_v3_login_outcome_contract(
3900            SuppliedSessionAuditOutcome::RuntimeLimit,
3901            SuppliedSessionLoginFormOutcome::RuntimeLimit,
3902            SuppliedSessionLoginSubmitOutcome::NotDispatched,
3903        ));
3904        assert!(valid_v3_login_outcome_contract(
3905            SuppliedSessionAuditOutcome::Cancelled,
3906            SuppliedSessionLoginFormOutcome::Matched,
3907            SuppliedSessionLoginSubmitOutcome::Cancelled,
3908        ));
3909        assert!(valid_v3_login_outcome_contract(
3910            SuppliedSessionAuditOutcome::RuntimeLimit,
3911            SuppliedSessionLoginFormOutcome::Matched,
3912            SuppliedSessionLoginSubmitOutcome::CookieAcquired,
3913        ));
3914        for outcome in [
3915            SuppliedSessionAuditOutcome::RuntimeLimit,
3916            SuppliedSessionAuditOutcome::Cancelled,
3917        ] {
3918            assert!(!valid_v3_login_outcome_contract(
3919                outcome,
3920                SuppliedSessionLoginFormOutcome::Missing,
3921                SuppliedSessionLoginSubmitOutcome::NotDispatched,
3922            ));
3923        }
3924
3925        assert!(valid_v3_login_page_contract(
3926            SuppliedSessionLoginFormOutcome::Invalid,
3927            Some("invalid_descriptor"),
3928            false,
3929            None,
3930            SuppliedSessionBodyState::Unavailable,
3931        ));
3932        assert!(!valid_v3_login_page_contract(
3933            SuppliedSessionLoginFormOutcome::Invalid,
3934            Some("invalid_descriptor"),
3935            true,
3936            None,
3937            SuppliedSessionBodyState::Unavailable,
3938        ));
3939        assert!(valid_v3_login_page_contract(
3940            SuppliedSessionLoginFormOutcome::Missing,
3941            Some("missing_csrf"),
3942            true,
3943            Some(200),
3944            SuppliedSessionBodyState::Complete,
3945        ));
3946        assert!(!valid_v3_login_page_contract(
3947            SuppliedSessionLoginFormOutcome::Missing,
3948            Some("missing_csrf"),
3949            true,
3950            Some(404),
3951            SuppliedSessionBodyState::Unavailable,
3952        ));
3953        assert!(!valid_v3_login_page_contract(
3954            SuppliedSessionLoginFormOutcome::RuntimeLimit,
3955            Some("runtime_limit"),
3956            true,
3957            Some(200),
3958            SuppliedSessionBodyState::Complete,
3959        ));
3960        assert!(!valid_v3_login_page_contract(
3961            SuppliedSessionLoginFormOutcome::IneligibleResponse,
3962            Some("inexact_final_target"),
3963            true,
3964            Some(600),
3965            SuppliedSessionBodyState::Complete,
3966        ));
3967        assert!(valid_v3_login_submit_contract(
3968            SuppliedSessionLoginSubmitOutcome::RuntimeLimit,
3969            true,
3970            1,
3971            None,
3972            SuppliedSessionBodyState::Unavailable,
3973            0,
3974        ));
3975        assert!(!valid_v3_login_submit_contract(
3976            SuppliedSessionLoginSubmitOutcome::RuntimeLimit,
3977            true,
3978            1,
3979            Some(200),
3980            SuppliedSessionBodyState::Complete,
3981            0,
3982        ));
3983        assert!(!valid_v3_login_submit_contract(
3984            SuppliedSessionLoginSubmitOutcome::HttpError,
3985            true,
3986            1,
3987            Some(99),
3988            SuppliedSessionBodyState::Complete,
3989            0,
3990        ));
3991        assert!(valid_v3_form_cookie_count_contract(
3992            1,
3993            0,
3994            SuppliedSessionLoginSubmitOutcome::CookieAcquired,
3995            1,
3996        ));
3997        assert!(valid_v3_form_cookie_count_contract(
3998            2,
3999            2,
4000            SuppliedSessionLoginSubmitOutcome::CookieAcquired,
4001            2,
4002        ));
4003        assert!(!valid_v3_form_cookie_count_contract(
4004            2,
4005            1,
4006            SuppliedSessionLoginSubmitOutcome::CookieAcquired,
4007            2,
4008        ));
4009        assert!(!valid_v3_form_cookie_count_contract(
4010            2,
4011            2,
4012            SuppliedSessionLoginSubmitOutcome::CookieAcquired,
4013            1,
4014        ));
4015        assert!(valid_v3_login_dispatch_contract(
4016            SuppliedSessionLoginFormOutcome::Matched,
4017            true,
4018            true,
4019            32,
4020        ));
4021        assert!(!valid_v3_login_dispatch_contract(
4022            SuppliedSessionLoginFormOutcome::Missing,
4023            true,
4024            true,
4025            32,
4026        ));
4027        assert!(valid_v3_login_dispatch_contract(
4028            SuppliedSessionLoginFormOutcome::Invalid,
4029            false,
4030            false,
4031            0,
4032        ));
4033        assert!(!valid_v3_login_dispatch_contract(
4034            SuppliedSessionLoginFormOutcome::Invalid,
4035            false,
4036            false,
4037            1,
4038        ));
4039    }
4040
4041    #[test]
4042    fn empty_validated_reports_expose_only_read_only_components() {
4043        let report = AssessmentRunReport::new(
4044            complete_run_report(PRIVATE_CANONICAL_TARGET, PRIVATE_EXACT_ORIGIN),
4045            root_item_set(PRIVATE_EXACT_ORIGIN),
4046            completed_truth(PRIVATE_CANONICAL_TARGET),
4047        )
4048        .unwrap();
4049
4050        assert_eq!(report.schema(), ASSESSMENT_RUN_REPORT_SCHEMA);
4051        assert_eq!(report.run_report.target(), PRIVATE_CANONICAL_TARGET);
4052        assert_eq!(report.profile().profile(), BuiltInScanProfile::WebReview);
4053        assert!(report.items().is_empty());
4054        assert_eq!(report.subject_count(), 1);
4055        assert_eq!(report.item_count(), 0);
4056    }
4057
4058    #[cfg(feature = "recon-snapshot-import")]
4059    #[test]
4060    fn recon_snapshot_attachment_preserves_assessment_truth_and_is_single_use() {
4061        let bytes = br#"{
4062            "schema":"security.recon-snapshot/v1",
4063            "snapshot_id":"synthetic-empty-snapshot",
4064            "snapshot_revision":"revision-1",
4065            "sources":[{
4066                "source_id":"synthetic-source",
4067                "namespace":"owned.fixture",
4068                "revision":"source-r1",
4069                "provider_time":null,
4070                "observed_time":"2026-09-20T10:30:00Z",
4071                "collection_method":"owned-offline-fixture",
4072                "completeness":"unknown",
4073                "origin":"owned fixture",
4074                "rights":{"status":"permitted_as_declared","attribution":null,"notice":null}
4075            }],
4076            "records":[]
4077        }"#;
4078        let first = crate::recon_snapshot::parse_recon_snapshot(bytes).unwrap();
4079        let duplicate = crate::recon_snapshot::parse_recon_snapshot(bytes).unwrap();
4080        let report = AssessmentRunReport::new(
4081            complete_run_report(PRIVATE_CANONICAL_TARGET, PRIVATE_EXACT_ORIGIN),
4082            root_item_set(PRIVATE_EXACT_ORIGIN),
4083            completed_truth(PRIVATE_CANONICAL_TARGET),
4084        )
4085        .unwrap();
4086        let run_target = report.run_report().target().to_owned();
4087        let subject_count = report.subject_count();
4088        let item_count = report.item_count();
4089
4090        let report = report.with_recon_snapshot(first).unwrap();
4091        assert_eq!(report.run_report().target(), run_target);
4092        assert_eq!(report.subject_count(), subject_count);
4093        assert_eq!(report.item_count(), item_count);
4094        assert_eq!(
4095            report
4096                .recon_snapshot_audit()
4097                .map(|audit| audit.record_count()),
4098            Some(0)
4099        );
4100        assert_eq!(
4101            report.with_recon_snapshot(duplicate).unwrap_err(),
4102            AssessmentRunReportError::ReconSnapshotAuditMismatch
4103        );
4104    }
4105
4106    #[test]
4107    fn run_identity_must_match_the_exact_canonical_assessment_root() {
4108        for (target, authorized_origin) in [
4109            (
4110                "https://example.test/private-path-credential-sentinel",
4111                "https://example.test",
4112            ),
4113            (
4114                "https://target-identity-sentinel.test/",
4115                "https://authority-identity-sentinel.test",
4116            ),
4117            (
4118                "https://user:credential-sentinel@example.test/",
4119                "https://example.test",
4120            ),
4121            (
4122                "https://example.test/?credential-sentinel=value",
4123                "https://example.test",
4124            ),
4125            ("HTTPS://EXAMPLE.TEST/", "https://example.test"),
4126            ("https://example.test/", "https://example.test/"),
4127            ("ftp://example.test/", "ftp://example.test"),
4128        ] {
4129            let error = AssessmentRunReport::new(
4130                complete_run_report(target, authorized_origin),
4131                root_item_set("https://example.test"),
4132                completed_truth("https://example.test/"),
4133            )
4134            .unwrap_err();
4135            assert_eq!(error, AssessmentRunReportError::RunIdentityNotExactOrigin);
4136            let display = error.to_string();
4137            assert!(!display.contains(target));
4138            assert!(!display.contains(authorized_origin));
4139            assert!(!display.contains("credential-sentinel"));
4140        }
4141
4142        for (target, exact_origin) in [
4143            ("http://example.test/", "http://example.test"),
4144            ("https://example.test/", "https://example.test"),
4145            (
4146                "https://example.test:8443/review",
4147                "https://example.test:8443",
4148            ),
4149            ("http://127.0.0.1:8080/path", "http://127.0.0.1:8080"),
4150            ("http://[::1]:8080/root", "http://[::1]:8080"),
4151        ] {
4152            let report = AssessmentRunReport::new(
4153                complete_run_report(target, exact_origin),
4154                root_item_set(exact_origin),
4155                completed_truth(target),
4156            )
4157            .unwrap();
4158            assert_eq!(report.run_report.target(), target);
4159            assert_eq!(report.run_report.authorized_origin(), exact_origin);
4160        }
4161    }
4162
4163    #[test]
4164    fn item_scope_must_match_the_run_exact_origin() {
4165        let error = AssessmentRunReport::new(
4166            complete_run_report("https://example.test/path", "https://example.test"),
4167            root_item_set("https://other.test"),
4168            completed_truth("https://example.test/path"),
4169        )
4170        .unwrap_err();
4171
4172        assert_eq!(error, AssessmentRunReportError::ScopeAuthorityMismatch);
4173        assert!(!error.to_string().contains("example.test"));
4174        assert!(!error.to_string().contains("other.test"));
4175    }
4176
4177    #[test]
4178    fn item_limit_accepts_the_boundary_and_rejects_one_more() {
4179        assert_eq!(validate_item_count(MAX_ASSESSMENT_RUN_ITEMS), Ok(()));
4180        assert_eq!(
4181            validate_item_count(MAX_ASSESSMENT_RUN_ITEMS + 1),
4182            Err(AssessmentRunReportError::TooManyItems {
4183                actual: MAX_ASSESSMENT_RUN_ITEMS + 1,
4184                limit: MAX_ASSESSMENT_RUN_ITEMS,
4185            })
4186        );
4187    }
4188
4189    #[test]
4190    fn baseline_can_never_label_an_origin_assessment_even_when_items_are_empty() {
4191        assert_eq!(
4192            validate_profile_item_count(BuiltInScanProfile::Baseline, 0),
4193            Err(AssessmentRunReportError::BaselineItemsForbidden)
4194        );
4195        assert_eq!(
4196            validate_profile_item_count(BuiltInScanProfile::Baseline, 1),
4197            Err(AssessmentRunReportError::BaselineItemsForbidden)
4198        );
4199        assert_eq!(
4200            validate_profile_item_count(BuiltInScanProfile::WebReview, 1),
4201            Ok(())
4202        );
4203
4204        let mut item = vec![TestItem("sha256:baseline-forbidden")];
4205        assert_eq!(
4206            validate_and_canonicalize_items(BuiltInScanProfile::Baseline, &mut item),
4207            Err(AssessmentRunReportError::BaselineItemsForbidden)
4208        );
4209    }
4210
4211    #[test]
4212    fn truth_bridge_rejects_incomplete_profile_limit_defense_and_usage_mislabeling() {
4213        let runtime =
4214            WebAssessmentRuntime::builder(Url::parse("https://example.test/review").unwrap())
4215                .build()
4216                .unwrap();
4217        let root = runtime.authorized_root();
4218        let limits = WebAssessmentLimits::default();
4219        let usage = usage_truth(root.url().as_str());
4220        let web_review = ScanProfileV1::web_review().unwrap();
4221
4222        assert_eq!(
4223            validate_completed_assessment_truth(
4224                root,
4225                limits,
4226                usage,
4227                &WebAssessmentCompletion::Complete,
4228                WebAssessmentDefenseMode::ObservationOnly,
4229                &web_review,
4230            ),
4231            Ok(())
4232        );
4233
4234        let incomplete = WebAssessmentCompletion::Incomplete {
4235            reasons: BTreeSet::from([WebAssessmentIncompleteReason::HostCancellation]),
4236        };
4237        assert_eq!(
4238            validate_completed_assessment_truth(
4239                root,
4240                limits,
4241                usage,
4242                &incomplete,
4243                WebAssessmentDefenseMode::ObservationOnly,
4244                &web_review,
4245            ),
4246            Err(AssessmentRunReportError::AssessmentIncomplete)
4247        );
4248        assert_eq!(
4249            validate_completed_assessment_truth(
4250                root,
4251                limits,
4252                usage,
4253                &WebAssessmentCompletion::Complete,
4254                WebAssessmentDefenseMode::ObservationOnly,
4255                &ScanProfileV1::baseline().unwrap(),
4256            ),
4257            Err(AssessmentRunReportError::BaselineItemsForbidden)
4258        );
4259
4260        let narrower = limits.with_max_subjects(2).unwrap();
4261        assert_eq!(
4262            validate_completed_assessment_truth(
4263                root,
4264                narrower,
4265                usage,
4266                &WebAssessmentCompletion::Complete,
4267                WebAssessmentDefenseMode::ObservationOnly,
4268                &web_review,
4269            ),
4270            Err(AssessmentRunReportError::ProfileAuthorityMismatch)
4271        );
4272        assert_eq!(
4273            validate_completed_assessment_truth(
4274                root,
4275                limits,
4276                usage,
4277                &WebAssessmentCompletion::Complete,
4278                WebAssessmentDefenseMode::Enforced,
4279                &web_review,
4280            ),
4281            Err(AssessmentRunReportError::ProfileDefenseMismatch)
4282        );
4283
4284        let excessive_usage = AssessmentUsageTruth {
4285            total_requests: limits.max_total_requests().saturating_add(1),
4286            ..usage
4287        };
4288        assert_eq!(
4289            validate_completed_assessment_truth(
4290                root,
4291                limits,
4292                excessive_usage,
4293                &WebAssessmentCompletion::Complete,
4294                WebAssessmentDefenseMode::ObservationOnly,
4295                &web_review,
4296            ),
4297            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4298        );
4299    }
4300
4301    #[cfg(all(
4302        feature = "graphql-review",
4303        feature = "authorization-review",
4304        feature = "jwt-target-acceptance-review",
4305        feature = "openapi-review",
4306        feature = "rest-review"
4307    ))]
4308    #[test]
4309    fn independently_enabled_optional_children_have_an_exact_additive_active_allowance() {
4310        let runtime =
4311            WebAssessmentRuntime::builder(Url::parse("https://example.test/review").unwrap())
4312                .build()
4313                .unwrap();
4314        let root = runtime.authorized_root();
4315        let limits = WebAssessmentLimits::default();
4316        let compiled_allowance = 6_u16;
4317        #[cfg(feature = "template-evaluation-review")]
4318        let compiled_allowance = compiled_allowance
4319            .checked_add(TEMPLATE_EVALUATION_ACTIVE_VERIFICATION_ALLOWANCE)
4320            .unwrap();
4321        #[cfg(feature = "xml-external-entity-review")]
4322        let compiled_allowance = compiled_allowance
4323            .checked_add(u16::try_from(XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS).unwrap())
4324            .unwrap();
4325        #[cfg(feature = "tls-negotiation-review")]
4326        let compiled_allowance = compiled_allowance
4327            .checked_add(TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE)
4328            .unwrap();
4329        let expected = limits
4330            .max_active_verifications()
4331            .checked_add(compiled_allowance)
4332            .unwrap();
4333        let usage = AssessmentUsageTruth {
4334            active_verifications: compiled_allowance,
4335            ..usage_truth(root.url().as_str())
4336        };
4337        let profile = ScanProfileV1::web_review().unwrap();
4338
4339        assert_eq!(
4340            validate_completed_assessment_truth_with_active_limit(
4341                root,
4342                AssessmentRuntimeLimits::new(limits, expected, compiled_allowance),
4343                usage,
4344                &WebAssessmentCompletion::Complete,
4345                WebAssessmentDefenseMode::ObservationOnly,
4346                &profile,
4347            ),
4348            Ok(())
4349        );
4350        assert_eq!(
4351            validate_completed_assessment_truth_with_active_limit(
4352                root,
4353                AssessmentRuntimeLimits::new(limits, expected - 1, compiled_allowance),
4354                usage,
4355                &WebAssessmentCompletion::Complete,
4356                WebAssessmentDefenseMode::ObservationOnly,
4357                &profile,
4358            ),
4359            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4360        );
4361        assert_eq!(
4362            validate_completed_assessment_truth_with_active_limit(
4363                root,
4364                AssessmentRuntimeLimits::new(limits, expected + 1, compiled_allowance + 1),
4365                usage,
4366                &WebAssessmentCompletion::Complete,
4367                WebAssessmentDefenseMode::ObservationOnly,
4368                &profile,
4369            ),
4370            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4371        );
4372    }
4373
4374    #[cfg(feature = "tls-negotiation-review")]
4375    #[test]
4376    fn tls_negotiation_has_exact_two_active_handshake_allowance() {
4377        let runtime =
4378            WebAssessmentRuntime::builder(Url::parse("https://example.test/review").unwrap())
4379                .build()
4380                .unwrap();
4381        let root = runtime.authorized_root();
4382        let limits = WebAssessmentLimits::default();
4383        let allowance = TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE;
4384        let expected = limits
4385            .max_active_verifications()
4386            .checked_add(allowance)
4387            .unwrap();
4388        let usage = AssessmentUsageTruth {
4389            active_verifications: allowance,
4390            ..usage_truth(root.url().as_str())
4391        };
4392        let profile = ScanProfileV1::web_review().unwrap();
4393
4394        assert_eq!(
4395            validate_completed_assessment_truth_with_active_limit(
4396                root,
4397                AssessmentRuntimeLimits::new(limits, expected, allowance),
4398                usage,
4399                &WebAssessmentCompletion::Complete,
4400                WebAssessmentDefenseMode::ObservationOnly,
4401                &profile,
4402            ),
4403            Ok(())
4404        );
4405        assert_eq!(
4406            validate_completed_assessment_truth_with_active_limit(
4407                root,
4408                AssessmentRuntimeLimits::new(limits, expected - 1, allowance),
4409                usage,
4410                &WebAssessmentCompletion::Complete,
4411                WebAssessmentDefenseMode::ObservationOnly,
4412                &profile,
4413            ),
4414            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4415        );
4416        assert_eq!(
4417            validate_completed_assessment_truth_with_active_limit(
4418                root,
4419                AssessmentRuntimeLimits::new(limits, expected, allowance + 1),
4420                usage,
4421                &WebAssessmentCompletion::Complete,
4422                WebAssessmentDefenseMode::ObservationOnly,
4423                &profile,
4424            ),
4425            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4426        );
4427    }
4428
4429    #[cfg(feature = "template-evaluation-review")]
4430    #[test]
4431    fn template_evaluation_has_exact_two_active_control_allowance() {
4432        let runtime =
4433            WebAssessmentRuntime::builder(Url::parse("https://example.test/review").unwrap())
4434                .build()
4435                .unwrap();
4436        let root = runtime.authorized_root();
4437        let limits = WebAssessmentLimits::default();
4438        let allowance = TEMPLATE_EVALUATION_ACTIVE_VERIFICATION_ALLOWANCE;
4439        let expected = limits
4440            .max_active_verifications()
4441            .checked_add(allowance)
4442            .unwrap();
4443        let usage = AssessmentUsageTruth {
4444            active_verifications: allowance,
4445            ..usage_truth(root.url().as_str())
4446        };
4447        let profile = ScanProfileV1::web_review().unwrap();
4448
4449        assert_eq!(
4450            validate_completed_assessment_truth_with_active_limit(
4451                root,
4452                AssessmentRuntimeLimits::new(limits, expected, allowance),
4453                usage,
4454                &WebAssessmentCompletion::Complete,
4455                WebAssessmentDefenseMode::ObservationOnly,
4456                &profile,
4457            ),
4458            Ok(())
4459        );
4460        assert_eq!(
4461            validate_completed_assessment_truth_with_active_limit(
4462                root,
4463                AssessmentRuntimeLimits::new(limits, expected - 1, allowance),
4464                usage,
4465                &WebAssessmentCompletion::Complete,
4466                WebAssessmentDefenseMode::ObservationOnly,
4467                &profile,
4468            ),
4469            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4470        );
4471        assert_eq!(
4472            validate_completed_assessment_truth_with_active_limit(
4473                root,
4474                AssessmentRuntimeLimits::new(limits, expected, allowance + 1),
4475                usage,
4476                &WebAssessmentCompletion::Complete,
4477                WebAssessmentDefenseMode::ObservationOnly,
4478                &profile,
4479            ),
4480            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4481        );
4482    }
4483
4484    #[cfg(feature = "jwt-target-acceptance-review")]
4485    #[test]
4486    fn jwt_target_acceptance_has_exact_two_active_control_allowance() {
4487        let runtime =
4488            WebAssessmentRuntime::builder(Url::parse("https://example.test/review").unwrap())
4489                .build()
4490                .unwrap();
4491        let root = runtime.authorized_root();
4492        let limits = WebAssessmentLimits::default();
4493        let allowance = u16::from(MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS);
4494        let expected = limits
4495            .max_active_verifications()
4496            .checked_add(allowance)
4497            .unwrap();
4498        let usage = AssessmentUsageTruth {
4499            active_verifications: allowance,
4500            ..usage_truth(root.url().as_str())
4501        };
4502        let profile = ScanProfileV1::web_review().unwrap();
4503
4504        assert_eq!(
4505            validate_completed_assessment_truth_with_active_limit(
4506                root,
4507                AssessmentRuntimeLimits::new(limits, expected, allowance),
4508                usage,
4509                &WebAssessmentCompletion::Complete,
4510                WebAssessmentDefenseMode::ObservationOnly,
4511                &profile,
4512            ),
4513            Ok(())
4514        );
4515        assert_eq!(
4516            validate_completed_assessment_truth_with_active_limit(
4517                root,
4518                AssessmentRuntimeLimits::new(limits, expected - 1, allowance),
4519                usage,
4520                &WebAssessmentCompletion::Complete,
4521                WebAssessmentDefenseMode::ObservationOnly,
4522                &profile,
4523            ),
4524            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4525        );
4526    }
4527
4528    #[cfg(feature = "xml-external-entity-review")]
4529    #[test]
4530    fn xml_external_entity_has_exact_one_active_control_allowance() {
4531        let runtime =
4532            WebAssessmentRuntime::builder(Url::parse("https://example.test/review").unwrap())
4533                .build()
4534                .unwrap();
4535        let root = runtime.authorized_root();
4536        let limits = WebAssessmentLimits::default();
4537        let allowance = u16::try_from(XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS).unwrap();
4538        let expected = limits
4539            .max_active_verifications()
4540            .checked_add(allowance)
4541            .unwrap();
4542        let usage = AssessmentUsageTruth {
4543            active_verifications: allowance,
4544            ..usage_truth(root.url().as_str())
4545        };
4546        let profile = ScanProfileV1::web_review().unwrap();
4547
4548        assert_eq!(
4549            validate_completed_assessment_truth_with_active_limit(
4550                root,
4551                AssessmentRuntimeLimits::new(limits, expected, allowance),
4552                usage,
4553                &WebAssessmentCompletion::Complete,
4554                WebAssessmentDefenseMode::ObservationOnly,
4555                &profile,
4556            ),
4557            Ok(())
4558        );
4559        assert_eq!(
4560            validate_completed_assessment_truth_with_active_limit(
4561                root,
4562                AssessmentRuntimeLimits::new(limits, expected - 1, allowance),
4563                usage,
4564                &WebAssessmentCompletion::Complete,
4565                WebAssessmentDefenseMode::ObservationOnly,
4566                &profile,
4567            ),
4568            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4569        );
4570        assert_eq!(
4571            validate_completed_assessment_truth_with_active_limit(
4572                root,
4573                AssessmentRuntimeLimits::new(limits, expected, allowance + 1),
4574                usage,
4575                &WebAssessmentCompletion::Complete,
4576                WebAssessmentDefenseMode::ObservationOnly,
4577                &profile,
4578            ),
4579            Err(AssessmentRunReportError::AssessmentUsageMismatch)
4580        );
4581    }
4582
4583    #[cfg(feature = "rest-review")]
4584    #[test]
4585    fn rest_audit_contract_requires_exact_positive_replay_and_item_truth() {
4586        let positive = RestAuditFacts {
4587            outcome: RestRuntimeOutcome::SurfaceObserved,
4588            request_count: MAX_REST_REVIEW_REQUESTS,
4589            active_verification_count: MAX_REST_REVIEW_ACTIVE_VERIFICATIONS,
4590            eligible_operation_count: 1,
4591            selected_operation_present: true,
4592            replay_stable: true,
4593            item_projected: true,
4594        };
4595        assert!(positive.is_valid(1));
4596
4597        for invalid in [
4598            RestAuditFacts {
4599                request_count: MAX_REST_REVIEW_REQUESTS + 1,
4600                ..positive
4601            },
4602            RestAuditFacts {
4603                active_verification_count: MAX_REST_REVIEW_ACTIVE_VERIFICATIONS + 1,
4604                ..positive
4605            },
4606            RestAuditFacts {
4607                eligible_operation_count: 0,
4608                ..positive
4609            },
4610            RestAuditFacts {
4611                selected_operation_present: false,
4612                ..positive
4613            },
4614            RestAuditFacts {
4615                replay_stable: false,
4616                ..positive
4617            },
4618            RestAuditFacts {
4619                item_projected: false,
4620                ..positive
4621            },
4622        ] {
4623            assert!(!invalid.is_valid(1));
4624        }
4625
4626        let negative = RestAuditFacts {
4627            outcome: RestRuntimeOutcome::NotEligible,
4628            request_count: 0,
4629            active_verification_count: 0,
4630            eligible_operation_count: 0,
4631            selected_operation_present: false,
4632            replay_stable: false,
4633            item_projected: false,
4634        };
4635        assert!(negative.is_valid(0));
4636        assert!(!negative.is_valid(1));
4637    }
4638
4639    #[test]
4640    fn partial_cancelled_failed_and_no_eligible_runs_cannot_masquerade_as_complete() {
4641        for (status, stop, step) in [
4642            (
4643                RunStatus::Partial,
4644                RunStopCode::NoEligibleAction,
4645                RunStepStatus::Succeeded,
4646            ),
4647            (
4648                RunStatus::Cancelled,
4649                RunStopCode::Cancelled,
4650                RunStepStatus::Cancelled,
4651            ),
4652            (
4653                RunStatus::Failed,
4654                RunStopCode::StepFailed,
4655                RunStepStatus::Failed,
4656            ),
4657            (
4658                RunStatus::Complete,
4659                RunStopCode::NoEligibleAction,
4660                RunStepStatus::Succeeded,
4661            ),
4662        ] {
4663            let run = run_report(
4664                status,
4665                stop,
4666                step,
4667                PRIVATE_CANONICAL_TARGET,
4668                PRIVATE_EXACT_ORIGIN,
4669                "2026-08-28T10:00:01Z",
4670                WEB_ASSESSMENT_RUN_STEP_ID,
4671                None,
4672                expected_run_accounting(
4673                    WebAssessmentLimits::default(),
4674                    usage_truth(PRIVATE_CANONICAL_TARGET),
4675                ),
4676                Vec::new(),
4677            );
4678            assert_eq!(
4679                AssessmentRunReport::new(
4680                    run,
4681                    root_item_set(PRIVATE_EXACT_ORIGIN),
4682                    completed_truth(PRIVATE_CANONICAL_TARGET),
4683                )
4684                .unwrap_err(),
4685                AssessmentRunReportError::RunNotComplete
4686            );
4687        }
4688    }
4689
4690    #[test]
4691    fn accounting_duration_step_and_outcome_injection_fail_closed() {
4692        let unmetered = run_report(
4693            RunStatus::Complete,
4694            RunStopCode::Completed,
4695            RunStepStatus::Succeeded,
4696            PRIVATE_CANONICAL_TARGET,
4697            PRIVATE_EXACT_ORIGIN,
4698            "2026-08-28T10:00:01Z",
4699            WEB_ASSESSMENT_RUN_STEP_ID,
4700            None,
4701            RunAccounting::unmetered(),
4702            Vec::new(),
4703        );
4704        assert_eq!(
4705            AssessmentRunReport::new(
4706                unmetered,
4707                root_item_set(PRIVATE_EXACT_ORIGIN),
4708                completed_truth(PRIVATE_CANONICAL_TARGET),
4709            )
4710            .unwrap_err(),
4711            AssessmentRunReportError::RunAccountingMismatch
4712        );
4713
4714        let wrong_duration = run_report(
4715            RunStatus::Complete,
4716            RunStopCode::Completed,
4717            RunStepStatus::Succeeded,
4718            PRIVATE_CANONICAL_TARGET,
4719            PRIVATE_EXACT_ORIGIN,
4720            "2026-08-28T10:00:02Z",
4721            WEB_ASSESSMENT_RUN_STEP_ID,
4722            None,
4723            expected_run_accounting(
4724                WebAssessmentLimits::default(),
4725                usage_truth(PRIVATE_CANONICAL_TARGET),
4726            ),
4727            Vec::new(),
4728        );
4729        assert_eq!(
4730            AssessmentRunReport::new(
4731                wrong_duration,
4732                root_item_set(PRIVATE_EXACT_ORIGIN),
4733                completed_truth(PRIVATE_CANONICAL_TARGET),
4734            )
4735            .unwrap_err(),
4736            AssessmentRunReportError::RunDurationMismatch
4737        );
4738
4739        let wrong_step = run_report(
4740            RunStatus::Complete,
4741            RunStopCode::Completed,
4742            RunStepStatus::Succeeded,
4743            PRIVATE_CANONICAL_TARGET,
4744            PRIVATE_EXACT_ORIGIN,
4745            "2026-08-28T10:00:01Z",
4746            "unrelated-step",
4747            None,
4748            expected_run_accounting(
4749                WebAssessmentLimits::default(),
4750                usage_truth(PRIVATE_CANONICAL_TARGET),
4751            ),
4752            Vec::new(),
4753        );
4754        assert_eq!(
4755            AssessmentRunReport::new(
4756                wrong_step,
4757                root_item_set(PRIVATE_EXACT_ORIGIN),
4758                completed_truth(PRIVATE_CANONICAL_TARGET),
4759            )
4760            .unwrap_err(),
4761            AssessmentRunReportError::RunStepMismatch
4762        );
4763
4764        let injected = RunOutcomeRecord::unresolved(
4765            EntityId::new("endpoint:https://example.test/").unwrap(),
4766            "legacy.outcome",
4767            "unverified",
4768            "redacted",
4769        )
4770        .unwrap();
4771        let with_outcome = run_report(
4772            RunStatus::Complete,
4773            RunStopCode::Completed,
4774            RunStepStatus::Succeeded,
4775            PRIVATE_CANONICAL_TARGET,
4776            PRIVATE_EXACT_ORIGIN,
4777            "2026-08-28T10:00:01Z",
4778            WEB_ASSESSMENT_RUN_STEP_ID,
4779            None,
4780            expected_run_accounting(
4781                WebAssessmentLimits::default(),
4782                usage_truth(PRIVATE_CANONICAL_TARGET),
4783            ),
4784            vec![injected],
4785        );
4786        assert_eq!(
4787            AssessmentRunReport::new(
4788                with_outcome,
4789                root_item_set(PRIVATE_EXACT_ORIGIN),
4790                completed_truth(PRIVATE_CANONICAL_TARGET),
4791            )
4792            .unwrap_err(),
4793            AssessmentRunReportError::RunOutcomesForbidden
4794        );
4795    }
4796
4797    #[test]
4798    fn fingerprints_define_stable_order_and_duplicates_fail_closed() {
4799        let mut items = vec![
4800            TestItem("sha256:ccc"),
4801            TestItem("sha256:aaa"),
4802            TestItem("sha256:bbb"),
4803        ];
4804        validate_and_canonicalize_items(BuiltInScanProfile::WebReview, &mut items).unwrap();
4805        assert_eq!(
4806            items,
4807            vec![
4808                TestItem("sha256:aaa"),
4809                TestItem("sha256:bbb"),
4810                TestItem("sha256:ccc"),
4811            ]
4812        );
4813
4814        let mut duplicates = vec![
4815            TestItem("sha256:other"),
4816            TestItem("sha256:duplicate"),
4817            TestItem("sha256:duplicate"),
4818        ];
4819        assert_eq!(
4820            validate_and_canonicalize_items(BuiltInScanProfile::WebReview, &mut duplicates,),
4821            Err(AssessmentRunReportError::DuplicateFingerprint)
4822        );
4823    }
4824
4825    #[test]
4826    fn debug_and_errors_never_echo_private_report_or_item_identity() {
4827        let truth = completed_truth(PRIVATE_CANONICAL_TARGET);
4828        let truth_debug = format!("{truth:?}");
4829        assert!(truth_debug.contains("<stable-digest>"));
4830        assert!(!truth_debug.contains(PRIVATE_CANONICAL_TARGET));
4831
4832        let report = AssessmentRunReport::new(
4833            complete_run_report(PRIVATE_CANONICAL_TARGET, PRIVATE_EXACT_ORIGIN),
4834            root_item_set(PRIVATE_EXACT_ORIGIN),
4835            truth,
4836        )
4837        .unwrap();
4838        let debug = format!("{report:?}");
4839        assert!(debug.contains(ASSESSMENT_RUN_REPORT_SCHEMA));
4840        assert!(debug.contains("item_count: 0"));
4841        for private in [
4842            PRIVATE_EXACT_ORIGIN,
4843            PRIVATE_CANONICAL_TARGET,
4844            PRIVATE_STOP_DETAIL,
4845        ] {
4846            assert!(!debug.contains(private));
4847        }
4848
4849        let duplicate = AssessmentRunReportError::DuplicateFingerprint.to_string();
4850        let baseline = AssessmentRunReportError::BaselineItemsForbidden.to_string();
4851        for output in [duplicate, baseline] {
4852            assert!(!output.contains("sha256:"));
4853            assert!(!output.contains("evidence"));
4854            assert!(!output.contains("credential"));
4855            assert!(!output.contains(PRIVATE_EXACT_ORIGIN));
4856        }
4857    }
4858
4859    #[cfg(feature = "supplied-session-review")]
4860    #[test]
4861    fn form_login_epoch_requires_health_or_the_exact_pre_transition_cookie_stop() {
4862        assert!(valid_v3_login_epoch_transition(1, true, false));
4863        assert!(!valid_v3_login_epoch_transition(1, false, false));
4864
4865        assert!(valid_v3_login_epoch_transition(0, false, false));
4866        assert!(valid_v3_login_epoch_transition(0, true, true));
4867        assert!(!valid_v3_login_epoch_transition(0, true, false));
4868
4869        assert!(!valid_v3_login_epoch_transition(2, true, true));
4870    }
4871}