Skip to main content

termivar_scanner/
reporting.rs

1//! Deterministic, bounded rendering for validated [`termivar_core::RunReport`] values.
2
3pub mod comparison;
4
5#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
6use crate::control_reference_mapping::{
7    ControlReferenceMappingAudit, CONTROL_REFERENCE_MAPPING_AUDIT_SCHEMA,
8    CONTROL_REFERENCE_MAPPING_CATALOGUE_ID, CONTROL_REFERENCE_MAPPING_CATALOGUE_REVISION,
9    CONTROL_REFERENCE_MAPPING_POLICY_ID,
10};
11#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
12use crate::recon_ct_provider::{
13    MAX_CERT_SPOTTER_ELAPSED, MAX_CERT_SPOTTER_IN_FLIGHT_REQUESTS, MAX_CERT_SPOTTER_RESPONSE_PAGES,
14    MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES, MAX_CERT_SPOTTER_RETAINED_NAMES,
15    MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES,
16};
17#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
18use crate::recon_snapshot::{
19    ReconSnapshot, ReconnaissanceRecordValue, MAX_RECONNAISSANCE_SNAPSHOT_PREPARED_INDEX_BYTES,
20    MAX_RECONNAISSANCE_SNAPSHOT_RECORDS, MAX_RECONNAISSANCE_SNAPSHOT_SOURCES,
21    MAX_RECONNAISSANCE_SNAPSHOT_SOURCE_ASSOCIATIONS,
22};
23
24#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
25use crate::jwt_policy_review::{
26    JwtClockAssurance, JwtExternalOperationStatus, JwtLocalSignatureStatus, JwtParseRejection,
27    JwtParsingStatus, JwtPolicyReviewAudit, JwtPolicyStatus, JwtPolicyViolation,
28    JwtTargetAcceptanceStatus, JWT_POLICY_REVIEW_AUDIT_SCHEMA, JWT_POLICY_REVIEW_POLICY_ID,
29    MAX_CLOCK_SKEW_SECONDS, MAX_REQUIRED_CLAIMS,
30};
31#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
32use crate::jwt_target_acceptance::{
33    JwtTargetAcceptanceActivity, JwtTargetAcceptanceAudit, JwtTargetAcceptanceCommitStatus,
34    JwtTargetAcceptanceConclusion, JwtTargetAcceptanceDimension, JwtTargetAcceptanceDispatchStatus,
35    JwtTargetAcceptanceIncompleteReason, JwtTargetAcceptanceLegRole, JwtTargetAcceptanceLegStage,
36    JwtTargetAcceptanceMarkerStatus, JwtTargetAcceptanceMethod,
37    JwtTargetAcceptanceNotEligibleReason, JwtTargetAcceptanceResponseStatus,
38    JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA, JWT_TARGET_ACCEPTANCE_POLICY_ID,
39    MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS, MAX_JWT_TARGET_ACCEPTANCE_REQUESTS,
40    MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES, MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES,
41};
42#[cfg(all(feature = "scanning", feature = "rest-review"))]
43use crate::rest_review::RestDocumentedResponseClass;
44#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
45use crate::template_evaluation_review::{
46    TemplateEvaluationReviewAudit, TemplateEvaluationReviewOutcome,
47    TEMPLATE_EVALUATION_ENGINE_IDENTITY, TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED,
48    TEMPLATE_EVALUATION_REVIEW_AUDIT_SCHEMA, TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID,
49    TEMPLATE_EVALUATION_REVIEW_CATEGORY, TEMPLATE_EVALUATION_REVIEW_CLAIM_LIMITS,
50    TEMPLATE_EVALUATION_REVIEW_FAMILY_ID, TEMPLATE_EVALUATION_REVIEW_POLICY_ID,
51    TEMPLATE_EVALUATION_REVIEW_REMEDIATION_ID, TEMPLATE_EVALUATION_REVIEW_REMEDIATION_SUMMARY,
52    TEMPLATE_EVALUATION_REVIEW_SUMMARY, TEMPLATE_EVALUATION_REVIEW_TITLE,
53};
54
55// Mirrors the closed projection emitted by
56// `assessment_review_projection::TEMPLATE_EVALUATION_REVIEW`. Keeping these
57// values in the writer contract makes a producer change fail closed instead of
58// silently changing the saved-report meaning.
59#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
60const TEMPLATE_EVALUATION_REVIEW_CONFIDENCE_PPM: u32 = 1_000_000;
61#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
62const TEMPLATE_EVALUATION_REVIEW_CONTROL_EVIDENCE_COUNT: usize = 6;
63#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
64const TEMPLATE_EVALUATION_REVIEW_CANDIDATE_EVIDENCE_COUNT: usize = 6;
65#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
66use crate::web_runtime::WebAssessmentReconCtProviderAudit;
67#[cfg(all(
68    feature = "scanning",
69    feature = "websocket-review",
70    feature = "supplied-session-review"
71))]
72use crate::web_runtime::WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA;
73#[cfg(feature = "scanning")]
74use crate::web_runtime::{
75    AssessmentBasis, AssessmentRunReport, AssessmentRunReportError, ScanProfileV1,
76    WebAssessmentRunReport,
77};
78#[cfg(all(feature = "scanning", feature = "openapi-review"))]
79use crate::web_runtime::{OpenApiRuntimeOutcome, OPENAPI_REVIEW_CAPABILITY_ID};
80#[cfg(all(feature = "scanning", feature = "rest-review"))]
81use crate::web_runtime::{
82    RestObservedMediaClass, RestRuntimeOutcome, MAX_REST_REVIEW_ACTIVE_VERIFICATIONS,
83    MAX_REST_REVIEW_REQUESTS, REST_REVIEW_CAPABILITY_ID,
84};
85#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
86use crate::web_runtime::{
87    WebAssessmentSecretExposureAudit, MAX_SECRET_EXPOSURE_BODY_BYTES,
88    MAX_SECRET_EXPOSURE_OCCURRENCES, MAX_SECRET_EXPOSURE_RESPONSES,
89    MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS, MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES,
90    SECRET_EXPOSURE_AUDIT_SCHEMA, SECRET_EXPOSURE_CATALOGUE_ID, SECRET_EXPOSURE_CATALOGUE_REVISION,
91    SECRET_EXPOSURE_POLICY_ID, SECRET_EXPOSURE_REPRESENTATION,
92};
93#[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
94use crate::web_runtime::{
95    WebAssessmentTlsNegotiationReviewAudit, TLS_NEGOTIATION_REVIEW_AUDIT_SCHEMA,
96    TLS_NEGOTIATION_REVIEW_CAPABILITY_ID, TLS_NEGOTIATION_REVIEW_POLICY_ID,
97};
98#[cfg(all(feature = "scanning", feature = "tls-observation"))]
99use crate::web_runtime::{
100    WebAssessmentTlsObservationAudit, HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS,
101    TLS_OBSERVATION_AUDIT_SCHEMA, TLS_OBSERVATION_BACKEND_LIMIT, TLS_OBSERVATION_CLOCK_ASSURANCE,
102    TLS_OBSERVATION_POLICY_ID, TLS_OBSERVATION_REVOCATION_STATUS, TLS_OBSERVATION_SOURCE_SCOPE,
103    TLS_OBSERVATION_VALIDATION_SCOPE,
104};
105#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
106use crate::xml_external_entity_review::{
107    XmlExternalEntityMaximumDisposition, XmlExternalEntityOperationStatus,
108    XmlExternalEntityReviewAudit, XmlExternalEntityReviewOutcome,
109    MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES, MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS,
110    MAX_XML_EXTERNAL_ENTITY_TARGET_RESPONSE_BYTES, XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS,
111    XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL, XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE,
112    XML_EXTERNAL_ENTITY_REVIEW_METHOD, XML_EXTERNAL_ENTITY_TARGET_REQUESTS,
113};
114#[cfg(all(feature = "scanning", feature = "authorization-review"))]
115use crate::{
116    authorization_review::{
117        AuthorizationReviewOutcome, HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS,
118        HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS,
119    },
120    web_runtime::{MAX_AUTHORIZATION_REVIEW_REQUESTS, RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID},
121};
122#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
123use crate::{
124    supplied_session_review::{
125        SuppliedSessionCredentialAcquisition, SuppliedSessionCredentialMechanism,
126        MAX_SUPPLIED_SESSION_COOKIES, MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES,
127    },
128    web_runtime::{
129        SuppliedSessionAuditOutcome, SuppliedSessionBodyState, SuppliedSessionCoverage,
130        SuppliedSessionHealthCheckpointPhase, SuppliedSessionHealthOracleKind,
131        SuppliedSessionHealthOutcome, SuppliedSessionLoginFormOutcome,
132        SuppliedSessionLoginSubmitOutcome, SuppliedSessionPredicateOutcome,
133        SuppliedSessionPrincipalAssurance, SuppliedSessionResourceOutcome,
134        WebAssessmentSuppliedSessionAudit, WebAssessmentSuppliedSessionCheckpointAudit,
135        MAX_SUPPLIED_SESSION_CHECKPOINTS, MAX_SUPPLIED_SESSION_REQUESTS,
136        MAX_SUPPLIED_SESSION_RESOURCES, SUPPLIED_SESSION_AUDIT_SCHEMA,
137        SUPPLIED_SESSION_CAPABILITY_ID, SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA,
138        SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA,
139        SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA,
140        SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA, SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA,
141        SUPPLIED_SESSION_RENEWAL_AUDIT_SCHEMA,
142    },
143};
144#[cfg(all(feature = "scanning", feature = "websocket-review"))]
145use crate::{
146    web_runtime::{
147        WebAssessmentWebSocketReviewAudit, HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES,
148        MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION, MAX_WEBSOCKET_REVIEW_CONNECTIONS,
149        WEBSOCKET_REVIEW_AUDIT_SCHEMA, WEBSOCKET_REVIEW_CAPABILITY_ID,
150    },
151    websocket_review::{
152        MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES, MAX_WEBSOCKET_REVIEW_MESSAGES,
153        MAX_WEBSOCKET_REVIEW_WALL_TIME_MS,
154    },
155};
156#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
157use crate::{
158    web_runtime::{
159        WebAssessmentWordPressAudit, WordPressAssetFingerprintExecution,
160        WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID, WORDPRESS_REVIEW_CAPABILITY_ID,
161    },
162    wordpress_review::{
163        WordPressActivationState, WordPressAdvisoryCatalogSchema, WordPressApplicability,
164        WordPressAssetFingerprintAggregateState, WordPressAssetFingerprintRelation,
165        WordPressAssetFingerprintReleaseState, WordPressAssetFingerprintUnknownReason,
166        WordPressCatalogStatus, WordPressComparisonProfile, WordPressComponentEvidenceClass,
167        WordPressComponentKind, WordPressEvidenceConfidence, WordPressEvidenceSource,
168        WordPressExecutionStatus, WordPressExternalApplicability,
169        WordPressExternalComparisonPolicy, WordPressExternalRangeReason,
170        WordPressExternalRangeRelation, WordPressExternalVersionEvidenceStatus,
171        WordPressExternalVersionRelation, WordPressExternalVersionRelationReason,
172        WordPressHostingOs, WordPressInventoryCategoryStatus, WordPressInventoryEntryStatus,
173        WordPressInventoryLimitationReason, WordPressLocalInputClass, WordPressMultisiteState,
174        WordPressPatchState, WordPressPrerequisite, WordPressPrerequisiteOutcome,
175        WordPressVersionRelation, WordPressVersionResolution, WordPressVersionResolutionReason,
176        WordfenceV3CvssRating, WordfenceV3IdentityMapping, WordfenceV3RangeValue,
177        MAX_WORDFENCE_V3_SOFTWARE_PER_RECORD, MAX_WORDPRESS_ADVISORY_RECORDS,
178        MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES, MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_BYTES,
179        MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_COMPONENTS,
180        MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_FILES, MAX_WORDPRESS_ASSET_FINGERPRINT_PATH_BYTES,
181        MAX_WORDPRESS_ASSET_FINGERPRINT_RELEASES_PER_COMPONENT,
182        MAX_WORDPRESS_ASSET_FINGERPRINT_RESOURCES_PER_COMPONENT,
183        MAX_WORDPRESS_ASSET_FINGERPRINT_RETAINED_BYTES,
184        MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS, MAX_WORDPRESS_RESULT_COMPONENTS,
185        MAX_WORDPRESS_RESULT_VERSION_EVIDENCE, MAX_WORDPRESS_SAVED_INVENTORY_BYTES,
186        MAX_WORDPRESS_SIGNALS, WORDFENCE_V3_IDENTITY_MAPPING_POLICY, WORDFENCE_V3_MAPPING_REVISION,
187        WORDFENCE_V3_MAPPING_REVISION_V2, WORDFENCE_V3_RESOURCE_POLICY_V1,
188        WORDFENCE_V3_RESOURCE_POLICY_V2, WORDFENCE_V3_RESOURCE_POLICY_V3,
189        WORDPRESS_ADVISORY_CATALOG_SCHEMA, WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2,
190        WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA,
191        WORDPRESS_ASSET_FINGERPRINT_REPRESENTATION_PROFILE,
192    },
193};
194use serde::Serialize;
195#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
196use sha2::{Digest, Sha256};
197use std::{error::Error, fmt, io};
198use termivar_core::{
199    OutcomeStatus, ResourceAccounting, ResourceAccountingMode, RunOutcomeRecord, RunReport,
200    RunStatus, RunStepStatus, RunStopCode, SecuritySeverity,
201};
202
203/// Stable schema name for rendered run documents.
204pub const REPORT_DOCUMENT_SCHEMA: &str = "venom-rendered-run/v1";
205/// Stable schema name for the additive, redacted assessment document.
206#[cfg(feature = "scanning")]
207pub const ASSESSMENT_REPORT_DOCUMENT_SCHEMA: &str = "venom-rendered-assessment/v1";
208#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
209const XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA: &str =
210    "security.xml-external-entity-review-audit/v1";
211#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
212const XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM: &str =
213    "security.xml-external-entity-resolution-review/v1";
214#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
215const XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID: &str =
216    "xml.external-entity.repeated-outbound-interaction@1";
217/// Maximum UTF-8 bytes returned by one render operation.
218pub const MAX_RENDERED_REPORT_BYTES: usize = 16 * 1_024 * 1_024;
219#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
220const RECON_SNAPSHOT_IMPORT_AUDIT_SCHEMA: &str = "security.recon-snapshot-import-audit/v1";
221#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
222const RECON_CERTSPOTTER_AUDIT_SCHEMA: &str = "security.recon-certspotter-audit/v1";
223#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
224const RECON_CERTSPOTTER_POLICY_ID: &str = "termivar.recon-certspotter-provider/v1";
225#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
226const RECON_CERTSPOTTER_PROVIDER: &str = "cert_spotter";
227#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
228const RECON_CERTSPOTTER_ISSUANCE_REFERENCE_PREFIX: &str = "certspotter-issuance-sha256:";
229#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
230const MAX_RECON_CERTSPOTTER_ISSUANCE_ID_BYTES: usize = 128;
231#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
232const RECON_CERTSPOTTER_CLAIM_LIMITS: [&str; 5] = [
233    "hypotheses_only",
234    "scan_authority_not_granted",
235    "ownership_not_established",
236    "currentness_not_established",
237    "source_authentication_not_established",
238];
239
240const REPORT_FORMATS: [ReportFormat; 4] = [
241    ReportFormat::Json,
242    ReportFormat::Csv,
243    ReportFormat::Html,
244    ReportFormat::Markdown,
245];
246
247/// Supported deterministic report encodings.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
249#[non_exhaustive]
250pub enum ReportFormat {
251    /// Compact UTF-8 JSON.
252    Json,
253    /// UTF-8 comma-separated records.
254    Csv,
255    /// Self-contained UTF-8 HTML.
256    Html,
257    /// UTF-8 Markdown.
258    Markdown,
259}
260
261impl ReportFormat {
262    /// Returns the stable format token.
263    pub const fn as_str(self) -> &'static str {
264        match self {
265            Self::Json => "json",
266            Self::Csv => "csv",
267            Self::Html => "html",
268            Self::Markdown => "markdown",
269        }
270    }
271
272    /// Returns the stable media type.
273    pub const fn media_type(self) -> &'static str {
274        match self {
275            Self::Json => "application/json",
276            Self::Csv => "text/csv; charset=utf-8",
277            Self::Html => "text/html; charset=utf-8",
278            Self::Markdown => "text/markdown; charset=utf-8",
279        }
280    }
281
282    /// Returns the conventional extension without a leading period.
283    pub const fn extension(self) -> &'static str {
284        match self {
285            Self::Json => "json",
286            Self::Csv => "csv",
287            Self::Html => "html",
288            Self::Markdown => "md",
289        }
290    }
291}
292
293/// Fail-closed rendering errors.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295#[non_exhaustive]
296pub enum ReportError {
297    /// Escaped output would exceed the public byte ceiling.
298    OutputLimitExceeded {
299        /// Configured maximum returned size.
300        limit: usize,
301    },
302    /// A deterministic projection could not be serialized.
303    Serialization,
304}
305
306impl fmt::Display for ReportError {
307    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
308        match self {
309            Self::OutputLimitExceeded { .. } => {
310                formatter.write_str("rendered report exceeds the output byte limit")
311            },
312            Self::Serialization => formatter.write_str("rendered report serialization failed"),
313        }
314    }
315}
316
317impl Error for ReportError {}
318
319/// Stateless renderer for supported encodings.
320#[derive(Debug, Default, Clone, Copy)]
321pub struct ReportGenerator;
322
323impl ReportGenerator {
324    /// Renders one report, returning no partial document on failure.
325    pub fn generate(report: &RunReport, format: ReportFormat) -> Result<String, ReportError> {
326        let document = ReportDocument::from_report(report)?;
327        render_with_limit(&document, format, MAX_RENDERED_REPORT_BYTES)
328    }
329
330    /// Consumes one completed runtime-owned web assessment into its typed
331    /// product envelope. The generic run envelope is minted internally from
332    /// the runtime's clock and metered accounting; callers cannot supply or
333    /// replace it.
334    #[cfg(feature = "scanning")]
335    pub fn compose_assessment(
336        report: WebAssessmentRunReport,
337        profile: ScanProfileV1,
338    ) -> Result<AssessmentRunReport, AssessmentRunReportError> {
339        #[cfg(feature = "jwt-target-acceptance-review")]
340        if report.jwt_target_acceptance_audit().is_some() {
341            return Err(AssessmentRunReportError::JwtPolicyReviewAuditMismatch);
342        }
343        report.into_assessment_report(profile)
344    }
345
346    /// Composes the ordinary completed assessment and then attaches one
347    /// independently evaluated transport-free local JWT audit. No runtime or
348    /// network authority is created by this reporting bridge.
349    #[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
350    pub fn compose_assessment_with_jwt_policy_review(
351        report: WebAssessmentRunReport,
352        profile: ScanProfileV1,
353        audit: Option<JwtPolicyReviewAudit>,
354    ) -> Result<AssessmentRunReport, AssessmentRunReportError> {
355        report
356            .into_assessment_report(profile)?
357            .with_jwt_policy_review_audit(audit)
358    }
359
360    /// Attaches the explicitly selected, transport-free control-reference
361    /// projection after the ordinary assessment has completed. The mapper can
362    /// neither create assessment items nor change their claim authority.
363    #[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
364    pub fn attach_control_reference_mapping(
365        report: AssessmentRunReport,
366    ) -> Result<AssessmentRunReport, AssessmentRunReportError> {
367        report.with_control_reference_mapping()
368    }
369
370    /// Attaches one explicitly supplied, already validated local
371    /// reconnaissance snapshot after the ordinary assessment has completed.
372    /// Imported hypotheses do not become subjects, assessment items, or
373    /// transport authority.
374    #[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
375    pub fn attach_recon_snapshot(
376        report: AssessmentRunReport,
377        audit: ReconSnapshot,
378    ) -> Result<AssessmentRunReport, AssessmentRunReportError> {
379        report.with_recon_snapshot(audit)
380    }
381
382    /// Renders one completed typed assessment through the existing bounded,
383    /// context-safe report encoders. This is an additive document surface and
384    /// never changes the [`REPORT_DOCUMENT_SCHEMA`] compatibility contract.
385    #[cfg(feature = "scanning")]
386    pub fn generate_assessment(
387        report: &AssessmentRunReport,
388        format: ReportFormat,
389    ) -> Result<String, ReportError> {
390        let document = AssessmentDocument::from_report(report)?;
391        render_assessment_with_limit(&document, format, MAX_RENDERED_REPORT_BYTES)
392    }
393
394    /// Returns all formats in stable negotiation order.
395    pub const fn available_formats() -> &'static [ReportFormat] {
396        &REPORT_FORMATS
397    }
398}
399
400fn render_with_limit(
401    document: &ReportDocument<'_>,
402    format: ReportFormat,
403    limit: usize,
404) -> Result<String, ReportError> {
405    match format {
406        ReportFormat::Json => render_json(document, limit),
407        ReportFormat::Csv => render_csv(document, limit),
408        ReportFormat::Html => render_html(document, limit),
409        ReportFormat::Markdown => render_markdown(document, limit),
410    }
411}
412
413fn render_json(document: &ReportDocument<'_>, limit: usize) -> Result<String, ReportError> {
414    render_serializable_json(document, limit)
415}
416
417fn render_serializable_json(
418    document: &impl Serialize,
419    limit: usize,
420) -> Result<String, ReportError> {
421    let mut raw = RawJsonWriter::new(limit);
422    if serde_json::to_writer(&mut raw, document).is_err() {
423        return if raw.exceeded {
424            Err(ReportError::OutputLimitExceeded { limit })
425        } else {
426            Err(ReportError::Serialization)
427        };
428    }
429    let raw = std::str::from_utf8(&raw.bytes).map_err(|_| ReportError::Serialization)?;
430    let mut output = RenderBuffer::new(limit);
431    for character in raw.chars() {
432        if character.is_control() || is_bidi_control(character) {
433            write_json_codepoint(&mut output, character)?;
434        } else {
435            output.push_char(character)?;
436        }
437    }
438    Ok(output.finish())
439}
440
441struct RawJsonWriter {
442    bytes: Vec<u8>,
443    limit: usize,
444    exceeded: bool,
445}
446
447impl RawJsonWriter {
448    fn new(limit: usize) -> Self {
449        Self {
450            bytes: Vec::new(),
451            limit,
452            exceeded: false,
453        }
454    }
455}
456
457impl io::Write for RawJsonWriter {
458    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
459        let Some(next_len) = self.bytes.len().checked_add(bytes.len()) else {
460            self.exceeded = true;
461            return Err(io::Error::other("raw JSON limit reached"));
462        };
463        if next_len > self.limit {
464            self.exceeded = true;
465            return Err(io::Error::other("raw JSON limit reached"));
466        }
467        self.bytes
468            .try_reserve(bytes.len())
469            .map_err(|_| io::Error::other("raw JSON allocation failed"))?;
470        self.bytes.extend_from_slice(bytes);
471        Ok(bytes.len())
472    }
473
474    fn flush(&mut self) -> io::Result<()> {
475        Ok(())
476    }
477}
478
479fn write_json_codepoint(output: &mut RenderBuffer, character: char) -> Result<(), ReportError> {
480    let codepoint = u32::from(character);
481    if codepoint <= u32::from(u16::MAX) {
482        return output.push_fmt(format_args!("\\u{codepoint:04X}"));
483    }
484    let supplementary = codepoint - 0x1_0000;
485    let high = 0xD800 + (supplementary >> 10);
486    let low = 0xDC00 + (supplementary & 0x3FF);
487    output.push_fmt(format_args!("\\u{high:04X}\\u{low:04X}"))
488}
489
490struct RenderBuffer {
491    value: String,
492    limit: usize,
493}
494
495impl RenderBuffer {
496    fn new(limit: usize) -> Self {
497        Self {
498            value: String::new(),
499            limit,
500        }
501    }
502
503    fn push_str(&mut self, value: &str) -> Result<(), ReportError> {
504        let Some(next_len) = self.value.len().checked_add(value.len()) else {
505            return Err(ReportError::OutputLimitExceeded { limit: self.limit });
506        };
507        if next_len > self.limit {
508            return Err(ReportError::OutputLimitExceeded { limit: self.limit });
509        }
510        self.value.push_str(value);
511        Ok(())
512    }
513
514    fn push_char(&mut self, value: char) -> Result<(), ReportError> {
515        let next_len = self
516            .value
517            .len()
518            .checked_add(value.len_utf8())
519            .ok_or(ReportError::OutputLimitExceeded { limit: self.limit })?;
520        if next_len > self.limit {
521            return Err(ReportError::OutputLimitExceeded { limit: self.limit });
522        }
523        self.value.push(value);
524        Ok(())
525    }
526
527    fn push_fmt(&mut self, arguments: fmt::Arguments<'_>) -> Result<(), ReportError> {
528        fmt::write(self, arguments)
529            .map_err(|_| ReportError::OutputLimitExceeded { limit: self.limit })
530    }
531
532    fn finish(self) -> String {
533        self.value
534    }
535}
536
537impl fmt::Write for RenderBuffer {
538    fn write_str(&mut self, value: &str) -> fmt::Result {
539        self.push_str(value).map_err(|_| fmt::Error)
540    }
541
542    fn write_char(&mut self, value: char) -> fmt::Result {
543        self.push_char(value).map_err(|_| fmt::Error)
544    }
545}
546
547const CSV_HEADERS: [&str; 14] = [
548    "record_type",
549    "name",
550    "value",
551    "status_or_disposition",
552    "kind",
553    "action_id",
554    "severity",
555    "confidence_ppm",
556    "evidence_count",
557    "limit",
558    "consumed",
559    "remaining",
560    "duration_ms",
561    "redacted_summary",
562];
563
564fn render_csv(document: &ReportDocument<'_>, limit: usize) -> Result<String, ReportError> {
565    let mut output = RenderBuffer::new(limit);
566    write_csv_row(&mut output, CSV_HEADERS)?;
567    for (name, value) in [
568        ("schema", document.schema),
569        ("source_schema", document.source_schema),
570        ("status", document.status),
571        ("stop_code", document.stop_code),
572        ("target", document.target),
573        ("authorized_origin", document.authorized_origin),
574        ("started_at", document.started_at.as_str()),
575        ("completed_at", document.completed_at.as_str()),
576    ] {
577        write_csv_row(
578            &mut output,
579            [
580                "document", name, value, "", "", "", "", "", "", "", "", "", "", "",
581            ],
582        )?;
583    }
584    for (name, dimension) in document.accounting.dimensions() {
585        write_csv_row(
586            &mut output,
587            [
588                "accounting",
589                name,
590                dimension.mode,
591                "",
592                "",
593                "",
594                "",
595                "",
596                "",
597                dimension.limit.as_deref().unwrap_or(""),
598                dimension.consumed.as_deref().unwrap_or(""),
599                dimension.remaining.as_deref().unwrap_or(""),
600                "",
601                "",
602            ],
603        )?;
604    }
605    for step in &document.steps {
606        let ordinal = step.ordinal.to_string();
607        write_csv_row(
608            &mut output,
609            [
610                "step",
611                &ordinal,
612                "",
613                step.status,
614                "",
615                step.action_id,
616                "",
617                "",
618                "",
619                "",
620                "",
621                "",
622                &step.duration_ms,
623                "",
624            ],
625        )?;
626    }
627    for (index, outcome) in document.outcomes.iter().enumerate() {
628        let index = (index + 1).to_string();
629        let confidence_ppm = outcome.confidence_ppm.to_string();
630        let evidence_count = outcome.evidence_count.to_string();
631        write_csv_row(
632            &mut output,
633            [
634                "outcome",
635                &index,
636                "",
637                outcome.disposition,
638                outcome.kind,
639                outcome.action_id,
640                outcome.severity,
641                &confidence_ppm,
642                &evidence_count,
643                "",
644                "",
645                "",
646                "",
647                outcome.redacted_summary,
648            ],
649        )?;
650    }
651    Ok(output.finish())
652}
653
654fn write_csv_row(
655    output: &mut RenderBuffer,
656    cells: [&str; CSV_HEADERS.len()],
657) -> Result<(), ReportError> {
658    for (index, cell) in cells.into_iter().enumerate() {
659        if index != 0 {
660            output.push_char(',')?;
661        }
662        write_csv_cell(output, cell)?;
663    }
664    output.push_char('\n')
665}
666
667fn write_csv_cell(output: &mut RenderBuffer, value: &str) -> Result<(), ReportError> {
668    output.push_char('"')?;
669    if starts_csv_formula_after_whitespace(value) {
670        output.push_char('\'')?;
671    }
672    for character in value.chars() {
673        match character {
674            '"' => output.push_str("\"\"")?,
675            '\'' => output.push_str("\\u{0027}")?,
676            '\\' => output.push_str("\\\\")?,
677            character if character.is_control() || is_bidi_control(character) => {
678                write_visible_codepoint(output, character)?;
679            },
680            character => output.push_char(character)?,
681        }
682    }
683    output.push_char('"')
684}
685
686fn starts_csv_formula_after_whitespace(value: &str) -> bool {
687    matches!(
688        value.chars().find(|character| !character.is_whitespace()),
689        Some('=' | '+' | '-' | '@')
690    )
691}
692
693fn render_html(document: &ReportDocument<'_>, limit: usize) -> Result<String, ReportError> {
694    let mut output = RenderBuffer::new(limit);
695    output.push_str(
696        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
697<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; base-uri 'none'; form-action 'none'\">\
698<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
699<title>Termivar run report</title><style>\
700:root{color-scheme:light dark}body{font:14px/1.5 system-ui,sans-serif;margin:2rem;max-width:90rem}\
701h1,h2{line-height:1.2}.lifecycle{border:3px solid currentColor;padding:.8rem;font-size:1.1rem}.meta{display:grid;grid-template-columns:max-content 1fr;gap:.3rem 1rem}\
702table{border-collapse:collapse;width:100%;margin-block:1rem 2rem}th,td{border:1px solid currentColor;padding:.35rem;text-align:left;vertical-align:top}\
703code{overflow-wrap:anywhere}.empty{font-style:italic}</style></head><body><main>\
704<h1>Termivar run report</h1>",
705    )?;
706    if document.status == "complete" {
707        output
708            .push_str("<p class=\"lifecycle\"><strong>Lifecycle status:</strong> completed.</p>")?;
709    } else {
710        output.push_str(
711            "<aside class=\"lifecycle\"><strong>Lifecycle notice:</strong> Run did not complete. Status <code>",
712        )?;
713        write_html_text(&mut output, document.status)?;
714        output.push_str("</code>; stop code <code>")?;
715        write_html_text(&mut output, document.stop_code)?;
716        output.push_str("</code>.</aside>")?;
717    }
718    output.push_str("<dl class=\"meta\">")?;
719    for (label, value) in document.metadata() {
720        output.push_str("<dt>")?;
721        write_html_text(&mut output, label)?;
722        output.push_str("</dt><dd><code>")?;
723        write_html_text(&mut output, value)?;
724        output.push_str("</code></dd>")?;
725    }
726    output.push_str(
727        "</dl><section><h2>Resource accounting</h2><table><thead><tr>\
728<th>Dimension</th><th>Mode</th><th>Limit</th><th>Consumed</th><th>Remaining</th>\
729</tr></thead><tbody>",
730    )?;
731    for (name, dimension) in document.accounting.dimensions() {
732        output.push_str("<tr><td><code>")?;
733        write_html_text(&mut output, name)?;
734        output.push_str("</code></td><td><code>")?;
735        write_html_text(&mut output, dimension.mode)?;
736        output.push_str("</code></td>")?;
737        write_html_optional_decimal(&mut output, dimension.limit.as_deref())?;
738        write_html_optional_decimal(&mut output, dimension.consumed.as_deref())?;
739        write_html_optional_decimal(&mut output, dimension.remaining.as_deref())?;
740        output.push_str("</tr>")?;
741    }
742    output.push_str("</tbody></table></section><section><h2>Steps</h2>")?;
743    if document.steps.is_empty() {
744        output.push_str("<p class=\"empty\">No step records.</p>")?;
745    } else {
746        output.push_str(
747            "<table><thead><tr><th>Ordinal</th><th>Action</th><th>Status</th><th>Duration (ms)</th>\
748</tr></thead><tbody>",
749        )?;
750        for step in &document.steps {
751            output.push_fmt(format_args!("<tr><td>{}</td><td><code>", step.ordinal))?;
752            write_html_text(&mut output, step.action_id)?;
753            output.push_str("</code></td><td><code>")?;
754            write_html_text(&mut output, step.status)?;
755            output.push_fmt(format_args!(
756                "</code></td><td>{}</td></tr>",
757                step.duration_ms
758            ))?;
759        }
760        output.push_str("</tbody></table>")?;
761    }
762    output.push_str("</section><section><h2>Outcomes</h2>")?;
763    if document.outcomes.is_empty() {
764        output.push_str("<p class=\"empty\">No outcome records.</p>")?;
765    } else {
766        output.push_str(
767            "<table><thead><tr><th>Kind</th><th>Action</th><th>Severity</th>\
768<th>Disposition</th><th>Confidence (ppm)</th><th>Evidence count</th><th>Redacted summary</th>\
769</tr></thead><tbody>",
770        )?;
771        for outcome in &document.outcomes {
772            output.push_str("<tr><td><code>")?;
773            write_html_text(&mut output, outcome.kind)?;
774            output.push_str("</code></td><td><code>")?;
775            write_html_text(&mut output, outcome.action_id)?;
776            output.push_str("</code></td><td><code>")?;
777            write_html_text(&mut output, outcome.severity)?;
778            output.push_str("</code></td><td><code>")?;
779            write_html_text(&mut output, outcome.disposition)?;
780            output.push_fmt(format_args!(
781                "</code></td><td>{}</td><td>{}</td><td><code>",
782                outcome.confidence_ppm, outcome.evidence_count
783            ))?;
784            write_html_text(&mut output, outcome.redacted_summary)?;
785            output.push_str("</code></td></tr>")?;
786        }
787        output.push_str("</tbody></table>")?;
788    }
789    output.push_str("</section></main></body></html>")?;
790    Ok(output.finish())
791}
792
793fn write_html_optional_decimal(
794    output: &mut RenderBuffer,
795    value: Option<&str>,
796) -> Result<(), ReportError> {
797    match value {
798        Some(value) => {
799            output.push_str("<td>")?;
800            write_html_text(output, value)?;
801            output.push_str("</td>")
802        },
803        None => output.push_str("<td><span class=\"empty\">not reported</span></td>"),
804    }
805}
806
807fn write_html_text(output: &mut RenderBuffer, value: &str) -> Result<(), ReportError> {
808    for character in value.chars() {
809        match character {
810            '&' => output.push_str("&amp;")?,
811            '<' => output.push_str("&lt;")?,
812            '>' => output.push_str("&gt;")?,
813            '"' => output.push_str("&quot;")?,
814            '\'' => output.push_str("&#39;")?,
815            '\\' => output.push_str("\\\\")?,
816            character if character.is_control() || is_bidi_control(character) => {
817                write_visible_codepoint(output, character)?;
818            },
819            character => output.push_char(character)?,
820        }
821    }
822    Ok(())
823}
824
825fn render_markdown(document: &ReportDocument<'_>, limit: usize) -> Result<String, ReportError> {
826    let mut output = RenderBuffer::new(limit);
827    output.push_str("# Termivar run report\n\n")?;
828    if document.status != "complete" {
829        output.push_str("> **Lifecycle notice:** This run did not complete. Status ")?;
830        write_markdown_code_span(&mut output, document.status)?;
831        output.push_str("; stop code ")?;
832        write_markdown_code_span(&mut output, document.stop_code)?;
833        output.push_str(".\n\n")?;
834    }
835    for (label, value) in document.metadata() {
836        output.push_fmt(format_args!("- {label}: "))?;
837        write_markdown_code_span(&mut output, value)?;
838        output.push_char('\n')?;
839    }
840
841    output.push_str("\n## Resource accounting\n\n")?;
842    for (name, dimension) in document.accounting.dimensions() {
843        output.push_str("- Dimension ")?;
844        write_markdown_code_span(&mut output, name)?;
845        output.push_str(": mode ")?;
846        write_markdown_code_span(&mut output, dimension.mode)?;
847        output.push_str(", limit ")?;
848        write_markdown_optional_decimal(&mut output, dimension.limit.as_deref())?;
849        output.push_str(", consumed ")?;
850        write_markdown_optional_decimal(&mut output, dimension.consumed.as_deref())?;
851        output.push_str(", remaining ")?;
852        write_markdown_optional_decimal(&mut output, dimension.remaining.as_deref())?;
853        output.push_char('\n')?;
854    }
855
856    output.push_str("\n## Steps\n\n")?;
857    if document.steps.is_empty() {
858        output.push_str("No step records.\n")?;
859    } else {
860        for step in &document.steps {
861            output.push_fmt(format_args!("### Step {}\n\n- Action: ", step.ordinal))?;
862            write_markdown_code_span(&mut output, step.action_id)?;
863            output.push_str("\n- Status: ")?;
864            write_markdown_code_span(&mut output, step.status)?;
865            output.push_fmt(format_args!("\n- Duration (ms): {}\n\n", step.duration_ms))?;
866        }
867    }
868
869    output.push_str("## Outcomes\n\n")?;
870    if document.outcomes.is_empty() {
871        output.push_str("No outcome records.\n")?;
872    } else {
873        for (index, outcome) in document.outcomes.iter().enumerate() {
874            output.push_fmt(format_args!("### Outcome {}\n\n- Kind: ", index + 1))?;
875            write_markdown_code_span(&mut output, outcome.kind)?;
876            output.push_str("\n- Action: ")?;
877            write_markdown_code_span(&mut output, outcome.action_id)?;
878            output.push_str("\n- Severity: ")?;
879            write_markdown_code_span(&mut output, outcome.severity)?;
880            output.push_str("\n- Disposition: ")?;
881            write_markdown_code_span(&mut output, outcome.disposition)?;
882            output.push_fmt(format_args!(
883                "\n- Confidence (ppm): {}\n- Evidence count: {}\n- Redacted summary: ",
884                outcome.confidence_ppm, outcome.evidence_count
885            ))?;
886            write_markdown_code_span(&mut output, outcome.redacted_summary)?;
887            output.push_str("\n\n")?;
888        }
889    }
890    Ok(output.finish())
891}
892
893fn write_markdown_optional_decimal(
894    output: &mut RenderBuffer,
895    value: Option<&str>,
896) -> Result<(), ReportError> {
897    match value {
898        Some(value) => write_markdown_code_span(output, value),
899        None => output.push_str("`not reported`"),
900    }
901}
902
903fn write_markdown_code_span(output: &mut RenderBuffer, value: &str) -> Result<(), ReportError> {
904    let visible = if value.is_empty() {
905        String::from("\\u{EMPTY}")
906    } else {
907        visible_text(value)
908    };
909    let fence_length = longest_backtick_run(&visible) + 1;
910    for _ in 0..fence_length {
911        output.push_char('`')?;
912    }
913    let all_spaces = visible.chars().all(|character| character == ' ');
914    let needs_padding = !all_spaces
915        && (visible.starts_with('`')
916            || visible.starts_with(' ')
917            || visible.ends_with('`')
918            || visible.ends_with(' '));
919    if needs_padding {
920        output.push_char(' ')?;
921    }
922    output.push_str(&visible)?;
923    if needs_padding {
924        output.push_char(' ')?;
925    }
926    for _ in 0..fence_length {
927        output.push_char('`')?;
928    }
929    Ok(())
930}
931
932fn longest_backtick_run(value: &str) -> usize {
933    let mut current = 0;
934    let mut longest = 0;
935    for character in value.chars() {
936        if character == '`' {
937            current += 1;
938            longest = longest.max(current);
939        } else {
940            current = 0;
941        }
942    }
943    longest
944}
945
946fn visible_text(value: &str) -> String {
947    let mut visible = String::with_capacity(value.len());
948    for character in value.chars() {
949        if character == '\\' {
950            visible.push_str("\\\\");
951        } else if character.is_control() || is_bidi_control(character) {
952            push_visible_codepoint(&mut visible, character);
953        } else {
954            visible.push(character);
955        }
956    }
957    visible
958}
959
960fn push_visible_codepoint(output: &mut String, character: char) {
961    const HEX: &[u8; 16] = b"0123456789ABCDEF";
962    let codepoint = u32::from(character);
963    output.push_str("\\u{");
964    for shift in [12, 8, 4, 0] {
965        let index = ((codepoint >> shift) & 0xF) as usize;
966        output.push(char::from(HEX[index]));
967    }
968    output.push('}');
969}
970
971#[cfg(feature = "scanning")]
972fn render_assessment_with_limit(
973    document: &AssessmentDocument<'_>,
974    format: ReportFormat,
975    limit: usize,
976) -> Result<String, ReportError> {
977    document.validate()?;
978    match format {
979        ReportFormat::Json => render_serializable_json(document, limit),
980        ReportFormat::Csv => render_assessment_csv(document, limit),
981        ReportFormat::Html => render_assessment_html(document, limit),
982        ReportFormat::Markdown => render_assessment_markdown(document, limit),
983    }
984}
985
986#[cfg(feature = "scanning")]
987const ASSESSMENT_CSV_HEADERS: [&str; 30] = [
988    "record_type",
989    "document_schema",
990    "source_schema",
991    "run_schema",
992    "profile_schema",
993    "profile",
994    "status",
995    "subject_count",
996    "item_count",
997    "item_schema",
998    "capability_id",
999    "subject_reference",
1000    "title",
1001    "disposition",
1002    "claim_basis",
1003    "severity",
1004    "confidence_ppm",
1005    "fingerprint",
1006    "evidence_count",
1007    "redacted_summary",
1008    "category",
1009    "cwe",
1010    "remediation_id",
1011    "remediation_summary",
1012    "evidence_references",
1013    "control_evidence_references",
1014    "candidate_evidence_references",
1015    "case_reference",
1016    "outcome_reference",
1017    "verification_stage",
1018];
1019
1020#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
1021const SECRET_EXPOSURE_CONTEXT: &str = "anonymous-ordinary-get";
1022
1023#[cfg(feature = "scanning")]
1024fn render_assessment_csv(
1025    document: &AssessmentDocument<'_>,
1026    limit: usize,
1027) -> Result<String, ReportError> {
1028    let mut output = RenderBuffer::new(limit);
1029    write_assessment_csv_row(&mut output, ASSESSMENT_CSV_HEADERS)?;
1030    let subject_count = document.subject_count.to_string();
1031    let item_count = document.item_count.to_string();
1032    write_assessment_csv_row(
1033        &mut output,
1034        [
1035            "document",
1036            document.schema,
1037            document.source_schema,
1038            document.run_schema,
1039            document.profile_schema,
1040            document.profile,
1041            document.status,
1042            &subject_count,
1043            &item_count,
1044            "",
1045            "",
1046            "",
1047            "",
1048            "",
1049            "",
1050            "",
1051            "",
1052            "",
1053            "",
1054            "",
1055            "",
1056            "",
1057            "",
1058            "",
1059            "",
1060            "",
1061            "",
1062            "",
1063            "",
1064            "",
1065        ],
1066    )?;
1067    #[cfg(feature = "supplied-session-review")]
1068    if let Some(audit) = &document.supplied_session {
1069        let committed_count = audit.committed_resource_count.to_string();
1070        let summary = audit.wire_json()?;
1071        write_assessment_csv_row(
1072            &mut output,
1073            [
1074                "supplied_session_audit",
1075                audit.schema,
1076                "",
1077                "",
1078                "",
1079                "",
1080                audit.outcome,
1081                "",
1082                "",
1083                "",
1084                audit.capability_id,
1085                "",
1086                "",
1087                "",
1088                "",
1089                "",
1090                "",
1091                "",
1092                &committed_count,
1093                &summary,
1094                "supplied-session",
1095                "",
1096                "",
1097                "",
1098                "",
1099                "",
1100                "",
1101                "",
1102                "",
1103                "",
1104            ],
1105        )?;
1106    }
1107    #[cfg(feature = "authorization-review")]
1108    if let Some(audit) = &document.authorization_review {
1109        let request_count = audit.request_count.to_string();
1110        let summary = format!(
1111            "policy_id={};selected_path_count={};ignored_path_count={};primary_stable={};peer_stable={};cross_resources_equivalent={};item_projected={}",
1112            audit.policy_id,
1113            audit.selected_path_count,
1114            audit.ignored_path_count,
1115            optional_bool_token(audit.primary_stable),
1116            optional_bool_token(audit.peer_stable),
1117            optional_bool_token(audit.cross_resources_equivalent),
1118            audit.item_projected,
1119        );
1120        write_assessment_csv_row(
1121            &mut output,
1122            [
1123                "authorization_review_audit",
1124                audit.schema,
1125                "",
1126                "",
1127                "",
1128                "",
1129                audit.outcome,
1130                "",
1131                "",
1132                "",
1133                audit.capability_id,
1134                "",
1135                "",
1136                if audit.item_projected {
1137                    "needs_review"
1138                } else {
1139                    ""
1140                },
1141                if audit.item_projected {
1142                    "differential"
1143                } else {
1144                    ""
1145                },
1146                "",
1147                "",
1148                "",
1149                &request_count,
1150                &summary,
1151                "authorization-review",
1152                "",
1153                "",
1154                "",
1155                "",
1156                "",
1157                "",
1158                "",
1159                "",
1160                "",
1161            ],
1162        )?;
1163    }
1164    #[cfg(feature = "websocket-review")]
1165    if let Some(audit) = &document.websocket_review {
1166        let message_count = audit.coverage.expected_response_count.to_string();
1167        let summary = audit.wire_json()?;
1168        write_assessment_csv_row(
1169            &mut output,
1170            [
1171                "websocket_review_audit",
1172                audit.schema,
1173                "",
1174                "",
1175                "",
1176                "",
1177                audit.coverage.terminal,
1178                "",
1179                "",
1180                "",
1181                audit.capability_id,
1182                "",
1183                "",
1184                "",
1185                "",
1186                "",
1187                "",
1188                "",
1189                &message_count,
1190                &summary,
1191                "websocket-review",
1192                "",
1193                "",
1194                "",
1195                "",
1196                "",
1197                "",
1198                "",
1199                "",
1200                "",
1201            ],
1202        )?;
1203    }
1204    #[cfg(feature = "xml-external-entity-review")]
1205    if let Some(audit) = &document.xml_external_entity_review {
1206        let evidence_count = document
1207            .items
1208            .iter()
1209            .find(|item| item.capability_id == XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID)
1210            .map_or(0, |item| item.evidence_count)
1211            .to_string();
1212        let summary = audit.wire_json()?;
1213        write_assessment_csv_row(
1214            &mut output,
1215            [
1216                "xml_external_entity_review_audit",
1217                audit.schema,
1218                "",
1219                "",
1220                "",
1221                "",
1222                audit.outcome,
1223                "",
1224                "",
1225                "",
1226                XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID,
1227                "",
1228                "",
1229                if audit.item_projected {
1230                    "needs_review"
1231                } else {
1232                    ""
1233                },
1234                if audit.item_projected {
1235                    "differential"
1236                } else {
1237                    ""
1238                },
1239                "",
1240                "",
1241                "",
1242                &evidence_count,
1243                &summary,
1244                "xml-external-entity-review",
1245                if audit.item_projected { "CWE-611" } else { "" },
1246                "",
1247                "",
1248                "",
1249                "",
1250                "",
1251                "",
1252                "",
1253                "",
1254            ],
1255        )?;
1256    }
1257    #[cfg(feature = "template-evaluation-review")]
1258    if let Some(audit) = &document.template_evaluation_review {
1259        let evidence_count = document
1260            .items
1261            .iter()
1262            .find(|item| item.capability_id == TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID)
1263            .map_or(0, |item| item.evidence_count)
1264            .to_string();
1265        let summary = audit.wire_json()?;
1266        write_assessment_csv_row(
1267            &mut output,
1268            [
1269                "template_evaluation_review_audit",
1270                audit.schema,
1271                "",
1272                "",
1273                "",
1274                "",
1275                audit.outcome,
1276                "",
1277                "",
1278                "",
1279                TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID,
1280                "",
1281                "",
1282                if audit.coverage.projected_item_count == 1 {
1283                    "needs_review"
1284                } else {
1285                    ""
1286                },
1287                if audit.coverage.projected_item_count == 1 {
1288                    "differential"
1289                } else {
1290                    ""
1291                },
1292                "",
1293                "",
1294                "",
1295                &evidence_count,
1296                &summary,
1297                "template-evaluation-review",
1298                if audit.coverage.projected_item_count == 1 {
1299                    "CWE-1336"
1300                } else {
1301                    ""
1302                },
1303                "",
1304                "",
1305                "",
1306                "",
1307                "",
1308                "",
1309                "",
1310                "",
1311            ],
1312        )?;
1313    }
1314    #[cfg(feature = "openapi-review")]
1315    if let Some(audit) = &document.openapi_review {
1316        let request_count = audit.request_count.to_string();
1317        let summary = audit.summary();
1318        write_assessment_csv_row(
1319            &mut output,
1320            [
1321                "openapi_review_audit",
1322                audit.schema,
1323                "",
1324                "",
1325                "",
1326                "",
1327                audit.outcome,
1328                "",
1329                "",
1330                "",
1331                audit.capability_id,
1332                "",
1333                "",
1334                if audit.item_projected {
1335                    "informational"
1336                } else {
1337                    ""
1338                },
1339                if audit.item_projected {
1340                    "observation"
1341                } else {
1342                    ""
1343                },
1344                "",
1345                "",
1346                "",
1347                &request_count,
1348                &summary,
1349                "api-surface",
1350                "",
1351                "",
1352                "",
1353                "",
1354                "",
1355                "",
1356                "",
1357                "",
1358                "",
1359            ],
1360        )?;
1361    }
1362    #[cfg(feature = "rest-review")]
1363    if let Some(audit) = &document.rest_review {
1364        let request_count = audit.request_count.to_string();
1365        let summary = audit.summary();
1366        write_assessment_csv_row(
1367            &mut output,
1368            [
1369                "rest_readonly_review_audit",
1370                audit.schema,
1371                "",
1372                "",
1373                "",
1374                "",
1375                audit.outcome,
1376                "",
1377                "",
1378                "",
1379                audit.capability_id,
1380                "",
1381                "",
1382                if audit.item_projected {
1383                    "informational"
1384                } else {
1385                    ""
1386                },
1387                if audit.item_projected {
1388                    "observation"
1389                } else {
1390                    ""
1391                },
1392                "",
1393                "",
1394                "",
1395                &request_count,
1396                &summary,
1397                "api-surface",
1398                "",
1399                "",
1400                "",
1401                "",
1402                "",
1403                "",
1404                "",
1405                "",
1406                "",
1407            ],
1408        )?;
1409    }
1410    #[cfg(feature = "secret-exposure-review")]
1411    if let Some(audit) = &document.secret_exposure_review {
1412        let evidence_count = audit.observation_count.to_string();
1413        let summary = audit.wire_json()?;
1414        write_assessment_csv_row(
1415            &mut output,
1416            [
1417                "secret_exposure_review_audit",
1418                audit.schema,
1419                "",
1420                "",
1421                "",
1422                "",
1423                "selected",
1424                "",
1425                "",
1426                "",
1427                "",
1428                "",
1429                "",
1430                "informational",
1431                "observation",
1432                "",
1433                "",
1434                "",
1435                &evidence_count,
1436                &summary,
1437                "passive-secret-exposure",
1438                "",
1439                "",
1440                "",
1441                "",
1442                "",
1443                "",
1444                "",
1445                "",
1446                "",
1447            ],
1448        )?;
1449    }
1450    #[cfg(feature = "tls-observation")]
1451    if let Some(audit) = &document.tls_observation {
1452        let evidence_count = audit.leaf_observation_count.to_string();
1453        let summary = audit.wire_json()?;
1454        write_assessment_csv_row(
1455            &mut output,
1456            [
1457                "tls_observation_audit",
1458                audit.schema,
1459                "",
1460                "",
1461                "",
1462                "",
1463                "selected",
1464                "",
1465                "",
1466                "",
1467                "",
1468                "",
1469                "",
1470                "informational",
1471                "observation",
1472                "",
1473                "",
1474                "",
1475                &evidence_count,
1476                &summary,
1477                "existing-connection-tls-observation",
1478                "",
1479                "",
1480                "",
1481                "",
1482                "",
1483                "",
1484                "",
1485                "",
1486                "",
1487            ],
1488        )?;
1489    }
1490    #[cfg(feature = "tls-negotiation-review")]
1491    if let Some(audit) = &document.tls_negotiation_review {
1492        let evidence_count = audit.coverage.completed_handshake_count.to_string();
1493        let summary = audit.wire_json()?;
1494        write_assessment_csv_row(
1495            &mut output,
1496            [
1497                "tls_negotiation_review_audit",
1498                audit.schema,
1499                "",
1500                "",
1501                "",
1502                "",
1503                audit.coverage.terminal,
1504                "",
1505                "",
1506                "",
1507                TLS_NEGOTIATION_REVIEW_CAPABILITY_ID,
1508                "",
1509                "",
1510                "informational",
1511                "observation",
1512                "",
1513                "",
1514                "",
1515                &evidence_count,
1516                &summary,
1517                "active-tls-negotiation-review",
1518                "",
1519                "",
1520                "",
1521                "",
1522                "",
1523                "",
1524                "",
1525                "",
1526                "",
1527            ],
1528        )?;
1529    }
1530    #[cfg(feature = "jwt-policy-review")]
1531    if let Some(audit) = &document.jwt_policy_review {
1532        let summary = audit.wire_json()?;
1533        #[cfg(feature = "jwt-target-acceptance-review")]
1534        let collection_method = if audit.target_acceptance.is_some() {
1535            "local-jwt-policy-and-bounded-target-review"
1536        } else {
1537            "local-jwt-policy-review"
1538        };
1539        #[cfg(feature = "jwt-target-acceptance-review")]
1540        let evidence_count = audit
1541            .target_acceptance
1542            .as_ref()
1543            .map_or(0, |target| target.accounting.committed_response_count)
1544            .to_string();
1545        #[cfg(not(feature = "jwt-target-acceptance-review"))]
1546        let collection_method = "local-jwt-policy-review";
1547        #[cfg(not(feature = "jwt-target-acceptance-review"))]
1548        let evidence_count = "0".to_owned();
1549        write_assessment_csv_row(
1550            &mut output,
1551            [
1552                "jwt_policy_review_audit",
1553                audit.schema,
1554                "",
1555                "",
1556                "",
1557                "",
1558                "selected",
1559                "",
1560                "",
1561                "",
1562                "",
1563                "",
1564                "",
1565                "informational",
1566                "observation",
1567                "",
1568                "",
1569                "",
1570                &evidence_count,
1571                &summary,
1572                collection_method,
1573                "",
1574                "",
1575                "",
1576                "",
1577                "",
1578                "",
1579                "",
1580                "",
1581                "",
1582            ],
1583        )?;
1584    }
1585    #[cfg(feature = "control-reference-mapping")]
1586    if let Some(audit) = &document.control_reference_mapping {
1587        let evidence_count = audit.coverage.reference_link_count.to_string();
1588        let summary = audit.wire_json()?;
1589        write_assessment_csv_row(
1590            &mut output,
1591            [
1592                "control_reference_mapping_audit",
1593                audit.schema,
1594                "",
1595                "",
1596                "",
1597                "",
1598                "selected",
1599                "",
1600                "",
1601                "",
1602                "",
1603                "",
1604                "",
1605                "informational",
1606                "observation",
1607                "",
1608                "",
1609                "",
1610                &evidence_count,
1611                &summary,
1612                "offline-control-reference-mapping",
1613                "",
1614                "",
1615                "",
1616                "",
1617                "",
1618                "",
1619                "",
1620                "",
1621                "",
1622            ],
1623        )?;
1624    }
1625    #[cfg(feature = "recon-snapshot-import")]
1626    if let Some(audit) = &document.recon_snapshot_import {
1627        let evidence_count = audit.accounting.record_count.to_string();
1628        let summary = audit.wire_json()?;
1629        write_assessment_csv_row(
1630            &mut output,
1631            [
1632                "recon_snapshot_import_audit",
1633                audit.schema,
1634                "",
1635                "",
1636                "",
1637                "",
1638                "selected",
1639                "",
1640                "",
1641                "",
1642                "",
1643                "",
1644                "",
1645                "informational",
1646                "observation",
1647                "",
1648                "",
1649                "",
1650                &evidence_count,
1651                &summary,
1652                "offline-reconnaissance-snapshot-import",
1653                "",
1654                "",
1655                "",
1656                "",
1657                "",
1658                "",
1659                "",
1660                "",
1661                "",
1662            ],
1663        )?;
1664    }
1665    #[cfg(feature = "recon-ct-provider")]
1666    if let Some(audit) = &document.recon_certspotter {
1667        let summary = audit.wire_json()?;
1668        write_assessment_csv_row(
1669            &mut output,
1670            [
1671                "recon_certspotter_audit",
1672                audit.schema,
1673                "",
1674                "",
1675                "",
1676                "",
1677                audit.coverage.completeness,
1678                "",
1679                "",
1680                "",
1681                "",
1682                "",
1683                "",
1684                "",
1685                "",
1686                "",
1687                "",
1688                "",
1689                "",
1690                &summary,
1691                "bounded-certspotter-provider-query",
1692                "",
1693                "",
1694                "",
1695                "",
1696                "",
1697                "",
1698                "",
1699                "",
1700                "",
1701            ],
1702        )?;
1703    }
1704    #[cfg(feature = "wordpress-review")]
1705    if let Some(audit) = &document.wordpress_review {
1706        let evidence_count = audit.evidence_reference_count.to_string();
1707        let summary = audit.wire_json()?;
1708        write_assessment_csv_row(
1709            &mut output,
1710            [
1711                "wordpress_review_audit",
1712                audit.schema,
1713                "",
1714                "",
1715                "",
1716                "",
1717                audit.catalog_status,
1718                "",
1719                "",
1720                "",
1721                audit.capability_id,
1722                "",
1723                "",
1724                if audit.item_projected {
1725                    "informational"
1726                } else {
1727                    ""
1728                },
1729                if audit.item_projected {
1730                    "observation"
1731                } else {
1732                    ""
1733                },
1734                "",
1735                "",
1736                "",
1737                &evidence_count,
1738                &summary,
1739                "wordpress-review",
1740                "",
1741                "",
1742                "",
1743                "",
1744                "",
1745                "",
1746                "",
1747                "",
1748                "",
1749            ],
1750        )?;
1751    }
1752    #[cfg(feature = "wordpress-review")]
1753    if let Some(discovery) = &document.wordpress_discovery {
1754        let evidence_count = discovery
1755            .sources
1756            .iter()
1757            .map(|source| source.evidence_reference_count)
1758            .chain(
1759                discovery
1760                    .supplied_session_pages
1761                    .iter()
1762                    .flat_map(|pages| pages.pages.iter())
1763                    .map(|page| page.evidence_reference_count),
1764            )
1765            .sum::<usize>()
1766            .to_string();
1767        let summary = discovery.wire_json()?;
1768        write_assessment_csv_row(
1769            &mut output,
1770            [
1771                "wordpress_discovery_audit",
1772                discovery.schema,
1773                "",
1774                "",
1775                "",
1776                "",
1777                "selected",
1778                "",
1779                "",
1780                "",
1781                discovery.capability_id,
1782                "",
1783                "",
1784                "informational",
1785                "observation",
1786                "",
1787                "",
1788                "",
1789                &evidence_count,
1790                &summary,
1791                "wordpress-discovery",
1792                "",
1793                "",
1794                "",
1795                "",
1796                "",
1797                "",
1798                "",
1799                "",
1800                "",
1801            ],
1802        )?;
1803    }
1804    #[cfg(feature = "wordpress-review")]
1805    if let Some(fingerprints) = &document.wordpress_asset_fingerprints {
1806        let evidence_count = fingerprints.evidence_reference_count().to_string();
1807        let summary = fingerprints.wire_json()?;
1808        write_assessment_csv_row(
1809            &mut output,
1810            [
1811                "wordpress_asset_fingerprint_audit",
1812                fingerprints.schema,
1813                "",
1814                "",
1815                "",
1816                "",
1817                "selected",
1818                "",
1819                "",
1820                "",
1821                fingerprints.capability_id,
1822                "",
1823                "",
1824                "informational",
1825                "observation",
1826                "",
1827                "",
1828                "",
1829                &evidence_count,
1830                &summary,
1831                "wordpress-asset-fingerprint",
1832                "",
1833                "",
1834                "",
1835                "",
1836                "",
1837                "",
1838                "",
1839                "",
1840                "",
1841            ],
1842        )?;
1843    }
1844    for item in &document.items {
1845        let confidence_ppm = item.confidence_ppm.to_string();
1846        let evidence_count = item.evidence_count.to_string();
1847        let evidence_references = item.evidence_references.join(";");
1848        let control_evidence_references = item.control_evidence_references.join(";");
1849        let candidate_evidence_references = item.candidate_evidence_references.join(";");
1850        write_assessment_csv_row(
1851            &mut output,
1852            [
1853                "item",
1854                "",
1855                "",
1856                "",
1857                "",
1858                "",
1859                "",
1860                "",
1861                "",
1862                item.schema,
1863                item.capability_id,
1864                &item.subject_reference,
1865                item.title,
1866                item.disposition,
1867                item.claim_basis,
1868                item.severity.unwrap_or(""),
1869                &confidence_ppm,
1870                item.fingerprint,
1871                &evidence_count,
1872                item.redacted_summary,
1873                item.category,
1874                item.cwe.unwrap_or(""),
1875                item.remediation.id,
1876                item.remediation.summary,
1877                &evidence_references,
1878                &control_evidence_references,
1879                &candidate_evidence_references,
1880                item.case_reference.as_deref().unwrap_or(""),
1881                item.outcome_reference.as_deref().unwrap_or(""),
1882                item.verification_stage.unwrap_or(""),
1883            ],
1884        )?;
1885    }
1886    Ok(output.finish())
1887}
1888
1889#[cfg(feature = "scanning")]
1890fn write_assessment_csv_row(
1891    output: &mut RenderBuffer,
1892    cells: [&str; ASSESSMENT_CSV_HEADERS.len()],
1893) -> Result<(), ReportError> {
1894    for (index, cell) in cells.into_iter().enumerate() {
1895        if index != 0 {
1896            output.push_char(',')?;
1897        }
1898        write_csv_cell(output, cell)?;
1899    }
1900    output.push_char('\n')
1901}
1902
1903#[cfg(feature = "scanning")]
1904const ACTIONABLE_ITEM_DISPOSITION_ORDER: [&str; 3] = ["confirmed", "needs_review", "informational"];
1905
1906#[cfg(feature = "scanning")]
1907#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1908struct AssessmentDecisionOverview {
1909    informational: usize,
1910    needs_review: usize,
1911    confirmed: usize,
1912}
1913
1914#[cfg(feature = "scanning")]
1915impl AssessmentDecisionOverview {
1916    fn from_document(document: &AssessmentDocument<'_>) -> Result<Self, ReportError> {
1917        let mut overview = Self {
1918            informational: 0,
1919            needs_review: 0,
1920            confirmed: 0,
1921        };
1922        for item in &document.items {
1923            let count = match item.disposition {
1924                "informational" => &mut overview.informational,
1925                "needs_review" => &mut overview.needs_review,
1926                "confirmed" => &mut overview.confirmed,
1927                _ => return Err(ReportError::Serialization),
1928            };
1929            *count = count.checked_add(1).ok_or(ReportError::Serialization)?;
1930        }
1931        let total = overview
1932            .informational
1933            .checked_add(overview.needs_review)
1934            .and_then(|value| value.checked_add(overview.confirmed))
1935            .ok_or(ReportError::Serialization)?;
1936        if total != document.items.len()
1937            || u64::try_from(total).map_err(|_| ReportError::Serialization)? != document.item_count
1938        {
1939            return Err(ReportError::Serialization);
1940        }
1941        Ok(overview)
1942    }
1943
1944    const fn priority_statement(self) -> &'static str {
1945        if self.confirmed != 0 {
1946            "Presentation starts with verifier-bound confirmed items, then unresolved differentials and observations. This is evidence-assurance order, not severity, impact, or remediation priority."
1947        } else if self.needs_review != 0 {
1948            "Presentation starts with unresolved differentials, then observations; no verifier-bound confirmed item was projected. This is evidence-assurance order, not severity, impact, or remediation priority."
1949        } else if self.informational != 0 {
1950            "Presentation contains bounded observations only; no review candidate or verifier-bound confirmed item was projected. This is not a severity, impact, or remediation priority."
1951        } else {
1952            "No assessment item was projected. This does not establish that the application is secure or that coverage was exhaustive."
1953        }
1954    }
1955}
1956
1957#[cfg(feature = "scanning")]
1958fn assessment_item_interpretation(
1959    item: &AssessmentItemDocument<'_>,
1960) -> Result<&'static str, ReportError> {
1961    match (item.disposition, item.claim_basis) {
1962        ("informational", "observation") => Ok(
1963            "Informational: bounded observation evidence was committed; no differential or verifier transition was established.",
1964        ),
1965        ("needs_review", "differential") => Ok(
1966            "Needs review: a typed evidence relationship was committed; human interpretation is required and no verifier transition was established.",
1967        ),
1968        ("confirmed", "verifier_transition") => Ok(
1969            "Confirmed is limited to the cited verifier case and outcome; the label does not widen assessment scope or establish broader impact.",
1970        ),
1971        _ => Err(ReportError::Serialization),
1972    }
1973}
1974
1975#[cfg(feature = "scanning")]
1976fn assessment_item_unestablished(
1977    item: &AssessmentItemDocument<'_>,
1978) -> Result<&'static str, ReportError> {
1979    match item.disposition {
1980        "informational" => Ok(
1981            "This observation alone does not establish a vulnerability, exploitability, impact, or remediation.",
1982        ),
1983        "needs_review" => Ok(
1984            "This review candidate is not a confirmed vulnerability; exploitability, impact, and remediation were not established.",
1985        ),
1986        "confirmed" => Ok(
1987            "Confirmation applies only to the cited verifier transition; broader impact, root cause, and remediation remain unestablished unless separately evidenced.",
1988        ),
1989        _ => Err(ReportError::Serialization),
1990    }
1991}
1992
1993#[cfg(feature = "scanning")]
1994fn assessment_item_verification_guidance(
1995    item: &AssessmentItemDocument<'_>,
1996) -> Result<&'static str, ReportError> {
1997    match item.disposition {
1998        "informational" => Ok(
1999            "Review the cited evidence and capability-owned recommendation. Do not perform active confirmation unless the exact target, context, action, and current authorization are established separately.",
2000        ),
2001        "needs_review" => Ok(
2002            "Establish the exact target and principal/application context from authorized records. Reproduce the control and candidate relationship only under separate current authorization; if that context cannot be established, do not rerun it.",
2003        ),
2004        "confirmed" => Ok(
2005            "Confirmation is bound to the cited case and outcome. After a change, rerun only if the exact target, context, case, and separate current authorization are established; report integrity alone does not verify remediation.",
2006        ),
2007        _ => Err(ReportError::Serialization),
2008    }
2009}
2010
2011#[cfg(feature = "scanning")]
2012fn assessment_item_collection_context(_document: &AssessmentDocument<'_>) -> &'static str {
2013    #[cfg(feature = "supplied-session-review")]
2014    if _document.supplied_session.is_some() {
2015        return "The run includes a supplied-session audit, but this item is not assigned to a principal by the current report contract. Consult the technical audit appendix for operator-declared context, health checkpoints, and coverage.";
2016    }
2017    "No supplied-session audit is present. The current report contract does not assign a separate principal to this item."
2018}
2019
2020#[cfg(feature = "scanning")]
2021fn write_html_actionable_text_field(
2022    output: &mut RenderBuffer,
2023    label: &'static str,
2024    value: &str,
2025) -> Result<(), ReportError> {
2026    output.push_str("<dt>")?;
2027    write_html_text(output, label)?;
2028    output.push_str("</dt><dd>")?;
2029    write_html_text(output, value)?;
2030    output.push_str("</dd>")
2031}
2032
2033#[cfg(feature = "scanning")]
2034fn write_html_actionable_code_field(
2035    output: &mut RenderBuffer,
2036    label: &'static str,
2037    value: &str,
2038) -> Result<(), ReportError> {
2039    output.push_str("<dt>")?;
2040    write_html_text(output, label)?;
2041    output.push_str("</dt><dd><code>")?;
2042    write_html_text(output, value)?;
2043    output.push_str("</code></dd>")
2044}
2045
2046#[cfg(feature = "scanning")]
2047fn write_assessment_decision_overview_html(
2048    output: &mut RenderBuffer,
2049    document: &AssessmentDocument<'_>,
2050) -> Result<(), ReportError> {
2051    let overview = AssessmentDecisionOverview::from_document(document)?;
2052    output.push_str(
2053        "<section id=\"decision-overview\"><h2>Decision overview</h2>\
2054<p>These counts describe typed assessment items, not a count of confirmed vulnerabilities. Unassigned or unknown severity is not low or zero severity.</p>\
2055<div class=\"decision-grid\">",
2056    )?;
2057    for (label, count) in [
2058        ("Verifier-bound confirmed items", overview.confirmed),
2059        ("Needs-review candidates", overview.needs_review),
2060        ("Informational observations", overview.informational),
2061    ] {
2062        output.push_str("<div class=\"decision-card\"><strong>")?;
2063        output.push_fmt(format_args!("{count}"))?;
2064        output.push_str("</strong><span>")?;
2065        write_html_text(output, label)?;
2066        output.push_str("</span></div>")?;
2067    }
2068    output.push_str("</div><p><strong>Next decision:</strong> ")?;
2069    write_html_text(output, overview.priority_statement())?;
2070    output.push_str("</p></section>")
2071}
2072
2073#[cfg(feature = "scanning")]
2074fn write_assessment_actionable_items_html(
2075    output: &mut RenderBuffer,
2076    document: &AssessmentDocument<'_>,
2077) -> Result<(), ReportError> {
2078    output.push_str("<section id=\"actionable-items\"><h2>Actionable items</h2>")?;
2079    if document.items.is_empty() {
2080        output.push_str(
2081            "<p class=\"empty\">No assessment items were projected. This is not evidence that the application is secure or that assessment coverage was complete.</p></section>",
2082        )?;
2083        return Ok(());
2084    }
2085    let collection_context = assessment_item_collection_context(document);
2086    let mut index = 0usize;
2087    for disposition in ACTIONABLE_ITEM_DISPOSITION_ORDER {
2088        for item in document
2089            .items
2090            .iter()
2091            .filter(|item| item.disposition == disposition)
2092        {
2093            index = index.checked_add(1).ok_or(ReportError::Serialization)?;
2094            output.push_fmt(format_args!(
2095                "<article class=\"item\"><h3>Actionable item {index}: "
2096            ))?;
2097            write_html_text(output, item.title)?;
2098            output.push_str("</h3><p class=\"disposition\"><span>Disposition: </span><code>")?;
2099            write_html_text(output, item.disposition)?;
2100            output.push_str("</code></p><dl>")?;
2101            write_html_actionable_text_field(output, "What was observed", item.redacted_summary)?;
2102            write_html_actionable_code_field(
2103                output,
2104                "Opaque assessment subject reference (not proof of affectedness or location)",
2105                &item.subject_reference,
2106            )?;
2107            write_html_actionable_code_field(
2108                output,
2109                "Assessment target kind (resource location withheld)",
2110                item.presentation_target,
2111            )?;
2112            write_html_actionable_text_field(
2113                output,
2114                "Collection and principal context",
2115                collection_context,
2116            )?;
2117            write_html_actionable_text_field(
2118                output,
2119                "Interpretation",
2120                assessment_item_interpretation(item)?,
2121            )?;
2122            write_html_actionable_text_field(
2123                output,
2124                "What was not established",
2125                assessment_item_unestablished(item)?,
2126            )?;
2127            write_html_actionable_text_field(
2128                output,
2129                "Recommended action (not a verified fix)",
2130                item.remediation.summary,
2131            )?;
2132            write_html_actionable_text_field(
2133                output,
2134                "Safe verification guidance",
2135                assessment_item_verification_guidance(item)?,
2136            )?;
2137            output.push_str("<dt>Severity</dt><dd>")?;
2138            write_html_optional_assessment_text(output, item.severity, "not assigned")?;
2139            if item.severity.is_none() {
2140                output.push_str(" <span>(unassigned does not mean low or zero)</span>")?;
2141            }
2142            output.push_str("</dd><dt>Confidence</dt><dd><code>")?;
2143            output.push_fmt(format_args!("{} ppm", item.confidence_ppm))?;
2144            output.push_str(
2145                "</code> <span>(evidence-specific; not CVSS, impact, or exploit probability)</span></dd>",
2146            )?;
2147            write_html_actionable_code_field(output, "Claim basis", item.claim_basis)?;
2148            write_html_actionable_code_field(output, "Item schema", item.schema)?;
2149            write_html_actionable_code_field(output, "Capability", item.capability_id)?;
2150            write_html_actionable_code_field(output, "Category", item.category)?;
2151            output.push_str("<dt>CWE</dt><dd>")?;
2152            write_html_optional_assessment_text(output, item.cwe, "not applicable")?;
2153            output.push_str("</dd>")?;
2154            write_html_actionable_code_field(output, "Item fingerprint", item.fingerprint)?;
2155            write_html_actionable_code_field(
2156                output,
2157                "Evidence reference count",
2158                &item.evidence_count.to_string(),
2159            )?;
2160            write_html_actionable_code_field(output, "Recommendation ID", item.remediation.id)?;
2161            write_html_actionable_code_field(
2162                output,
2163                "Direct evidence references",
2164                &assessment_reference_list(&item.evidence_references),
2165            )?;
2166            write_html_actionable_code_field(
2167                output,
2168                "Control evidence references",
2169                &assessment_reference_list(&item.control_evidence_references),
2170            )?;
2171            write_html_actionable_code_field(
2172                output,
2173                "Candidate evidence references",
2174                &assessment_reference_list(&item.candidate_evidence_references),
2175            )?;
2176            write_html_actionable_code_field(
2177                output,
2178                "Verifier case reference",
2179                item.case_reference.as_deref().unwrap_or("not applicable"),
2180            )?;
2181            write_html_actionable_code_field(
2182                output,
2183                "Verifier outcome reference",
2184                item.outcome_reference
2185                    .as_deref()
2186                    .unwrap_or("not applicable"),
2187            )?;
2188            write_html_actionable_code_field(
2189                output,
2190                "Verification stage",
2191                item.verification_stage.unwrap_or("not applicable"),
2192            )?;
2193            output.push_str("</dl></article>")?;
2194        }
2195    }
2196    if index != document.items.len() {
2197        return Err(ReportError::Serialization);
2198    }
2199    output.push_str("</section>")
2200}
2201
2202#[cfg(feature = "scanning")]
2203fn render_assessment_html(
2204    document: &AssessmentDocument<'_>,
2205    limit: usize,
2206) -> Result<String, ReportError> {
2207    let mut output = RenderBuffer::new(limit);
2208    output.push_str(
2209        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
2210<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; base-uri 'none'; form-action 'none'\">\
2211<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
2212<title>Termivar assessment report</title><style>\
2213:root{color-scheme:light dark}body{font:14px/1.5 system-ui,sans-serif;margin:2rem;max-width:90rem}\
2214h1,h2,h3{line-height:1.2}.meta{display:grid;grid-template-columns:max-content 1fr;gap:.3rem 1rem}\
2215.item,.wp-detail{border:1px solid currentColor;padding:1rem;margin-block:1rem}.item dl,.wp-detail dl{display:grid;grid-template-columns:max-content 1fr;gap:.3rem 1rem}\
2216.disposition{border:2px solid currentColor;display:inline-block;font-weight:700;padding:.15rem .4rem}\
2217.decision-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:.75rem;margin-block:1rem}\
2218.decision-card{border:1px solid currentColor;padding:.75rem}.decision-card strong{display:block;font-size:1.4rem}\
2219.wp-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));gap:.75rem;margin-block:1rem}\
2220.wp-card{border:1px solid currentColor;padding:.75rem}.wp-card strong{display:block;font-size:1.4rem}\
2221.wp-note{border-inline-start:.3rem solid currentColor;padding:.5rem .75rem}.wp-detail summary{cursor:pointer;font-weight:700}\
2222code,pre{overflow-wrap:anywhere}pre{white-space:pre-wrap}.empty{font-style:italic}@media print{.wp-detail>*{display:block!important}}</style></head><body><main>\
2223<h1>Termivar assessment report</h1><p><strong>Lifecycle status:</strong> completed typed assessment.</p>",
2224    )?;
2225    write_assessment_decision_overview_html(&mut output, document)?;
2226    write_assessment_actionable_items_html(&mut output, document)?;
2227    output.push_str(
2228        "<div id=\"technical-audit-appendix\"><h2>Technical audit appendix</h2>\
2229<p>Available report metadata and selected capability/source audit details follow, including their local accounting when present. These records preserve stated context and limitations; report integrity does not establish target truth or remediation.</p>\
2230<h3>Report metadata</h3><dl class=\"meta\">",
2231    )?;
2232    for (label, value) in document.metadata() {
2233        output.push_str("<dt>")?;
2234        write_html_text(&mut output, label)?;
2235        output.push_str("</dt><dd><code>")?;
2236        write_html_text(&mut output, &value)?;
2237        output.push_str("</code></dd>")?;
2238    }
2239    output.push_str("</dl>")?;
2240    #[cfg(feature = "supplied-session-review")]
2241    if let Some(audit) = &document.supplied_session {
2242        output
2243            .push_str("<section><h2>Supplied session assessment audit</h2><dl class=\"meta\">")?;
2244        for (label, value) in audit.metadata() {
2245            output.push_str("<dt>")?;
2246            write_html_text(&mut output, label)?;
2247            output.push_str("</dt><dd><code>")?;
2248            write_html_text(&mut output, &value)?;
2249            output.push_str("</code></dd>")?;
2250        }
2251        output.push_str("</dl><h3>Health checkpoints</h3><ol>")?;
2252        for checkpoint in &audit.checkpoints {
2253            output.push_str("<li><code>")?;
2254            write_html_text(
2255                &mut output,
2256                &checkpoint
2257                    .human_summary(audit.schema == SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA),
2258            )?;
2259            output.push_str("</code></li>")?;
2260        }
2261        output.push_str("</ol>")?;
2262        if let Some(health) = audit
2263            .oauth_refresh
2264            .as_ref()
2265            .and_then(|refresh| refresh.health())
2266        {
2267            output.push_str("<h3>OAuth renewal health checkpoint</h3><ol><li><code>")?;
2268            let health = AssessmentSuppliedSessionCheckpointDocument::from_checkpoint(health, true);
2269            write_html_text(&mut output, &health.human_summary(true))?;
2270            output.push_str("</code></li></ol>")?;
2271        }
2272        output.push_str("<h3>Protected resources</h3><ol>")?;
2273        for resource in &audit.resources {
2274            let status = resource
2275                .status
2276                .map_or_else(|| "not_available".to_owned(), |status| status.to_string());
2277            let evidence_reference = resource
2278                .evidence_reference
2279                .as_deref()
2280                .unwrap_or("not_available");
2281            output.push_str("<li><code>")?;
2282            write_html_text(
2283                &mut output,
2284                &format!(
2285                    "sequence={};resource_reference={};evidence_reference={};outcome={};status={};response_bytes={};epoch={}",
2286                    resource.sequence,
2287                    resource.resource_reference,
2288                    evidence_reference,
2289                    resource.outcome,
2290                    status,
2291                    resource.response_bytes,
2292                    resource.epoch,
2293                ),
2294            )?;
2295            output.push_str("</code></li>")?;
2296        }
2297        output.push_str("</ol></section>")?;
2298    }
2299    #[cfg(feature = "authorization-review")]
2300    if let Some(audit) = &document.authorization_review {
2301        output
2302            .push_str("<section><h2>Resource authorization review audit</h2><dl class=\"meta\">")?;
2303        for (label, value) in audit.metadata() {
2304            output.push_str("<dt>")?;
2305            write_html_text(&mut output, label)?;
2306            output.push_str("</dt><dd><code>")?;
2307            write_html_text(&mut output, &value)?;
2308            output.push_str("</code></dd>")?;
2309        }
2310        output.push_str("</dl></section>")?;
2311    }
2312    #[cfg(feature = "websocket-review")]
2313    if let Some(audit) = &document.websocket_review {
2314        output.push_str("<section><h2>WebSocket protocol review audit</h2>")?;
2315        if audit.has_supplied_session_context() {
2316            output.push_str(
2317                "<p class=\"wp-note\">This audit separates bounded protocol-exchange completion from supplied-session health qualification. Healthy startup and terminal checkpoints qualify only the declared observation context; they do not establish continuous authentication, authenticated identity, authorization, vulnerability, impact, or remediation. It retains no credential, payload, raw URL, path, query, subprotocol token, body-derived mismatch reference, or backend error. Physical continuation-frame counts are unavailable. Transport bytes are raw socket bytes including the HTTP Upgrade and WebSocket framing; for WSS they are encrypted TLS records, not application bytes.</p><dl class=\"meta\">",
2318            )?;
2319        } else {
2320            output.push_str(
2321                "<p class=\"wp-note\">This audit describes one bounded, anonymous, operator-configured HTTP/1 Upgrade exchange. It is not a finding and provides no remediation inference. It retains no payload, raw URL, path, query, subprotocol token, body-derived mismatch reference, or backend error. Physical continuation-frame counts are unavailable. Transport bytes are raw socket bytes including the HTTP Upgrade and WebSocket framing; for WSS they are encrypted TLS records, not application bytes.</p><dl class=\"meta\">",
2322            )?;
2323        }
2324        for (label, value) in audit.metadata() {
2325            output.push_str("<dt>")?;
2326            write_html_text(&mut output, label)?;
2327            output.push_str("</dt><dd><code>")?;
2328            write_html_text(&mut output, &value)?;
2329            output.push_str("</code></dd>")?;
2330        }
2331        output.push_str("</dl><h3>Configured exchange audit</h3><ol>")?;
2332        for message in &audit.messages {
2333            output.push_str("<li><code>")?;
2334            write_html_text(
2335                &mut output,
2336                &format!(
2337                    "message_reference={};expected_response_reference={};outbound_length={};inbound_length={};status={}",
2338                    message.message_reference,
2339                    message.expected_response_reference,
2340                    message.outbound_length,
2341                    message
2342                        .inbound_length
2343                        .map_or_else(|| "not_available".to_owned(), |length| length.to_string()),
2344                    message.status,
2345                ),
2346            )?;
2347            output.push_str("</code></li>")?;
2348        }
2349        output.push_str("</ol><h3>Interpretation limits</h3><ul>")?;
2350        for limit in &audit.claim_limits {
2351            output.push_str("<li><code>")?;
2352            write_html_text(&mut output, limit)?;
2353            output.push_str("</code></li>")?;
2354        }
2355        output.push_str("</ul></section>")?;
2356    }
2357    #[cfg(feature = "xml-external-entity-review")]
2358    if let Some(audit) = &document.xml_external_entity_review {
2359        output.push_str(
2360            "<section><h2>Controlled XML external-entity review audit</h2>\
2361<p class=\"wp-note\">This audit records a bounded callback-control review without retaining endpoint URLs or paths, provider origins, callback values, XML bodies, system literals, tokens, response bodies, or raw errors. Repeated candidate and replay interactions remain review-level evidence: they do not confirm XXE, identify a parser, prove file read, data exfiltration, internal-network access, semantic effect, or impact.</p><dl class=\"meta\">",
2362        )?;
2363        for (label, value) in audit.metadata() {
2364            output.push_str("<dt>")?;
2365            write_html_text(&mut output, label)?;
2366            output.push_str("</dt><dd><code>")?;
2367            write_html_text(&mut output, &value)?;
2368            output.push_str("</code></dd>")?;
2369        }
2370        output.push_str("</dl><h3>Claim limits</h3><ul>")?;
2371        for limit in &audit.claim_limits {
2372            output.push_str("<li><code>")?;
2373            write_html_text(&mut output, limit)?;
2374            output.push_str("</code></li>")?;
2375        }
2376        output.push_str("</ul></section>")?;
2377    }
2378    #[cfg(feature = "template-evaluation-review")]
2379    if let Some(audit) = &document.template_evaluation_review {
2380        output.push_str(
2381            "<section><h2>Template-evaluation review audit</h2>\
2382<p class=\"wp-note\">This audit records bounded, benign primary/replay differential semantics without retaining expressions, query values, URLs, response bodies, template-engine errors, or raw transport errors. Candidate-specific evaluation does not establish a template-engine identity, operating-system execution, file access, outbound interaction, vulnerability confirmation, or impact.</p><dl class=\"meta\">",
2383        )?;
2384        for (label, value) in audit.metadata() {
2385            output.push_str("<dt>")?;
2386            write_html_text(&mut output, label)?;
2387            output.push_str("</dt><dd><code>")?;
2388            write_html_text(&mut output, &value)?;
2389            output.push_str("</code></dd>")?;
2390        }
2391        output.push_str("</dl><h3>Claim limits</h3><ul>")?;
2392        for limit in &audit.claim_limits {
2393            output.push_str("<li><code>")?;
2394            write_html_text(&mut output, limit)?;
2395            output.push_str("</code></li>")?;
2396        }
2397        output.push_str("</ul></section>")?;
2398    }
2399    #[cfg(feature = "openapi-review")]
2400    if let Some(audit) = &document.openapi_review {
2401        output.push_str("<section><h2>OpenAPI review audit</h2><dl class=\"meta\">")?;
2402        for (label, value) in audit.metadata() {
2403            output.push_str("<dt>")?;
2404            write_html_text(&mut output, label)?;
2405            output.push_str("</dt><dd><code>")?;
2406            write_html_text(&mut output, &value)?;
2407            output.push_str("</code></dd>")?;
2408        }
2409        output.push_str("</dl></section>")?;
2410    }
2411    #[cfg(feature = "rest-review")]
2412    if let Some(audit) = &document.rest_review {
2413        output.push_str("<section><h2>REST read-only review audit</h2><dl class=\"meta\">")?;
2414        for (label, value) in audit.metadata() {
2415            output.push_str("<dt>")?;
2416            write_html_text(&mut output, label)?;
2417            output.push_str("</dt><dd><code>")?;
2418            write_html_text(&mut output, &value)?;
2419            output.push_str("</code></dd>")?;
2420        }
2421        output.push_str("</dl></section>")?;
2422    }
2423    #[cfg(feature = "secret-exposure-review")]
2424    if let Some(audit) = &document.secret_exposure_review {
2425        output.push_str(
2426            "<section><h2>Passive response secret-exposure review audit</h2>\
2427<p class=\"wp-note\">Detector observations are redacted response-content classifications. They do not establish source authenticity, secret validity, provider acceptance, exploitability, or impact.</p><dl class=\"meta\">",
2428        )?;
2429        for (label, value) in audit.metadata() {
2430            output.push_str("<dt>")?;
2431            write_html_text(&mut output, label)?;
2432            output.push_str("</dt><dd><code>")?;
2433            write_html_text(&mut output, &value)?;
2434            output.push_str("</code></dd>")?;
2435        }
2436        output.push_str("</dl><h3>Response outcomes</h3><ul>")?;
2437        for outcome in &audit.outcomes {
2438            output.push_str("<li><code>")?;
2439            write_html_text(
2440                &mut output,
2441                &format!("outcome={};count={}", outcome.outcome, outcome.count),
2442            )?;
2443            output.push_str("</code></li>")?;
2444        }
2445        output.push_str("</ul><h3>Redacted observations</h3><ul>")?;
2446        for observation in &audit.observations {
2447            output.push_str("<li><code>")?;
2448            write_html_text(
2449                &mut output,
2450                &format!(
2451                    "detector_class={};capability_id={};occurrence_count={};evidence_reference={}",
2452                    observation.detector_class,
2453                    observation.capability_id,
2454                    observation.occurrence_count,
2455                    observation.evidence_references[0],
2456                ),
2457            )?;
2458            output.push_str("</code></li>")?;
2459        }
2460        output.push_str("</ul></section>")?;
2461    }
2462    #[cfg(feature = "tls-observation")]
2463    if let Some(audit) = &document.tls_observation {
2464        output.push_str(
2465            "<section><h2>Existing-connection TLS observation audit</h2>\
2466<p class=\"wp-note\">This passive view describes leaf certificates exposed by successful existing HTTPS responses under the assessment's exact-origin authority. It does not attribute a certificate to a request purpose or principal. Validity status is relative to the reported local system-clock instant, whose accuracy was not independently verified. One successful connection does not enumerate server protocol or cipher support. Negotiated protocol, ALPN, cipher suite, full chain, connection reuse, session resumption, and handshake kind are unavailable from the current backend; revocation was not checked.</p><dl class=\"meta\">",
2467        )?;
2468        for (label, value) in audit.metadata() {
2469            output.push_str("<dt>")?;
2470            write_html_text(&mut output, label)?;
2471            output.push_str("</dt><dd><code>")?;
2472            write_html_text(&mut output, &value)?;
2473            output.push_str("</code></dd>")?;
2474        }
2475        output.push_str("</dl><h3>Leaf certificate observations</h3><ul>")?;
2476        for observation in &audit.observations {
2477            output.push_str("<li><code>")?;
2478            write_html_text(
2479                &mut output,
2480                &format!(
2481                    "sha256={};byte_length={};not_before={};not_after={};observed_at={};dns_sans={};ip_sans={};other_sans={};san_count_truncated={};time_status={};response_occurrences={};standard_transport_validation_succeeded={}",
2482                    observation.leaf_certificate_sha256,
2483                    observation.leaf_certificate_byte_length,
2484                    observation.not_before_epoch_seconds,
2485                    observation.not_after_epoch_seconds,
2486                    observation.observed_at_epoch_seconds,
2487                    observation.dns_san_count,
2488                    observation.ip_san_count,
2489                    observation.other_san_count,
2490                    observation.san_count_truncated,
2491                    observation.certificate_time_status,
2492                    observation.response_occurrence_count,
2493                    observation.standard_transport_validation_succeeded,
2494                ),
2495            )?;
2496            output.push_str("</code></li>")?;
2497        }
2498        output.push_str("</ul><p class=\"wp-note\">Certificate names and DER bytes are not retained in this report. A digest identifies observed bytes; it is not source authentication. Active negotiation, chain enumeration, revocation retrieval, exploit execution, and impact validation were not performed.</p></section>")?;
2499    }
2500    #[cfg(feature = "tls-negotiation-review")]
2501    if let Some(audit) = &document.tls_negotiation_review {
2502        output.push_str(
2503            "<section><h2>Active TLS negotiation review audit</h2>\
2504<p class=\"wp-note\">This explicitly selected, finite matrix opened at most two sequential credential-free TLS connections to the exact authorized HTTPS host, SNI, and port. It sent no HTTP or application data. Negotiation establishes compatibility only for a successful offered profile; failure does not establish server non-support, and the three legacy rows were not tested because the client backend does not implement them.</p><dl class=\"meta\">",
2505        )?;
2506        for (label, value) in audit.metadata() {
2507            output.push_str("<dt>")?;
2508            write_html_text(&mut output, label)?;
2509            output.push_str("</dt><dd><code>")?;
2510            write_html_text(&mut output, &value)?;
2511            output.push_str("</code></dd>")?;
2512        }
2513        output.push_str("</dl><h3>Fixed negotiation matrix</h3><ul>")?;
2514        for cell in &audit.capability_observations {
2515            output.push_str("<li><code>")?;
2516            write_html_text(
2517                &mut output,
2518                &format!(
2519                    "cell={};offer={};backend={};dispatch={};outcome={};support={};transport_validation={};negotiated_protocol={};negotiated_cipher_suite={};ingress_tls_bytes={};egress_tls_bytes={}",
2520                    cell.cell_id,
2521                    cell.offered_protocol,
2522                    cell.client_backend,
2523                    cell.dispatch_status,
2524                    cell.outcome,
2525                    cell.support,
2526                    cell.transport_validation,
2527                    cell.negotiated_protocol.unwrap_or("none"),
2528                    cell.negotiated_cipher_suite.unwrap_or("none"),
2529                    cell.accounted_ingress_tls_bytes,
2530                    cell.observed_egress_tls_bytes,
2531                ),
2532            )?;
2533            output.push_str("</code></li>")?;
2534        }
2535        output.push_str("</ul><h3>Claim limits</h3><ul>")?;
2536        for limit in &audit.claim_limits {
2537            output.push_str("<li><code>")?;
2538            write_html_text(&mut output, limit)?;
2539            output.push_str("</code></li>")?;
2540        }
2541        output.push_str("</ul><p class=\"wp-note\">The configured trust validation is not source authentication. Revocation, certificate-transparency, AIA, CRL, and OCSP retrieval, vulnerability confirmation, exploit execution, impact validation, and remediation verification were not performed.</p></section>")?;
2542    }
2543    #[cfg(feature = "jwt-policy-review")]
2544    if let Some(audit) = &document.jwt_policy_review {
2545        #[cfg(feature = "jwt-target-acceptance-review")]
2546        if audit.target_acceptance.is_some() {
2547            output.push_str("<section><h2>JWT policy and target-control review audit</h2><p class=\"wp-note\">Parsing, local policy consistency, local signature verification, and bounded target behavior are separate states. This value-free audit retains no token, claim value, key material, selected marker name, or raw resource URL. Target controls remain review-level and establish neither a general authentication bypass nor authorization impact.</p>")?;
2548        } else {
2549            output.push_str("<section><h2>Local JWT policy review audit</h2><p class=\"wp-note\">Parsing, local policy consistency, signature verification against the explicitly supplied local public key, and target acceptance are separate states. This value-free audit retains no token, claim value, or key material. It performed no target request, remote-key retrieval, or token forwarding; target acceptance and source authentication are not established.</p>")?;
2550        }
2551        #[cfg(not(feature = "jwt-target-acceptance-review"))]
2552        output.push_str("<section><h2>Local JWT policy review audit</h2><p class=\"wp-note\">Parsing, local policy consistency, signature verification against the explicitly supplied local public key, and target acceptance are separate states. This value-free audit retains no token, claim value, or key material. It performed no target request, remote-key retrieval, or token forwarding; target acceptance and source authentication are not established.</p>")?;
2553        output.push_str("<dl class=\"meta\">")?;
2554        for (label, value) in audit.metadata() {
2555            output.push_str("<dt>")?;
2556            write_html_text(&mut output, label)?;
2557            output.push_str("</dt><dd><code>")?;
2558            write_html_text(&mut output, &value)?;
2559            output.push_str("</code></dd>")?;
2560        }
2561        output.push_str("</dl><h3>Value-free policy violations</h3><ul>")?;
2562        if audit.policy_violations.is_empty() {
2563            output.push_str("<li><code>none</code></li>")?;
2564        }
2565        for violation in &audit.policy_violations {
2566            output.push_str("<li><code>")?;
2567            let row = violation.ordinal.map_or_else(
2568                || format!("kind={}", violation.kind),
2569                |ordinal| format!("kind={};ordinal={ordinal}", violation.kind),
2570            );
2571            write_html_text(&mut output, &row)?;
2572            output.push_str("</code></li>")?;
2573        }
2574        output.push_str("</ul>")?;
2575        #[cfg(feature = "jwt-target-acceptance-review")]
2576        if let Some(target) = &audit.target_acceptance {
2577            output.push_str("<h3>Target acceptance controls</h3><ul>")?;
2578            if target.legs.is_empty() {
2579                output.push_str("<li><code>no target leg dispatched</code></li>")?;
2580            }
2581            for leg in &target.legs {
2582                output.push_str("<li><code>")?;
2583                write_html_text(
2584                    &mut output,
2585                    &format!(
2586                        "role={};stage={};activity={};dispatch={};commit={};response={};marker={};retained_bytes={};accounted_transport_bytes={}",
2587                        leg.role,
2588                        leg.stage,
2589                        leg.activity,
2590                        leg.dispatch_status,
2591                        leg.commit_status,
2592                        leg.response_status,
2593                        leg.marker_status,
2594                        leg.retained_response_bytes,
2595                        leg.accounted_transport_response_bytes,
2596                    ),
2597                )?;
2598                output.push_str("</code></li>")?;
2599            }
2600            output.push_str("</ul><p class=\"wp-note\">")?;
2601            write_html_text(&mut output, &target.interpretation_limits.join(" "))?;
2602            output.push_str("</p>")?;
2603        }
2604        output.push_str("</section>")?;
2605    }
2606    #[cfg(feature = "control-reference-mapping")]
2607    if let Some(audit) = &document.control_reference_mapping {
2608        output.push_str(
2609            "<section><h2>Versioned control-reference mapping audit</h2>\
2610<p class=\"wp-note\">This deterministic offline mapping relates eligible assessment evidence to a finite, versioned reference catalogue. It does not assess a control, determine compliance or legal noncompliance, certify an organization, authenticate a source, or turn the absence of a finding into evidence that a control is fulfilled.</p><dl class=\"meta\">",
2611        )?;
2612        for (label, value) in audit.metadata() {
2613            output.push_str("<dt>")?;
2614            write_html_text(&mut output, label)?;
2615            output.push_str("</dt><dd><code>")?;
2616            write_html_text(&mut output, &value)?;
2617            output.push_str("</code></dd>")?;
2618        }
2619        output.push_str("</dl><h3>Reference sources</h3><ul>")?;
2620        for source in &audit.sources {
2621            output.push_str("<li><code>")?;
2622            write_html_text(
2623                &mut output,
2624                &format!(
2625                    "source_id={};framework_id={};edition={};revision_or_amendment={};authority_kind={};mapping_availability={};rights_basis={};source_verified_at={};source_url={}",
2626                    source.source_id,
2627                    source.framework_id,
2628                    source.edition,
2629                    source.revision_or_amendment.unwrap_or("not_applicable"),
2630                    source.authority_kind,
2631                    source.mapping_availability,
2632                    source.rights_basis,
2633                    source.source_verified_at,
2634                    source.source_url,
2635                ),
2636            )?;
2637            output.push_str("</code></li>")?;
2638        }
2639        output.push_str("</ul><h3>Evidence-to-reference relationships</h3><ul>")?;
2640        if audit.relationships.is_empty() {
2641            output.push_str("<li><code>no exact mapping in the selected catalogue</code></li>")?;
2642        }
2643        for relationship in &audit.relationships {
2644            let cwe = relationship.cwe.as_deref().unwrap_or("not_available");
2645            for reference in &relationship.references {
2646                output.push_str("<li><code>")?;
2647                write_html_text(
2648                    &mut output,
2649                    &format!(
2650                        "item_fingerprint={};capability_id={};cwe={};item_basis={};rule_id={};source_id={};edition={};control_reference={};control_title={};mapping_basis={};applicability={};applicability_condition={};assurance={};rationale={}",
2651                        relationship.item_fingerprint,
2652                        relationship.capability_id,
2653                        cwe,
2654                        relationship.item_basis,
2655                        reference.rule_id,
2656                        reference.source_id,
2657                        reference.edition,
2658                        reference.control_reference,
2659                        reference.control_title.unwrap_or("not_available"),
2660                        reference.mapping_basis,
2661                        reference.applicability,
2662                        reference.applicability_condition,
2663                        reference.assurance,
2664                        reference.original_mapping_rationale,
2665                    ),
2666                )?;
2667                output.push_str("</code></li>")?;
2668            }
2669        }
2670        output.push_str("</ul><p class=\"wp-note\">Framework identifiers and short titles are source-qualified references. Rights-deferred sources are bibliographic context only, and KVKK material is relevant technical context rather than legal advice or a compliance conclusion. No target or provider request was performed for this mapping.</p></section>")?;
2671    }
2672    #[cfg(feature = "recon-snapshot-import")]
2673    if let Some(audit) = &document.recon_snapshot_import {
2674        output.push_str("<section><h2>Offline reconnaissance snapshot import</h2><p class=\"wp-note\">Imported names, addresses, product hints, and reputation labels are source-qualified hypotheses only. The input digest identifies supplied bytes; it does not authenticate a source, establish asset ownership, authorize scanning, prove current reachability, or create a finding.</p><dl class=\"meta\">")?;
2675        for (label, value) in audit.metadata() {
2676            output.push_str("<dt>")?;
2677            write_html_text(&mut output, label)?;
2678            output.push_str("</dt><dd><code>")?;
2679            write_html_text(&mut output, &value)?;
2680            output.push_str("</code></dd>")?;
2681        }
2682        output.push_str("</dl><h3>Declared sources</h3><ul>")?;
2683        for source in &audit.sources {
2684            output.push_str("<li><code>")?;
2685            write_html_text(&mut output, &source.presentation())?;
2686            output.push_str("</code></li>")?;
2687        }
2688        output.push_str("</ul><h3>Source-qualified hypotheses</h3><ul>")?;
2689        for record in &audit.records {
2690            output.push_str("<li><code>")?;
2691            write_html_text(&mut output, &record.presentation())?;
2692            output.push_str("</code></li>")?;
2693        }
2694        output.push_str("</ul><p class=\"wp-note\">This import performed no target request, provider request, archive processing, or decompression. It did not add assessment subjects or items.</p></section>")?;
2695    }
2696    #[cfg(feature = "recon-ct-provider")]
2697    if let Some(audit) = &document.recon_certspotter {
2698        output.push_str("<section><h2>Cert Spotter reconnaissance provider audit</h2><p class=\"wp-note\">Returned certificate-transparency names are source-qualified hypotheses only. They are not findings and do not establish scan authority, source authentication, ownership, or currentness.</p><dl class=\"meta\">")?;
2699        for (label, value) in audit.metadata() {
2700            output.push_str("<dt>")?;
2701            write_html_text(&mut output, label)?;
2702            output.push_str("</dt><dd><code>")?;
2703            write_html_text(&mut output, &value)?;
2704            output.push_str("</code></dd>")?;
2705        }
2706        output.push_str("</dl><h3>Source-qualified hypotheses</h3><ul>")?;
2707        if audit.hypotheses.is_empty() {
2708            output.push_str("<li><code>none retained</code></li>")?;
2709        }
2710        for hypothesis in &audit.hypotheses {
2711            output.push_str("<li><code>")?;
2712            write_html_text(&mut output, &hypothesis.presentation())?;
2713            output.push_str("</code></li>")?;
2714        }
2715        output.push_str("</ul><p class=\"wp-note\">The fixed claim limits apply to the collection and every displayed hypothesis. Provider transport and bounded collection do not authenticate the provider as an asset owner or authorize contact with any returned name.</p></section>")?;
2716    }
2717    #[cfg(feature = "wordpress-review")]
2718    if let Some(audit) = &document.wordpress_review {
2719        output.push_str("<section><h2>WordPress evidence review audit</h2><dl class=\"meta\">")?;
2720        for (label, value) in audit.metadata() {
2721            output.push_str("<dt>")?;
2722            write_html_text(&mut output, label)?;
2723            output.push_str("</dt><dd><code>")?;
2724            write_html_text(&mut output, &value)?;
2725            output.push_str("</code></dd>")?;
2726        }
2727        output.push_str("</dl>")?;
2728        write_wordpress_presentation(&mut WordPressPresentationEmitter::Html(&mut output), audit)?;
2729        write_html_wordpress_external_attribution(&mut output, audit)?;
2730        output.push_str("</section>")?;
2731    }
2732    #[cfg(feature = "wordpress-review")]
2733    if let Some(fingerprints) = &document.wordpress_asset_fingerprints {
2734        output.push_str(
2735            "<section><h2>WordPress observed asset fingerprint candidates</h2><dl class=\"meta\">",
2736        )?;
2737        for (label, value) in fingerprints.metadata() {
2738            output.push_str("<dt>")?;
2739            write_html_text(&mut output, label)?;
2740            output.push_str("</dt><dd><code>")?;
2741            write_html_text(&mut output, &value)?;
2742            output.push_str("</code></dd>")?;
2743        }
2744        output.push_str("</dl>")?;
2745        write_wordpress_asset_fingerprint_presentation(
2746            &mut WordPressPresentationEmitter::Html(&mut output),
2747            fingerprints,
2748        )?;
2749        output.push_str("</section>")?;
2750    }
2751    #[cfg(feature = "wordpress-review")]
2752    if let Some(discovery) = &document.wordpress_discovery {
2753        output.push_str(
2754            "<section><h2>WordPress public metadata discovery audit</h2><dl class=\"meta\">",
2755        )?;
2756        for (label, value) in discovery.metadata() {
2757            output.push_str("<dt>")?;
2758            write_html_text(&mut output, label)?;
2759            output.push_str("</dt><dd><code>")?;
2760            write_html_text(&mut output, &value)?;
2761            output.push_str("</code></dd>")?;
2762        }
2763        output.push_str("</dl>")?;
2764        write_wordpress_discovery_presentation(
2765            &mut WordPressPresentationEmitter::Html(&mut output),
2766            discovery,
2767        )?;
2768        output.push_str("</section>")?;
2769    }
2770    output.push_str("</div></main></body></html>")?;
2771    Ok(output.finish())
2772}
2773
2774#[cfg(feature = "scanning")]
2775fn write_html_optional_assessment_text(
2776    output: &mut RenderBuffer,
2777    value: Option<&str>,
2778    absent: &'static str,
2779) -> Result<(), ReportError> {
2780    match value {
2781        Some(value) => {
2782            output.push_str("<code>")?;
2783            write_html_text(output, value)?;
2784            output.push_str("</code>")
2785        },
2786        None => {
2787            output.push_str("<span class=\"empty\">")?;
2788            write_html_text(output, absent)?;
2789            output.push_str("</span>")
2790        },
2791    }
2792}
2793
2794#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
2795#[derive(Clone, Copy)]
2796enum WordPressPresentationInline<'a> {
2797    Literal(&'static str),
2798    Code(&'a str),
2799    Count(usize),
2800    Bytes(u64),
2801    Bool(bool),
2802}
2803
2804#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
2805enum WordPressPresentationEmitter<'a> {
2806    Html(&'a mut RenderBuffer),
2807    Markdown(&'a mut RenderBuffer),
2808}
2809
2810#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
2811fn write_wordpress_presentation(
2812    emitter: &mut WordPressPresentationEmitter<'_>,
2813    audit: &AssessmentWordPressAuditDocument,
2814) -> Result<(), ReportError> {
2815    let counts = audit.presentation_counts();
2816    emitter.note(
2817        "Interpretation boundary: review candidates are source-data comparisons, not confirmed vulnerabilities. Processing completion does not establish exhaustive coverage, exploitability, impact, or remediation.",
2818    )?;
2819    emitter.overview(&[
2820        ("Components", audit.component_count),
2821        ("Review candidates", counts.review_candidates),
2822        ("Evaluation limitations", counts.limitations),
2823        ("Contradicted on supplied facts", counts.contradicted),
2824        ("Evaluations with source guidance", counts.source_guidance),
2825    ])?;
2826
2827    emitter.heading("Source and coverage")?;
2828    emitter.begin_fields()?;
2829    wordpress_code_field(emitter, "Catalogue status", audit.catalog_status)?;
2830    if let Some(catalog) = &audit.catalog {
2831        wordpress_code_field(emitter, "Catalogue identity", &catalog.id)?;
2832        wordpress_code_field(emitter, "Catalogue revision", &catalog.revision)?;
2833        wordpress_code_field(
2834            emitter,
2835            "Source-declared retrieval date",
2836            &catalog.retrieved_on,
2837        )?;
2838        if let Some(schema) = audit.catalog_schema {
2839            wordpress_code_field(emitter, "Catalogue schema", schema)?;
2840        }
2841    }
2842    if let Some(inventory) = &audit.inventory_import {
2843        wordpress_code_field(emitter, "Core inventory", inventory.coverage.core)?;
2844        wordpress_code_field(emitter, "Plugin inventory", inventory.coverage.plugins)?;
2845        wordpress_code_field(emitter, "Theme inventory", inventory.coverage.themes)?;
2846        wordpress_count_field(
2847            emitter,
2848            "Imported inventory components",
2849            inventory.component_count,
2850        )?;
2851        wordpress_count_field(
2852            emitter,
2853            "Inventory limitations",
2854            inventory.limitations.len(),
2855        )?;
2856    } else {
2857        wordpress_code_field(emitter, "Saved inventory coverage", "not supplied")?;
2858    }
2859    if let Some(external) = &audit.external_review {
2860        wordpress_code_field(
2861            emitter,
2862            "Advisory source namespace",
2863            external.source_namespace,
2864        )?;
2865        wordpress_code_field(emitter, "Advisory source format", external.source_format)?;
2866        wordpress_code_field(emitter, "Normalization mapping", external.mapping_revision)?;
2867        if let Some(policy) = external.identity_mapping_policy {
2868            wordpress_code_field(emitter, "Identity mapping policy", policy)?;
2869            wordpress_optional_code_field(
2870                emitter,
2871                "Source identity assurance",
2872                external.identity_source_assurance,
2873            )?;
2874        }
2875        if let Some(policy) = external.resource_policy {
2876            wordpress_code_field(emitter, "External resource policy", policy)?;
2877        }
2878        wordpress_code_field(emitter, "Comparison policy", external.comparison_policy)?;
2879        if let Some(profile) = external.comparison_profile {
2880            wordpress_code_field(emitter, "Comparison profile", profile)?;
2881            wordpress_optional_code_field(emitter, "Policy selection", external.policy_selection)?;
2882            wordpress_optional_code_field(
2883                emitter,
2884                "Source comparison semantics assurance",
2885                external.source_semantics_assurance,
2886            )?;
2887        }
2888        wordpress_bytes_field(emitter, "Input bytes", external.input.byte_length)?;
2889        wordpress_code_field(emitter, "Input byte digest", &external.input.sha256)?;
2890        wordpress_code_field(
2891            emitter,
2892            "Input semantic digest",
2893            &external.input.semantic_sha256,
2894        )?;
2895        if let Some(bytes) = external.input.accounted_retained_bytes {
2896            wordpress_count_field(emitter, "Accounted retained import bytes", bytes)?;
2897        }
2898        for (label, count) in [
2899            ("Parsed source records", external.counts.parsed_records),
2900            (
2901                "Software associations",
2902                external.counts.software_associations,
2903            ),
2904            (
2905                "Relevant associations",
2906                external.counts.selected_associations,
2907            ),
2908            (
2909                "Evaluable associations",
2910                external.counts.evaluable_associations,
2911            ),
2912        ] {
2913            wordpress_count_field(emitter, label, count)?;
2914        }
2915        for (label, count) in [
2916            (
2917                "Exact source identity associations",
2918                external.counts.exact_identity_associations,
2919            ),
2920            (
2921                "Case-fold candidate associations",
2922                external.counts.candidate_identity_associations,
2923            ),
2924            (
2925                "Case-fold ambiguous associations",
2926                external.counts.ambiguous_identity_associations,
2927            ),
2928            (
2929                "Unresolved source identity associations",
2930                external.counts.unresolved_identity_associations,
2931            ),
2932            (
2933                "Detailed unresolved identity rows shown",
2934                external.counts.projected_identity_limitations,
2935            ),
2936            (
2937                "Unresolved identity rows not expanded",
2938                external.counts.unprojected_identity_limitations,
2939            ),
2940        ] {
2941            if let Some(count) = count {
2942                wordpress_count_field(emitter, label, count)?;
2943            }
2944        }
2945        if external.comparison_profile.is_none() {
2946            wordpress_count_field(
2947                emitter,
2948                "Unsupported relevant associations",
2949                external.counts.unsupported_associations,
2950            )?;
2951        }
2952        wordpress_count_field(
2953            emitter,
2954            if external.identity_mapping_policy.is_some() {
2955                "Mapped associations not selected for supplied components"
2956            } else {
2957                "Irrelevant associations excluded"
2958            },
2959            external
2960                .counts
2961                .mapped_unselected_associations
2962                .unwrap_or(external.counts.excluded_associations),
2963        )?;
2964        for (label, count) in [
2965            (
2966                "Within-range associations",
2967                external.counts.within_associations,
2968            ),
2969            (
2970                "Outside-range associations",
2971                external.counts.outside_associations,
2972            ),
2973            (
2974                "Indeterminate associations",
2975                external.counts.indeterminate_associations,
2976            ),
2977            ("Selected source ranges", external.counts.selected_ranges),
2978            ("Evaluated ranges", external.counts.evaluated_ranges),
2979            ("Containing ranges", external.counts.containing_ranges),
2980            ("Noncontaining ranges", external.counts.noncontaining_ranges),
2981            ("Unsupported ranges", external.counts.unsupported_ranges),
2982            ("Invalid ranges", external.counts.invalid_ranges),
2983            ("Ranges not evaluated", external.counts.not_evaluated_ranges),
2984            (
2985                "Qualified partial-range matches",
2986                external.counts.partial_range_coverage_associations,
2987            ),
2988        ] {
2989            if let Some(count) = count {
2990                wordpress_count_field(emitter, label, count)?;
2991            }
2992        }
2993    }
2994    emitter.end_fields()?;
2995
2996    if let Some(inventory) = &audit.inventory_import {
2997        emitter.heading("Saved inventory provenance and limitations")?;
2998        for input in &inventory.inputs {
2999            emitter.begin_record()?;
3000            emitter.inline(WordPressPresentationInline::Code(input.class))?;
3001            emitter.end_record_title()?;
3002            wordpress_count_field(emitter, "Exact-read bytes", input.byte_length)?;
3003            wordpress_code_field(emitter, "SHA-256", &input.sha256)?;
3004            emitter.end_record()?;
3005        }
3006        if inventory.limitations.is_empty() {
3007            emitter.empty("No typed inventory-row limitations were recorded.")?;
3008        } else {
3009            for limitation in &inventory.limitations {
3010                emitter.begin_record()?;
3011                emitter.inline(WordPressPresentationInline::Literal(
3012                    "Unsupported inventory row: ",
3013                ))?;
3014                emitter.inline(WordPressPresentationInline::Code(&limitation.declared_name))?;
3015                emitter.end_record_title()?;
3016                wordpress_code_field(emitter, "Status", limitation.status)?;
3017                wordpress_code_field(emitter, "Reason", limitation.reason)?;
3018                wordpress_optional_code_field(
3019                    emitter,
3020                    "Declared version",
3021                    limitation.version.as_deref(),
3022                )?;
3023                emitter.end_record()?;
3024            }
3025        }
3026    }
3027
3028    emitter.heading("Component evidence")?;
3029    if audit.components.is_empty() {
3030        emitter.empty("No component evidence was retained.")?;
3031    } else {
3032        for component in &audit.components {
3033            write_wordpress_component(emitter, component)?;
3034        }
3035    }
3036
3037    emitter.heading("Execution boundary")?;
3038    emitter.paragraph(
3039        "The WordPress review interprets retained and operator-supplied data only; it does not execute an exploit or validate impact.",
3040    )?;
3041    emitter.begin_fields()?;
3042    wordpress_code_field(emitter, "Exploit execution", "not_performed")?;
3043    wordpress_code_field(emitter, "Impact validation", "not_performed")?;
3044    emitter.end_fields()?;
3045
3046    write_wordpress_evaluation_group(
3047        emitter,
3048        audit,
3049        WordPressPresentationGroup::ReviewCandidate,
3050        "Review candidates",
3051        "These records match only on the supplied declarations and still require operator review.",
3052    )?;
3053    write_wordpress_evaluation_group(
3054        emitter,
3055        audit,
3056        WordPressPresentationGroup::Contradicted,
3057        "Contradicted on supplied facts",
3058        "These declared-data evaluations did not match; they do not establish that the installation is safe.",
3059    )?;
3060    write_wordpress_evaluation_group(
3061        emitter,
3062        audit,
3063        WordPressPresentationGroup::Limitation,
3064        "Evaluation limitations",
3065        "Missing, conflicting, unsupported, or source-semantics-limited data prevented a stronger evaluation.",
3066    )?;
3067
3068    emitter.heading("Source-declared remediation information")?;
3069    emitter.note(
3070        "Source-declared fixed versions, patched versions, and remediation remain attached to each evaluation above. They are not an automatic update, a verified fix, or a minimum safe version across every maintained branch. A false source patched flag means only that the association did not declare a fix; it does not establish permanent unpatchability.",
3071    )?;
3072    emitter.begin_fields()?;
3073    wordpress_count_field(
3074        emitter,
3075        "Evaluations with source guidance",
3076        counts.source_guidance,
3077    )?;
3078    emitter.end_fields()
3079}
3080
3081#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
3082fn write_wordpress_discovery_presentation(
3083    emitter: &mut WordPressPresentationEmitter<'_>,
3084    discovery: &AssessmentWordPressDiscoveryAuditDocument,
3085) -> Result<(), ReportError> {
3086    emitter.note(
3087        "Discovery boundary: these anonymous exact-origin metadata responses are associated with one selected application and a bounded deployment layout. Exact means that the source was bound to one supported role under observed or operator-declared inputs; it does not authenticate installation ownership, prove vulnerability applicability, or perform exploitation or impact validation.",
3088    )?;
3089    emitter.overview(&[
3090        ("Admitted seeds", usize::from(discovery.seed_count)),
3091        (
3092            "Selected candidates",
3093            usize::from(discovery.candidate_count),
3094        ),
3095        (
3096            "Attempted requests",
3097            usize::from(discovery.attempted_request_count),
3098        ),
3099        (
3100            "Completed responses",
3101            usize::from(discovery.completed_response_count),
3102        ),
3103        (
3104            "Committed responses",
3105            usize::from(discovery.committed_response_count),
3106        ),
3107    ])?;
3108    emitter.heading("Selected application and deployment layout")?;
3109    emitter.paragraph(
3110        "Application, role, and resource references are opaque location identities in this report. They do not contain literal deployment paths, but they are not a secrecy guarantee, signature, or proof of deployment control. Ambiguous and unresolved roles are retained as coverage limits rather than guessed locations.",
3111    )?;
3112    emitter.begin_fields()?;
3113    wordpress_code_field(
3114        emitter,
3115        "Selected application reference",
3116        &discovery.layout.application_reference,
3117    )?;
3118    if let Some(declaration) = &discovery.layout.declaration {
3119        wordpress_code_field(emitter, "Layout declaration schema", declaration.schema)?;
3120        wordpress_bytes_field(
3121            emitter,
3122            "Layout declaration exact-read bytes",
3123            declaration.byte_length,
3124        )?;
3125        wordpress_code_field(emitter, "Layout declaration SHA-256", &declaration.sha256)?;
3126    } else {
3127        wordpress_code_field(emitter, "Operator layout declaration", "not supplied")?;
3128    }
3129    wordpress_count_field(
3130        emitter,
3131        "Skipped foreign-origin advertisements",
3132        usize::from(discovery.layout.skipped_foreign_origin_count),
3133    )?;
3134    wordpress_count_field(
3135        emitter,
3136        "Skipped sibling-application advertisements",
3137        usize::from(discovery.layout.skipped_sibling_application_count),
3138    )?;
3139    wordpress_count_field(
3140        emitter,
3141        "Conflicting layout associations",
3142        usize::from(discovery.layout.conflicting_association_count),
3143    )?;
3144    emitter.end_fields()?;
3145    for role in &discovery.layout.roles {
3146        emitter.begin_record()?;
3147        emitter.inline(WordPressPresentationInline::Code(role.role))?;
3148        emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3149        emitter.inline(WordPressPresentationInline::Code(role.status))?;
3150        emitter.end_record_title()?;
3151        wordpress_code_field(emitter, "Layout role", role.role)?;
3152        wordpress_code_field(emitter, "Resolution status", role.status)?;
3153        wordpress_code_field(emitter, "Resolution basis", role.basis)?;
3154        wordpress_optional_code_field(emitter, "Role reference", role.reference.as_deref())?;
3155        wordpress_count_field(
3156            emitter,
3157            "Distinct location candidates",
3158            usize::from(role.candidate_count),
3159        )?;
3160        emitter.end_record()?;
3161    }
3162    if let Some(pages) = &discovery.page_collection {
3163        emitter.heading("Page-scoped collection")?;
3164        emitter.paragraph(
3165            "The page scope is a bounded first-hop sample under the entry-selected application and layout. Reused responses were already charged by the parent assessment; newly transferred page bytes and metadata requests are reported separately. A missing page response or rejected association is a coverage limit, not evidence that a component is absent.",
3166        )?;
3167        emitter.begin_fields()?;
3168        wordpress_code_field(emitter, "Selected page mode", pages.mode)?;
3169        wordpress_code_field(
3170            emitter,
3171            "Opaque entry page reference",
3172            &pages.entry_page_reference,
3173        )?;
3174        wordpress_count_field(
3175            emitter,
3176            "Eligible page candidates",
3177            usize::from(pages.candidate_count),
3178        )?;
3179        wordpress_count_field(
3180            emitter,
3181            "Selected non-entry pages",
3182            usize::from(pages.selected_count),
3183        )?;
3184        wordpress_count_field(
3185            emitter,
3186            "Omitted page candidates",
3187            usize::from(pages.omitted_candidate_count),
3188        )?;
3189        wordpress_count_field(
3190            emitter,
3191            "Reused page responses",
3192            usize::from(pages.reused_response_count),
3193        )?;
3194        wordpress_count_field(
3195            emitter,
3196            "Fetched page responses",
3197            usize::from(pages.fetched_response_count),
3198        )?;
3199        wordpress_count_field(
3200            emitter,
3201            "Pages not observed",
3202            usize::from(pages.not_observed_count),
3203        )?;
3204        wordpress_count_field(
3205            emitter,
3206            "Rejected page responses",
3207            usize::from(pages.rejected_response_count),
3208        )?;
3209        wordpress_count_field(
3210            emitter,
3211            "Accepted page associations",
3212            usize::from(pages.accepted_association_count),
3213        )?;
3214        wordpress_count_field(
3215            emitter,
3216            "Rejected page associations",
3217            usize::from(pages.rejected_association_count),
3218        )?;
3219        wordpress_count_field(
3220            emitter,
3221            "Page request attempts",
3222            usize::from(pages.attempted_request_count),
3223        )?;
3224        emitter.begin_field("Interpreted page bytes")?;
3225        emitter.inline(WordPressPresentationInline::Bytes(
3226            pages.interpreted_response_bytes,
3227        ))?;
3228        emitter.end_field()?;
3229        emitter.begin_field("Newly transferred page bytes")?;
3230        emitter.inline(WordPressPresentationInline::Bytes(pages.response_bytes))?;
3231        emitter.end_field()?;
3232        emitter.end_fields()?;
3233        for page in &pages.pages {
3234            emitter.begin_record()?;
3235            emitter.inline(WordPressPresentationInline::Code(&page.page_reference))?;
3236            emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3237            emitter.inline(WordPressPresentationInline::Code(page.outcome))?;
3238            emitter.end_record_title()?;
3239            wordpress_code_field(emitter, "Acquisition class", page.acquisition)?;
3240            wordpress_code_field(emitter, "Application association", page.association)?;
3241            wordpress_code_field(emitter, "Interpretation outcome", page.outcome)?;
3242            wordpress_code_field(
3243                emitter,
3244                "Opaque source page reference",
3245                &page.page_reference,
3246            )?;
3247            wordpress_count_field(
3248                emitter,
3249                "Evidence reference count",
3250                page.evidence_reference_count,
3251            )?;
3252            emitter.begin_field("Interpreted response bytes")?;
3253            emitter.inline(WordPressPresentationInline::Bytes(
3254                page.interpreted_response_bytes,
3255            ))?;
3256            emitter.end_field()?;
3257            emitter.begin_field("Newly transferred response bytes")?;
3258            emitter.inline(WordPressPresentationInline::Bytes(page.response_bytes))?;
3259            emitter.end_field()?;
3260            emitter.end_record()?;
3261        }
3262    }
3263    if let Some(pages) = &discovery.supplied_session_pages {
3264        emitter.heading("Supplied-session page interpretation")?;
3265        emitter.paragraph(
3266            "Only health-qualified, committed responses from the explicitly supplied session can support these page relationships. WordPress metadata retrieval remains anonymous; the references below do not authenticate the principal or application, and V1 does not nominate authenticated page assets for fingerprinting.",
3267        )?;
3268        emitter.begin_fields()?;
3269        wordpress_code_field(emitter, "Interpretation mode", pages.mode)?;
3270        wordpress_code_field(emitter, "Session policy reference", &pages.policy_reference)?;
3271        wordpress_code_field(
3272            emitter,
3273            "Session application reference",
3274            &pages.application_reference,
3275        )?;
3276        wordpress_code_field(
3277            emitter,
3278            "Operator-declared principal reference",
3279            &pages.principal_reference,
3280        )?;
3281        wordpress_code_field(emitter, "Credential mechanism", pages.credential_mechanism)?;
3282        wordpress_count_field(emitter, "Session epoch", usize::from(pages.session_epoch))?;
3283        wordpress_count_field(
3284            emitter,
3285            "Selected session resources",
3286            usize::from(pages.selected_count),
3287        )?;
3288        wordpress_count_field(
3289            emitter,
3290            "Committed session resources",
3291            usize::from(pages.committed_count),
3292        )?;
3293        wordpress_count_field(
3294            emitter,
3295            "Accepted application associations",
3296            usize::from(pages.accepted_association_count),
3297        )?;
3298        wordpress_count_field(
3299            emitter,
3300            "Rejected application associations",
3301            usize::from(pages.rejected_association_count),
3302        )?;
3303        wordpress_count_field(
3304            emitter,
3305            "Associations not established",
3306            usize::from(pages.not_established_association_count),
3307        )?;
3308        wordpress_count_field(
3309            emitter,
3310            "Resources not evaluated",
3311            usize::from(pages.not_evaluated_count),
3312        )?;
3313        emitter.begin_field("Interpreted session response bytes")?;
3314        emitter.inline(WordPressPresentationInline::Bytes(
3315            pages.interpreted_response_bytes,
3316        ))?;
3317        emitter.end_field()?;
3318        emitter.end_fields()?;
3319        for page in &pages.pages {
3320            emitter.begin_record()?;
3321            emitter.inline(WordPressPresentationInline::Code(&page.page_reference))?;
3322            emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3323            emitter.inline(WordPressPresentationInline::Code(page.outcome))?;
3324            emitter.end_record_title()?;
3325            wordpress_code_field(emitter, "Acquisition class", page.acquisition)?;
3326            wordpress_code_field(emitter, "Application association", page.association)?;
3327            wordpress_code_field(emitter, "Interpretation outcome", page.outcome)?;
3328            wordpress_code_field(
3329                emitter,
3330                "Opaque supplied-session resource reference",
3331                &page.resource_reference,
3332            )?;
3333            wordpress_optional_code_field(
3334                emitter,
3335                "Opaque committed resource evidence reference",
3336                page.resource_evidence_reference.as_deref(),
3337            )?;
3338            wordpress_code_field(
3339                emitter,
3340                "Authenticated-page fingerprint evaluation",
3341                page.fingerprint_evaluation,
3342            )?;
3343            wordpress_count_field(
3344                emitter,
3345                "Evidence reference count",
3346                page.evidence_reference_count,
3347            )?;
3348            emitter.begin_field("Interpreted response bytes")?;
3349            emitter.inline(WordPressPresentationInline::Bytes(
3350                page.interpreted_response_bytes,
3351            ))?;
3352            emitter.end_field()?;
3353            emitter.end_record()?;
3354        }
3355    }
3356    emitter.heading("Discovered metadata sources")?;
3357    if discovery.sources.is_empty() {
3358        emitter
3359            .empty("No eligible metadata source was selected from the committed root response.")?;
3360        return Ok(());
3361    }
3362    for source in &discovery.sources {
3363        emitter.begin_record()?;
3364        emitter.inline(WordPressPresentationInline::Code(source.kind))?;
3365        if let Some(component) = &source.component {
3366            emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3367            emitter.inline(WordPressPresentationInline::Code(component.kind))?;
3368            emitter.inline(WordPressPresentationInline::Literal(":"))?;
3369            emitter.inline(WordPressPresentationInline::Code(&component.slug))?;
3370        }
3371        emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3372        emitter.inline(WordPressPresentationInline::Code(source.outcome))?;
3373        emitter.end_record_title()?;
3374        wordpress_code_field(emitter, "Source class", source.kind)?;
3375        wordpress_code_field(emitter, "Application association", source.association)?;
3376        wordpress_code_field(
3377            emitter,
3378            "Opaque resource reference",
3379            &source.resource_reference,
3380        )?;
3381        wordpress_optional_code_field(
3382            emitter,
3383            "Opaque role reference",
3384            source.role_reference.as_deref(),
3385        )?;
3386        wordpress_code_field(emitter, "Collection outcome", source.outcome)?;
3387        wordpress_count_field(
3388            emitter,
3389            "Parent-theme depth",
3390            usize::from(source.parent_depth),
3391        )?;
3392        wordpress_count_field(
3393            emitter,
3394            "Evidence reference count",
3395            source.evidence_reference_count,
3396        )?;
3397        write_wordpress_values(
3398            emitter,
3399            "Opaque source page references",
3400            source.source_page_references.iter().map(String::as_str),
3401            "not recorded by this audit version",
3402        )?;
3403        if let Some(references) = &source.source_supplied_session_page_references {
3404            wordpress_count_field(
3405                emitter,
3406                "Supplied-session source page reference count",
3407                references.len(),
3408            )?;
3409        }
3410        write_wordpress_values(
3411            emitter,
3412            "Opaque supplied-session source page references",
3413            source
3414                .source_supplied_session_page_references
3415                .iter()
3416                .flat_map(|references| references.iter())
3417                .map(String::as_str),
3418            "not recorded by this audit version or no supplied-session source",
3419        )?;
3420        emitter.begin_field("Response bytes")?;
3421        emitter.inline(WordPressPresentationInline::Bytes(source.response_bytes))?;
3422        emitter.end_field()?;
3423        write_wordpress_values(
3424            emitter,
3425            "REST namespaces (protocol support only)",
3426            source.namespaces.iter().map(String::as_str),
3427            "none retained",
3428        )?;
3429        if let Some(theme) = &source.theme {
3430            wordpress_optional_code_field(emitter, "Theme display name", theme.name.as_deref())?;
3431            wordpress_optional_code_field(
3432                emitter,
3433                "Artifact-declared theme version",
3434                theme.version.as_deref(),
3435            )?;
3436            wordpress_optional_code_field(
3437                emitter,
3438                "Declared parent theme slug",
3439                theme.template.as_deref(),
3440            )?;
3441            wordpress_optional_code_field(
3442                emitter,
3443                "Requires WordPress",
3444                theme.requires_wordpress.as_deref(),
3445            )?;
3446            wordpress_optional_code_field(
3447                emitter,
3448                "Requires PHP (compatibility declaration)",
3449                theme.requires_php.as_deref(),
3450            )?;
3451            wordpress_optional_code_field(
3452                emitter,
3453                "Tested up to (compatibility declaration)",
3454                theme.tested_up_to.as_deref(),
3455            )?;
3456        }
3457        if let Some(plugin) = &source.plugin {
3458            wordpress_optional_code_field(emitter, "Plugin display name", plugin.name.as_deref())?;
3459            wordpress_optional_code_field(
3460                emitter,
3461                "Distribution release hint (not installed version)",
3462                plugin.stable_tag.as_deref(),
3463            )?;
3464            wordpress_optional_code_field(
3465                emitter,
3466                "Requires WordPress",
3467                plugin.requires_wordpress.as_deref(),
3468            )?;
3469            wordpress_optional_code_field(
3470                emitter,
3471                "Requires PHP (compatibility declaration)",
3472                plugin.requires_php.as_deref(),
3473            )?;
3474            wordpress_optional_code_field(
3475                emitter,
3476                "Tested up to (compatibility declaration)",
3477                plugin.tested_up_to.as_deref(),
3478            )?;
3479        }
3480        emitter.end_record()?;
3481    }
3482    Ok(())
3483}
3484
3485#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
3486fn write_wordpress_asset_fingerprint_presentation(
3487    emitter: &mut WordPressPresentationEmitter<'_>,
3488    audit: &AssessmentWordPressAssetFingerprintAuditDocument,
3489) -> Result<(), ReportError> {
3490    emitter.note(
3491        "Fingerprint boundary: exact complete-byte observations are compared only with this finite supplied catalogue. Candidate releases are conditional catalogue results, not installed-version evidence, publisher authentication, whole-package verification, vulnerability confirmation, or exhaustive release coverage.",
3492    )?;
3493    emitter.overview(&[
3494        (
3495            "Observed asset candidates",
3496            usize::try_from(audit.candidate_count).unwrap_or(usize::MAX),
3497        ),
3498        ("Selected resources", audit.selected_resource_count),
3499        ("Omitted resources", audit.omitted_resource_count),
3500        ("Compared components", audit.component_count),
3501    ])?;
3502
3503    emitter.heading("Finite reference catalogue")?;
3504    emitter.begin_fields()?;
3505    wordpress_code_field(emitter, "Catalogue ID", &audit.catalogue.id)?;
3506    wordpress_code_field(emitter, "Catalogue revision", &audit.catalogue.revision)?;
3507    wordpress_code_field(
3508        emitter,
3509        "Catalogue source namespace",
3510        &audit.catalogue.source_namespace,
3511    )?;
3512    wordpress_bytes_field(
3513        emitter,
3514        "Catalogue exact-read bytes",
3515        audit.catalogue.byte_length,
3516    )?;
3517    wordpress_code_field(emitter, "Catalogue byte SHA-256", &audit.catalogue.sha256)?;
3518    wordpress_code_field(
3519        emitter,
3520        "Catalogue semantic SHA-256",
3521        &audit.catalogue.semantic_sha256,
3522    )?;
3523    wordpress_count_field(
3524        emitter,
3525        "Accounted prepared-index bytes",
3526        audit.catalogue.retained_bytes,
3527    )?;
3528    wordpress_count_field(
3529        emitter,
3530        "Listed components",
3531        audit.catalogue.component_count,
3532    )?;
3533    wordpress_count_field(emitter, "Listed releases", audit.catalogue.release_count)?;
3534    wordpress_count_field(
3535        emitter,
3536        "Listed reference files",
3537        audit.catalogue.file_count,
3538    )?;
3539    wordpress_code_field(
3540        emitter,
3541        "Catalogue provenance reference",
3542        &audit.catalogue.provenance.reference,
3543    )?;
3544    wordpress_code_field(
3545        emitter,
3546        "Catalogue provenance revision",
3547        &audit.catalogue.provenance.revision,
3548    )?;
3549    wordpress_code_field(emitter, "Reference scope", audit.finite_reference_scope)?;
3550    wordpress_code_field(
3551        emitter,
3552        "Same-release assumption",
3553        audit.same_release_assumption,
3554    )?;
3555    wordpress_code_field(
3556        emitter,
3557        "Installed-version assurance",
3558        audit.installed_version_assurance,
3559    )?;
3560    wordpress_code_field(emitter, "Source authenticity", audit.source_authenticity)?;
3561    emitter.end_fields()?;
3562
3563    emitter.heading("Catalogue notices")?;
3564    if audit.catalogue.provenance.notices.is_empty() {
3565        emitter.empty("No catalogue notice was supplied.")?;
3566    } else {
3567        for notice in &audit.catalogue.provenance.notices {
3568            emitter.begin_record()?;
3569            emitter.inline(WordPressPresentationInline::Code(&notice.id))?;
3570            emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3571            emitter.inline(WordPressPresentationInline::Code(&notice.party))?;
3572            emitter.end_record_title()?;
3573            wordpress_code_field(emitter, "Party", &notice.party)?;
3574            wordpress_code_field(emitter, "Notice", &notice.notice)?;
3575            wordpress_code_field(emitter, "License", &notice.license)?;
3576            wordpress_code_field(
3577                emitter,
3578                "Inert license reference",
3579                &notice.license_reference,
3580            )?;
3581            emitter.end_record()?;
3582        }
3583    }
3584
3585    emitter.heading("Observed resource collection")?;
3586    emitter.paragraph(
3587        "Resource and page references are bounded opaque identifiers. SHA-256 values cover eligible complete identity-coded content bytes and are not signatures. Query strings and raw resource bodies are not retained here.",
3588    )?;
3589    if audit.resources.is_empty() {
3590        emitter.empty("No resource was selected for byte comparison.")?;
3591    } else {
3592        for resource in &audit.resources {
3593            emitter.begin_record()?;
3594            emitter.inline(WordPressPresentationInline::Code(resource.component.kind))?;
3595            emitter.inline(WordPressPresentationInline::Literal(":"))?;
3596            emitter.inline(WordPressPresentationInline::Code(&resource.component.slug))?;
3597            emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3598            emitter.inline(WordPressPresentationInline::Code(&resource.relative_path))?;
3599            emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3600            emitter.inline(WordPressPresentationInline::Code(resource.outcome))?;
3601            emitter.end_record_title()?;
3602            wordpress_code_field(emitter, "Component kind", resource.component.kind)?;
3603            wordpress_code_field(emitter, "Component slug", &resource.component.slug)?;
3604            wordpress_code_field(emitter, "Component-relative path", &resource.relative_path)?;
3605            wordpress_code_field(
3606                emitter,
3607                "Opaque resource reference",
3608                &resource.resource_reference,
3609            )?;
3610            write_wordpress_values(
3611                emitter,
3612                "Opaque source page references",
3613                resource.source_page_references.iter().map(String::as_str),
3614                "none retained",
3615            )?;
3616            wordpress_count_field(
3617                emitter,
3618                "Observed URL variants",
3619                resource.observed_variant_count,
3620            )?;
3621            wordpress_code_field(emitter, "Acquisition class", resource.acquisition)?;
3622            wordpress_code_field(emitter, "Collection outcome", resource.outcome)?;
3623            wordpress_bool_field(emitter, "Request attempted", resource.request_attempted)?;
3624            wordpress_bytes_field(
3625                emitter,
3626                "Interpreted complete-body bytes",
3627                resource.interpreted_response_bytes,
3628            )?;
3629            wordpress_bytes_field(
3630                emitter,
3631                "Newly transferred response bytes",
3632                resource.response_bytes,
3633            )?;
3634            if let Some(observation) = &resource.observation {
3635                wordpress_bytes_field(emitter, "Observed byte length", observation.byte_length)?;
3636                wordpress_code_field(emitter, "Observed byte SHA-256", &observation.sha256)?;
3637            } else {
3638                wordpress_code_field(emitter, "Complete-byte fingerprint", "unavailable")?;
3639            }
3640            wordpress_count_field(
3641                emitter,
3642                "Evidence reference count",
3643                resource.evidence_reference_count,
3644            )?;
3645            emitter.end_record()?;
3646        }
3647    }
3648
3649    emitter.heading("Catalogue-scoped release candidates")?;
3650    if audit.components.is_empty() {
3651        emitter.empty("No component had a selected resource comparison.")?;
3652    } else {
3653        for component in &audit.components {
3654            emitter.begin_record()?;
3655            emitter.inline(WordPressPresentationInline::Code(component.identity.kind))?;
3656            emitter.inline(WordPressPresentationInline::Literal(":"))?;
3657            emitter.inline(WordPressPresentationInline::Code(&component.identity.slug))?;
3658            emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3659            emitter.inline(WordPressPresentationInline::Code(component.state))?;
3660            emitter.end_record_title()?;
3661            wordpress_code_field(emitter, "Aggregate state", component.state)?;
3662            wordpress_bool_field(
3663                emitter,
3664                "Component represented in catalogue",
3665                component.catalogue_component_listed,
3666            )?;
3667            wordpress_bool_field(
3668                emitter,
3669                "Listed release matrix complete",
3670                component.listed_matrix_complete,
3671            )?;
3672            wordpress_count_field(
3673                emitter,
3674                "Informative resource paths",
3675                component.informative_resource_count,
3676            )?;
3677            write_wordpress_values(
3678                emitter,
3679                "Compatible listed release IDs",
3680                component.compatible_release_ids.iter().map(String::as_str),
3681                "none",
3682            )?;
3683            write_wordpress_values(
3684                emitter,
3685                "Undetermined listed release IDs",
3686                component
3687                    .undetermined_release_ids
3688                    .iter()
3689                    .map(String::as_str),
3690                "none",
3691            )?;
3692            write_wordpress_values(
3693                emitter,
3694                "Inconsistent listed release IDs",
3695                component
3696                    .inconsistent_release_ids
3697                    .iter()
3698                    .map(String::as_str),
3699                "none",
3700            )?;
3701            if component.resources.is_empty() {
3702                wordpress_code_field(emitter, "Resource comparison matrix", "empty")?;
3703            } else {
3704                emitter.begin_list_field("Resource comparison matrix")?;
3705                for resource in &component.resources {
3706                    emitter.begin_list_item()?;
3707                    emitter.inline(WordPressPresentationInline::Code(&resource.relative_path))?;
3708                    emitter.inline(WordPressPresentationInline::Literal(" — observations "))?;
3709                    emitter.inline(WordPressPresentationInline::Count(
3710                        resource.distinct_observation_count,
3711                    ))?;
3712                    emitter.inline(WordPressPresentationInline::Literal("; informative "))?;
3713                    emitter.inline(WordPressPresentationInline::Bool(resource.informative))?;
3714                    for relation in &resource.release_relations {
3715                        emitter.inline(WordPressPresentationInline::Literal("; "))?;
3716                        emitter.inline(WordPressPresentationInline::Code(&relation.release_id))?;
3717                        emitter.inline(WordPressPresentationInline::Literal("="))?;
3718                        emitter.inline(WordPressPresentationInline::Code(relation.relation))?;
3719                        if let Some(reason) = relation.unknown_reason {
3720                            emitter.inline(WordPressPresentationInline::Literal(" ("))?;
3721                            emitter.inline(WordPressPresentationInline::Code(reason))?;
3722                            emitter.inline(WordPressPresentationInline::Literal(")"))?;
3723                        }
3724                    }
3725                    emitter.end_list_item()?;
3726                }
3727                emitter.end_list_field()?;
3728            }
3729            if component.releases.is_empty() {
3730                wordpress_code_field(emitter, "Listed releases", "none")?;
3731            } else {
3732                emitter.begin_list_field("Listed release sources and states")?;
3733                for release in &component.releases {
3734                    emitter.begin_list_item()?;
3735                    emitter.inline(WordPressPresentationInline::Code(&release.release_id))?;
3736                    emitter.inline(WordPressPresentationInline::Literal(" version "))?;
3737                    emitter.inline(WordPressPresentationInline::Code(&release.version))?;
3738                    if let Some(build) = &release.build_variant {
3739                        emitter.inline(WordPressPresentationInline::Literal(" build "))?;
3740                        emitter.inline(WordPressPresentationInline::Code(build))?;
3741                    }
3742                    emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3743                    emitter.inline(WordPressPresentationInline::Code(release.state))?;
3744                    emitter.inline(WordPressPresentationInline::Literal("; source "))?;
3745                    emitter.inline(WordPressPresentationInline::Code(&release.source.reference))?;
3746                    emitter.inline(WordPressPresentationInline::Literal(" @ "))?;
3747                    emitter.inline(WordPressPresentationInline::Code(&release.source.revision))?;
3748                    emitter.end_list_item()?;
3749                }
3750                emitter.end_list_field()?;
3751            }
3752            emitter.end_record()?;
3753        }
3754    }
3755
3756    emitter.heading("Interpretation boundary")?;
3757    emitter.paragraph(
3758        "Even one complete listed matrix can identify only a conditional candidate among represented releases. Unlisted releases, custom or cached copies, mixed artifacts, and reused bytes remain possible; existing WordPress installed-version and advisory inputs are not changed by this audit.",
3759    )
3760}
3761
3762#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
3763fn write_wordpress_component(
3764    emitter: &mut WordPressPresentationEmitter<'_>,
3765    component: &WordPressComponentDocument,
3766) -> Result<(), ReportError> {
3767    emitter.begin_record()?;
3768    emitter.inline(WordPressPresentationInline::Code(component.identity.kind))?;
3769    emitter.inline(WordPressPresentationInline::Literal(":"))?;
3770    emitter.inline(WordPressPresentationInline::Code(&component.identity.slug))?;
3771    emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3772    emitter.inline(WordPressPresentationInline::Code(component.evidence_class))?;
3773    emitter.end_record_title()?;
3774    wordpress_code_field(emitter, "Evidence class", component.evidence_class)?;
3775    wordpress_optional_code_field(emitter, "Activation", component.activation)?;
3776    wordpress_optional_code_field(emitter, "Inventory status", component.inventory_status)?;
3777    write_wordpress_values(
3778        emitter,
3779        "Identity sources",
3780        component.identity_sources.iter().copied(),
3781        "none recorded",
3782    )?;
3783    write_wordpress_values(
3784        emitter,
3785        "Confidence classes",
3786        component.confidence_classes.iter().copied(),
3787        "none recorded",
3788    )?;
3789    if component.versions.is_empty() {
3790        wordpress_code_field(emitter, "Version evidence", "missing")?;
3791    } else {
3792        emitter.begin_list_field("Version evidence")?;
3793        for version in &component.versions {
3794            emitter.begin_list_item()?;
3795            emitter.inline(WordPressPresentationInline::Code(&version.value))?;
3796            emitter.inline(WordPressPresentationInline::Literal(" from "))?;
3797            emitter.inline(WordPressPresentationInline::Code(version.source))?;
3798            emitter.inline(WordPressPresentationInline::Literal(" with "))?;
3799            emitter.inline(WordPressPresentationInline::Code(version.confidence))?;
3800            emitter.end_list_item()?;
3801        }
3802        emitter.end_list_field()?;
3803    }
3804    emitter.end_record()
3805}
3806
3807#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
3808fn write_wordpress_evaluation_group(
3809    emitter: &mut WordPressPresentationEmitter<'_>,
3810    audit: &AssessmentWordPressAuditDocument,
3811    group: WordPressPresentationGroup,
3812    title: &'static str,
3813    explanation: &'static str,
3814) -> Result<(), ReportError> {
3815    emitter.heading(title)?;
3816    emitter.paragraph(explanation)?;
3817    let mut rendered = 0_usize;
3818    for advisory in &audit.advisories {
3819        if wordpress_advisory_group(advisory) == group {
3820            write_wordpress_advisory(emitter, advisory)?;
3821            rendered += 1;
3822        }
3823    }
3824    if let Some(external) = &audit.external_review {
3825        for evaluation in &external.evaluations {
3826            if wordpress_external_advisory_group(evaluation) == group {
3827                write_wordpress_external_evaluation(emitter, external, evaluation)?;
3828                rendered += 1;
3829            }
3830        }
3831        if group == WordPressPresentationGroup::Limitation {
3832            if let Some(limitations) = &external.identity_limitations {
3833                for limitation in limitations {
3834                    write_wordpress_external_identity_limitation(emitter, limitation)?;
3835                    rendered += 1;
3836                }
3837            }
3838            if external
3839                .counts
3840                .unprojected_identity_limitations
3841                .is_some_and(|count| count > 0)
3842            {
3843                emitter.paragraph(
3844                    "The unresolved identity total covers the complete imported snapshot. This section expands only a deterministic bounded detail projection; rows not expanded here were not evaluated or classified as unaffected.",
3845                )?;
3846            }
3847        }
3848    }
3849    if rendered == 0 {
3850        emitter.empty("No records in this group.")?;
3851    }
3852    Ok(())
3853}
3854
3855#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
3856fn write_wordpress_advisory(
3857    emitter: &mut WordPressPresentationEmitter<'_>,
3858    advisory: &WordPressAdvisoryDocument,
3859) -> Result<(), ReportError> {
3860    emitter.begin_record()?;
3861    emitter.inline(WordPressPresentationInline::Code(&advisory.id))?;
3862    emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3863    emitter.inline(WordPressPresentationInline::Code(advisory.component.kind))?;
3864    emitter.inline(WordPressPresentationInline::Literal(":"))?;
3865    emitter.inline(WordPressPresentationInline::Code(&advisory.component.slug))?;
3866    emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3867    emitter.inline(WordPressPresentationInline::Code(advisory.applicability))?;
3868    emitter.end_record_title()?;
3869    wordpress_code_field(emitter, "Applicability", advisory.applicability)?;
3870    wordpress_code_field(emitter, "Version relation", advisory.version_relation)?;
3871    wordpress_code_field(
3872        emitter,
3873        "Comparison profile",
3874        advisory
3875            .comparison_profile
3876            .unwrap_or("numeric-dotted/v1 (implicit catalogue contract)"),
3877    )?;
3878    wordpress_code_field(
3879        emitter,
3880        "Version resolution",
3881        advisory
3882            .version_resolution
3883            .unwrap_or("not recorded by audit schema"),
3884    )?;
3885    wordpress_code_field(
3886        emitter,
3887        "Version-resolution reason",
3888        advisory
3889            .version_resolution_reason
3890            .unwrap_or("not recorded by audit schema"),
3891    )?;
3892    wordpress_code_field(emitter, "Component evidence", advisory.component_evidence)?;
3893    wordpress_code_field(emitter, "Source reference", &advisory.source.reference)?;
3894    wordpress_code_field(emitter, "Source revision", &advisory.source.revision)?;
3895    wordpress_code_field(
3896        emitter,
3897        "Source-declared retrieval date",
3898        &advisory.source.retrieved_on,
3899    )?;
3900    wordpress_code_field(emitter, "Source usage basis", &advisory.source.usage_basis)?;
3901    wordpress_optional_code_field(emitter, "CVE", advisory.cve.as_deref())?;
3902    wordpress_code_field(emitter, "Source summary", &advisory.summary)?;
3903    if advisory.affected_ranges.is_empty() {
3904        wordpress_code_field(emitter, "Affected ranges", "none declared")?;
3905    } else {
3906        emitter.begin_list_field("Affected ranges")?;
3907        for range in &advisory.affected_ranges {
3908            emitter.begin_list_item()?;
3909            emitter.inline(WordPressPresentationInline::Literal("lower "))?;
3910            write_wordpress_endpoint(emitter, range.lower.as_ref())?;
3911            emitter.inline(WordPressPresentationInline::Literal("; upper "))?;
3912            write_wordpress_endpoint(emitter, range.upper.as_ref())?;
3913            emitter.end_list_item()?;
3914        }
3915        emitter.end_list_field()?;
3916    }
3917    write_wordpress_values(
3918        emitter,
3919        "Source-declared fixed versions",
3920        advisory.fixed_versions.iter().map(String::as_str),
3921        "none declared",
3922    )?;
3923    if advisory.prerequisites.is_empty() {
3924        wordpress_code_field(emitter, "Prerequisites", "none declared")?;
3925    } else {
3926        emitter.begin_list_field("Prerequisites")?;
3927        for prerequisite in &advisory.prerequisites {
3928            emitter.begin_list_item()?;
3929            emitter.inline(WordPressPresentationInline::Code(prerequisite.kind))?;
3930            emitter.inline(WordPressPresentationInline::Literal(" expected "))?;
3931            emitter.inline(WordPressPresentationInline::Code(&prerequisite.expected))?;
3932            emitter.inline(WordPressPresentationInline::Literal(": "))?;
3933            emitter.inline(WordPressPresentationInline::Code(prerequisite.outcome))?;
3934            if let Some(patch_id) = &prerequisite.patch_id {
3935                emitter.inline(WordPressPresentationInline::Literal("; patch "))?;
3936                emitter.inline(WordPressPresentationInline::Code(patch_id))?;
3937            }
3938            emitter.end_list_item()?;
3939        }
3940        emitter.end_list_field()?;
3941    }
3942    wordpress_optional_code_field(
3943        emitter,
3944        "Source-declared remediation",
3945        advisory.remediation.as_deref(),
3946    )?;
3947    emitter.end_record()
3948}
3949
3950#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
3951fn write_wordpress_endpoint(
3952    emitter: &mut WordPressPresentationEmitter<'_>,
3953    endpoint: Option<&WordPressVersionEndpointDocument>,
3954) -> Result<(), ReportError> {
3955    if let Some(endpoint) = endpoint {
3956        emitter.inline(WordPressPresentationInline::Code(&endpoint.declared))?;
3957        emitter.inline(WordPressPresentationInline::Literal(
3958            if endpoint.inclusive {
3959                " (inclusive)"
3960            } else {
3961                " (exclusive)"
3962            },
3963        ))
3964    } else {
3965        emitter.inline(WordPressPresentationInline::Code("unbounded/unknown"))
3966    }
3967}
3968
3969#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
3970fn write_wordpress_external_evaluation(
3971    emitter: &mut WordPressPresentationEmitter<'_>,
3972    external: &WordPressExternalReviewDocument,
3973    evaluation: &WordPressExternalEvaluationDocument,
3974) -> Result<(), ReportError> {
3975    emitter.begin_record()?;
3976    emitter.inline(WordPressPresentationInline::Code(
3977        &evaluation.key.upstream_id,
3978    ))?;
3979    emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3980    emitter.inline(WordPressPresentationInline::Code(
3981        evaluation.key.component.kind,
3982    ))?;
3983    emitter.inline(WordPressPresentationInline::Literal(":"))?;
3984    emitter.inline(WordPressPresentationInline::Code(
3985        &evaluation.key.component.slug,
3986    ))?;
3987    emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3988    emitter.inline(WordPressPresentationInline::Code(evaluation.applicability))?;
3989    emitter.end_record_title()?;
3990    wordpress_code_field(
3991        emitter,
3992        "Source namespace",
3993        &evaluation.key.source_namespace,
3994    )?;
3995    if let Some(source_component) = &evaluation.key.source_component {
3996        emitter.begin_field("Raw source component identity")?;
3997        emitter.inline(WordPressPresentationInline::Code(source_component.kind))?;
3998        emitter.inline(WordPressPresentationInline::Literal(":"))?;
3999        emitter.inline(WordPressPresentationInline::Code(&source_component.slug))?;
4000        emitter.end_field()?;
4001        wordpress_optional_code_field(emitter, "Identity mapping", evaluation.identity_mapping)?;
4002        if let Some(count) = evaluation.identity_collision_raw_count {
4003            wordpress_count_field(emitter, "Colliding raw source identities", count)?;
4004        }
4005    }
4006    wordpress_code_field(emitter, "Source title", &evaluation.title)?;
4007    wordpress_code_field(emitter, "Source component name", &evaluation.display_name)?;
4008    wordpress_bool_field(
4009        emitter,
4010        "Source informational classification",
4011        evaluation.informational,
4012    )?;
4013    wordpress_code_field(emitter, "Source description", &evaluation.description)?;
4014    wordpress_code_field(emitter, "Comparison policy", external.comparison_policy)?;
4015    if let Some(profile) = external.comparison_profile {
4016        wordpress_code_field(emitter, "Comparison profile", profile)?;
4017        wordpress_optional_code_field(emitter, "Policy selection", external.policy_selection)?;
4018        wordpress_optional_code_field(
4019            emitter,
4020            "Source comparison semantics assurance",
4021            external.source_semantics_assurance,
4022        )?;
4023    }
4024    wordpress_code_field(emitter, "Applicability", evaluation.applicability)?;
4025    wordpress_code_field(emitter, "Version relation", evaluation.version_relation)?;
4026    if external.comparison_profile.is_some() {
4027        wordpress_optional_code_field(
4028            emitter,
4029            "Version-relation reason",
4030            evaluation.version_relation_reason,
4031        )?;
4032    }
4033    wordpress_code_field(
4034        emitter,
4035        "Version evidence resolution",
4036        evaluation.version_evidence_resolution.status,
4037    )?;
4038    emitter.begin_field("Version evidence rows / distinct spellings")?;
4039    emitter.inline(WordPressPresentationInline::Count(
4040        evaluation.version_evidence_resolution.evidence_row_count,
4041    ))?;
4042    emitter.inline(WordPressPresentationInline::Literal(" / "))?;
4043    emitter.inline(WordPressPresentationInline::Count(
4044        evaluation
4045            .version_evidence_resolution
4046            .distinct_spelling_count,
4047    ))?;
4048    emitter.end_field()?;
4049    if external.comparison_profile.is_some() {
4050        wordpress_optional_code_field(
4051            emitter,
4052            "Semantic version resolution",
4053            evaluation.version_evidence_resolution.semantic_status,
4054        )?;
4055        wordpress_optional_code_field(
4056            emitter,
4057            "Semantic resolution reason",
4058            evaluation.version_evidence_resolution.semantic_reason,
4059        )?;
4060    }
4061    wordpress_code_field(emitter, "Component evidence", evaluation.component_evidence)?;
4062    wordpress_optional_code_field(emitter, "CVE", evaluation.cve.as_deref())?;
4063    wordpress_optional_code_field(
4064        emitter,
4065        "Source CVE reference",
4066        evaluation.cve_link.as_deref(),
4067    )?;
4068    if let Some(cwe) = &evaluation.cwe {
4069        emitter.begin_field("Source CWE metadata")?;
4070        emitter.inline(WordPressPresentationInline::Literal("CWE-"))?;
4071        emitter.inline(WordPressPresentationInline::Bytes(u64::from(cwe.id)))?;
4072        emitter.inline(WordPressPresentationInline::Literal(" / "))?;
4073        emitter.inline(WordPressPresentationInline::Code(&cwe.name))?;
4074        emitter.end_field()?;
4075        wordpress_code_field(emitter, "Source CWE description", &cwe.description)?;
4076    } else {
4077        wordpress_code_field(emitter, "Source CWE metadata", "not declared")?;
4078    }
4079    if let Some(cvss) = &evaluation.cvss {
4080        emitter.begin_field("Source-declared CVSS metadata")?;
4081        emitter.inline(WordPressPresentationInline::Code(&cvss.score))?;
4082        emitter.inline(WordPressPresentationInline::Literal(" / "))?;
4083        emitter.inline(WordPressPresentationInline::Code(cvss.rating))?;
4084        emitter.inline(WordPressPresentationInline::Literal(" / "))?;
4085        emitter.inline(WordPressPresentationInline::Code(&cvss.vector))?;
4086        emitter.end_field()?;
4087    } else {
4088        wordpress_code_field(emitter, "Source-declared CVSS metadata", "not declared")?;
4089    }
4090    wordpress_optional_code_field(
4091        emitter,
4092        "Source published date",
4093        evaluation.source_dates.published.as_deref(),
4094    )?;
4095    wordpress_optional_code_field(
4096        emitter,
4097        "Source updated date",
4098        evaluation.source_dates.updated.as_deref(),
4099    )?;
4100    write_wordpress_values(
4101        emitter,
4102        "Source bibliography",
4103        evaluation.references.iter().map(String::as_str),
4104        "none declared",
4105    )?;
4106    write_wordpress_values(
4107        emitter,
4108        "Source researchers",
4109        evaluation.researchers.iter().map(String::as_str),
4110        "none declared",
4111    )?;
4112    write_wordpress_values(
4113        emitter,
4114        "Source notice identifiers",
4115        evaluation.notice_ids.iter().map(String::as_str),
4116        "none declared",
4117    )?;
4118    if evaluation.affected_ranges.is_empty() {
4119        wordpress_code_field(emitter, "Source-declared affected ranges", "none declared")?;
4120    } else {
4121        emitter.begin_list_field("Source-declared affected ranges")?;
4122        for (index, range) in evaluation.affected_ranges.iter().enumerate() {
4123            emitter.begin_list_item()?;
4124            emitter.inline(WordPressPresentationInline::Code(&range.label))?;
4125            emitter.inline(WordPressPresentationInline::Literal(": "))?;
4126            emitter.inline(WordPressPresentationInline::Code(range.from_kind))?;
4127            emitter.inline(WordPressPresentationInline::Literal(" "))?;
4128            emitter.inline(WordPressPresentationInline::Code(&range.from_version))?;
4129            emitter.inline(WordPressPresentationInline::Literal(
4130                if range.from_inclusive {
4131                    " inclusive to "
4132                } else {
4133                    " exclusive to "
4134                },
4135            ))?;
4136            emitter.inline(WordPressPresentationInline::Code(range.to_kind))?;
4137            emitter.inline(WordPressPresentationInline::Literal(" "))?;
4138            emitter.inline(WordPressPresentationInline::Code(&range.to_version))?;
4139            emitter.inline(WordPressPresentationInline::Literal(
4140                if range.to_inclusive {
4141                    " inclusive"
4142                } else {
4143                    " exclusive"
4144                },
4145            ))?;
4146            if let Some(interpreted) = evaluation
4147                .range_evaluations
4148                .as_ref()
4149                .and_then(|ranges| ranges.get(index))
4150            {
4151                emitter.inline(WordPressPresentationInline::Literal("; Termivar relation "))?;
4152                emitter.inline(WordPressPresentationInline::Code(interpreted.relation))?;
4153                emitter.inline(WordPressPresentationInline::Literal(" ("))?;
4154                emitter.inline(WordPressPresentationInline::Code(interpreted.reason))?;
4155                emitter.inline(WordPressPresentationInline::Literal(")"))?;
4156            }
4157            emitter.end_list_item()?;
4158        }
4159        emitter.end_list_field()?;
4160    }
4161    wordpress_bool_field(emitter, "Source patched flag", evaluation.source_patched)?;
4162    write_wordpress_values(
4163        emitter,
4164        "Source-declared patched versions",
4165        evaluation
4166            .source_patched_versions
4167            .iter()
4168            .map(String::as_str),
4169        "none declared",
4170    )?;
4171    wordpress_code_field(
4172        emitter,
4173        "Source-declared remediation",
4174        &evaluation.source_remediation,
4175    )?;
4176    emitter.end_record()
4177}
4178
4179#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4180fn write_wordpress_external_identity_limitation(
4181    emitter: &mut WordPressPresentationEmitter<'_>,
4182    limitation: &WordPressExternalIdentityLimitationDocument,
4183) -> Result<(), ReportError> {
4184    emitter.begin_record()?;
4185    emitter.inline(WordPressPresentationInline::Code(
4186        &limitation.key.upstream_id,
4187    ))?;
4188    emitter.inline(WordPressPresentationInline::Literal(" — "))?;
4189    emitter.inline(WordPressPresentationInline::Code(
4190        limitation.key.source_component.kind,
4191    ))?;
4192    emitter.inline(WordPressPresentationInline::Literal(":"))?;
4193    emitter.inline(WordPressPresentationInline::Code(
4194        &limitation.key.source_component.slug,
4195    ))?;
4196    emitter.inline(WordPressPresentationInline::Literal(" — "))?;
4197    emitter.inline(WordPressPresentationInline::Code(limitation.applicability))?;
4198    emitter.end_record_title()?;
4199    wordpress_code_field(emitter, "Source namespace", limitation.key.source_namespace)?;
4200    wordpress_code_field(
4201        emitter,
4202        "Raw source component identity",
4203        &format!(
4204            "{}:{}",
4205            limitation.key.source_component.kind, limitation.key.source_component.slug
4206        ),
4207    )?;
4208    wordpress_code_field(
4209        emitter,
4210        "Identity resolution",
4211        limitation.identity_resolution,
4212    )?;
4213    wordpress_code_field(emitter, "Source title", &limitation.title)?;
4214    wordpress_code_field(emitter, "Source component name", &limitation.display_name)?;
4215    wordpress_code_field(emitter, "Applicability", limitation.applicability)?;
4216    wordpress_code_field(emitter, "Version relation", limitation.version_relation)?;
4217    wordpress_code_field(
4218        emitter,
4219        "Version-relation reason",
4220        limitation.version_relation_reason,
4221    )?;
4222    write_wordpress_values(
4223        emitter,
4224        "Source bibliography",
4225        limitation.references.iter().map(String::as_str),
4226        "none declared",
4227    )?;
4228    write_wordpress_values(
4229        emitter,
4230        "Source notice identifiers",
4231        limitation.notice_ids.iter().map(String::as_str),
4232        "none declared",
4233    )?;
4234    if limitation.affected_ranges.is_empty() {
4235        wordpress_code_field(emitter, "Source-declared affected ranges", "none declared")?;
4236    } else {
4237        emitter.begin_list_field("Source-declared affected ranges")?;
4238        for (range, interpretation) in limitation
4239            .affected_ranges
4240            .iter()
4241            .zip(&limitation.range_evaluations)
4242        {
4243            emitter.begin_list_item()?;
4244            emitter.inline(WordPressPresentationInline::Code(&range.label))?;
4245            emitter.inline(WordPressPresentationInline::Literal(": "))?;
4246            emitter.inline(WordPressPresentationInline::Code(range.from_kind))?;
4247            emitter.inline(WordPressPresentationInline::Literal(" "))?;
4248            emitter.inline(WordPressPresentationInline::Code(&range.from_version))?;
4249            emitter.inline(WordPressPresentationInline::Literal(
4250                if range.from_inclusive {
4251                    " inclusive to "
4252                } else {
4253                    " exclusive to "
4254                },
4255            ))?;
4256            emitter.inline(WordPressPresentationInline::Code(range.to_kind))?;
4257            emitter.inline(WordPressPresentationInline::Literal(" "))?;
4258            emitter.inline(WordPressPresentationInline::Code(&range.to_version))?;
4259            emitter.inline(WordPressPresentationInline::Literal(
4260                if range.to_inclusive {
4261                    " inclusive"
4262                } else {
4263                    " exclusive"
4264                },
4265            ))?;
4266            emitter.inline(WordPressPresentationInline::Literal("; Termivar relation "))?;
4267            emitter.inline(WordPressPresentationInline::Code(interpretation.relation))?;
4268            emitter.inline(WordPressPresentationInline::Literal(" ("))?;
4269            emitter.inline(WordPressPresentationInline::Code(interpretation.reason))?;
4270            emitter.inline(WordPressPresentationInline::Literal(")"))?;
4271            emitter.end_list_item()?;
4272        }
4273        emitter.end_list_field()?;
4274    }
4275    wordpress_bool_field(emitter, "Source patched flag", limitation.source_patched)?;
4276    write_wordpress_values(
4277        emitter,
4278        "Source-declared patched versions",
4279        limitation
4280            .source_patched_versions
4281            .iter()
4282            .map(String::as_str),
4283        "none declared",
4284    )?;
4285    wordpress_code_field(
4286        emitter,
4287        "Source-declared remediation",
4288        &limitation.source_remediation,
4289    )?;
4290    emitter.end_record()
4291}
4292
4293#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4294fn wordpress_code_field(
4295    emitter: &mut WordPressPresentationEmitter<'_>,
4296    label: &'static str,
4297    value: &str,
4298) -> Result<(), ReportError> {
4299    emitter.begin_field(label)?;
4300    emitter.inline(WordPressPresentationInline::Code(value))?;
4301    emitter.end_field()
4302}
4303
4304#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4305fn wordpress_optional_code_field(
4306    emitter: &mut WordPressPresentationEmitter<'_>,
4307    label: &'static str,
4308    value: Option<&str>,
4309) -> Result<(), ReportError> {
4310    wordpress_code_field(emitter, label, value.unwrap_or("not declared"))
4311}
4312
4313#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4314fn wordpress_count_field(
4315    emitter: &mut WordPressPresentationEmitter<'_>,
4316    label: &'static str,
4317    value: usize,
4318) -> Result<(), ReportError> {
4319    emitter.begin_field(label)?;
4320    emitter.inline(WordPressPresentationInline::Count(value))?;
4321    emitter.end_field()
4322}
4323
4324#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4325fn wordpress_bytes_field(
4326    emitter: &mut WordPressPresentationEmitter<'_>,
4327    label: &'static str,
4328    value: u64,
4329) -> Result<(), ReportError> {
4330    emitter.begin_field(label)?;
4331    emitter.inline(WordPressPresentationInline::Bytes(value))?;
4332    emitter.end_field()
4333}
4334
4335#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4336fn wordpress_bool_field(
4337    emitter: &mut WordPressPresentationEmitter<'_>,
4338    label: &'static str,
4339    value: bool,
4340) -> Result<(), ReportError> {
4341    emitter.begin_field(label)?;
4342    emitter.inline(WordPressPresentationInline::Bool(value))?;
4343    emitter.end_field()
4344}
4345
4346#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4347fn write_wordpress_values<'a, I>(
4348    emitter: &mut WordPressPresentationEmitter<'_>,
4349    label: &'static str,
4350    values: I,
4351    empty: &'static str,
4352) -> Result<(), ReportError>
4353where
4354    I: IntoIterator<Item = &'a str>,
4355{
4356    let mut values = values.into_iter();
4357    let Some(first) = values.next() else {
4358        return wordpress_code_field(emitter, label, empty);
4359    };
4360    emitter.begin_list_field(label)?;
4361    emitter.begin_list_item()?;
4362    emitter.inline(WordPressPresentationInline::Code(first))?;
4363    emitter.end_list_item()?;
4364    for value in values {
4365        emitter.begin_list_item()?;
4366        emitter.inline(WordPressPresentationInline::Code(value))?;
4367        emitter.end_list_item()?;
4368    }
4369    emitter.end_list_field()
4370}
4371
4372#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4373impl WordPressPresentationEmitter<'_> {
4374    fn inline(&mut self, value: WordPressPresentationInline<'_>) -> Result<(), ReportError> {
4375        match self {
4376            Self::Html(output) => match value {
4377                WordPressPresentationInline::Literal(value) => output.push_str(value),
4378                WordPressPresentationInline::Code(value) => {
4379                    output.push_str("<code>")?;
4380                    write_html_text(output, value)?;
4381                    output.push_str("</code>")
4382                },
4383                WordPressPresentationInline::Count(value) => {
4384                    output.push_fmt(format_args!("{value}"))
4385                },
4386                WordPressPresentationInline::Bytes(value) => {
4387                    output.push_fmt(format_args!("{value}"))
4388                },
4389                WordPressPresentationInline::Bool(value) => {
4390                    output.push_str(if value { "true" } else { "false" })
4391                },
4392            },
4393            Self::Markdown(output) => match value {
4394                WordPressPresentationInline::Literal(value) => output.push_str(value),
4395                WordPressPresentationInline::Code(value) => write_markdown_code_span(output, value),
4396                WordPressPresentationInline::Count(value) => {
4397                    output.push_fmt(format_args!("{value}"))
4398                },
4399                WordPressPresentationInline::Bytes(value) => {
4400                    output.push_fmt(format_args!("{value}"))
4401                },
4402                WordPressPresentationInline::Bool(value) => {
4403                    output.push_str(if value { "true" } else { "false" })
4404                },
4405            },
4406        }
4407    }
4408
4409    fn heading(&mut self, title: &'static str) -> Result<(), ReportError> {
4410        match self {
4411            Self::Html(output) => {
4412                output.push_str("<h3>")?;
4413                write_html_text(output, title)?;
4414                output.push_str("</h3>")
4415            },
4416            Self::Markdown(output) => {
4417                output.push_str("\n#### ")?;
4418                output.push_str(title)?;
4419                output.push_str("\n\n")
4420            },
4421        }
4422    }
4423
4424    fn paragraph(&mut self, text: &'static str) -> Result<(), ReportError> {
4425        match self {
4426            Self::Html(output) => {
4427                output.push_str("<p>")?;
4428                write_html_text(output, text)?;
4429                output.push_str("</p>")
4430            },
4431            Self::Markdown(output) => {
4432                output.push_str(text)?;
4433                output.push_str("\n\n")
4434            },
4435        }
4436    }
4437
4438    fn note(&mut self, text: &'static str) -> Result<(), ReportError> {
4439        match self {
4440            Self::Html(output) => {
4441                output.push_str("<p class=\"wp-note\">")?;
4442                write_html_text(output, text)?;
4443                output.push_str("</p>")
4444            },
4445            Self::Markdown(output) => {
4446                output.push_str("\n> ")?;
4447                output.push_str(text)?;
4448                output.push_str("\n\n")
4449            },
4450        }
4451    }
4452
4453    fn overview(&mut self, cards: &[(&'static str, usize)]) -> Result<(), ReportError> {
4454        match self {
4455            Self::Html(output) => {
4456                output.push_str("<div class=\"wp-summary\">")?;
4457                for (label, count) in cards {
4458                    output.push_str("<div class=\"wp-card\"><strong>")?;
4459                    output.push_fmt(format_args!("{count}"))?;
4460                    output.push_str("</strong><span>")?;
4461                    write_html_text(output, label)?;
4462                    output.push_str("</span></div>")?;
4463                }
4464                output.push_str("</div>")
4465            },
4466            Self::Markdown(output) => {
4467                output.push_str("### Overview\n\n")?;
4468                for (label, count) in cards {
4469                    output.push_fmt(format_args!("- {label}: `{count}`\n"))?;
4470                }
4471                output.push_char('\n')
4472            },
4473        }
4474    }
4475
4476    fn begin_fields(&mut self) -> Result<(), ReportError> {
4477        match self {
4478            Self::Html(output) => output.push_str("<dl class=\"meta\">"),
4479            Self::Markdown(_) => Ok(()),
4480        }
4481    }
4482
4483    fn end_fields(&mut self) -> Result<(), ReportError> {
4484        match self {
4485            Self::Html(output) => output.push_str("</dl>"),
4486            Self::Markdown(output) => output.push_char('\n'),
4487        }
4488    }
4489
4490    fn begin_field(&mut self, label: &'static str) -> Result<(), ReportError> {
4491        match self {
4492            Self::Html(output) => {
4493                output.push_str("<dt>")?;
4494                write_html_text(output, label)?;
4495                output.push_str("</dt><dd>")
4496            },
4497            Self::Markdown(output) => output.push_fmt(format_args!("- {label}: ")),
4498        }
4499    }
4500
4501    fn end_field(&mut self) -> Result<(), ReportError> {
4502        match self {
4503            Self::Html(output) => output.push_str("</dd>"),
4504            Self::Markdown(output) => output.push_char('\n'),
4505        }
4506    }
4507
4508    fn begin_list_field(&mut self, label: &'static str) -> Result<(), ReportError> {
4509        match self {
4510            Self::Html(output) => {
4511                output.push_str("<dt>")?;
4512                write_html_text(output, label)?;
4513                output.push_str("</dt><dd><ul>")
4514            },
4515            Self::Markdown(output) => output.push_fmt(format_args!("- {label}:\n")),
4516        }
4517    }
4518
4519    fn begin_list_item(&mut self) -> Result<(), ReportError> {
4520        match self {
4521            Self::Html(output) => output.push_str("<li>"),
4522            Self::Markdown(output) => output.push_str("  - "),
4523        }
4524    }
4525
4526    fn end_list_item(&mut self) -> Result<(), ReportError> {
4527        match self {
4528            Self::Html(output) => output.push_str("</li>"),
4529            Self::Markdown(output) => output.push_char('\n'),
4530        }
4531    }
4532
4533    fn end_list_field(&mut self) -> Result<(), ReportError> {
4534        match self {
4535            Self::Html(output) => output.push_str("</ul></dd>"),
4536            Self::Markdown(_) => Ok(()),
4537        }
4538    }
4539
4540    fn begin_record(&mut self) -> Result<(), ReportError> {
4541        match self {
4542            Self::Html(output) => output.push_str("<details class=\"wp-detail\" open><summary>"),
4543            Self::Markdown(output) => output.push_str("#### "),
4544        }
4545    }
4546
4547    fn end_record_title(&mut self) -> Result<(), ReportError> {
4548        match self {
4549            Self::Html(output) => output.push_str("</summary><dl>"),
4550            Self::Markdown(output) => output.push_str("\n\n"),
4551        }
4552    }
4553
4554    fn end_record(&mut self) -> Result<(), ReportError> {
4555        match self {
4556            Self::Html(output) => output.push_str("</dl></details>"),
4557            Self::Markdown(output) => output.push_char('\n'),
4558        }
4559    }
4560
4561    fn empty(&mut self, text: &'static str) -> Result<(), ReportError> {
4562        match self {
4563            Self::Html(output) => {
4564                output.push_str("<p class=\"empty\">")?;
4565                write_html_text(output, text)?;
4566                output.push_str("</p>")
4567            },
4568            Self::Markdown(output) => {
4569                output.push_str(text)?;
4570                output.push_char('\n')
4571            },
4572        }
4573    }
4574}
4575#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
4576fn write_html_wordpress_external_attribution(
4577    output: &mut RenderBuffer,
4578    audit: &AssessmentWordPressAuditDocument,
4579) -> Result<(), ReportError> {
4580    let Some(external) = &audit.external_review else {
4581        return Ok(());
4582    };
4583    output.push_str("<h3>External source attribution</h3><ul>")?;
4584    for evaluation in &external.evaluations {
4585        let Some(reference) = &evaluation.record_reference else {
4586            continue;
4587        };
4588        output.push_str("<li><code>")?;
4589        write_html_text(output, &evaluation.key.upstream_id)?;
4590        output.push_str("</code> <a rel=\"noreferrer noopener\" href=\"")?;
4591        write_html_text(output, reference)?;
4592        output.push_str("\">Selected source reference</a></li>")?;
4593    }
4594    output.push_str("</ul><h3>Rights notices</h3><ul>")?;
4595    for notice in &external.notices {
4596        output.push_str("<li><strong>Party:</strong> <code>")?;
4597        write_html_text(output, &notice.party)?;
4598        output.push_str("</code><br><strong>Message:</strong> <code>")?;
4599        write_html_text(output, &notice.message)?;
4600        output.push_str("</code><br><strong>Notice:</strong> <code>")?;
4601        write_html_text(output, &notice.notice)?;
4602        output.push_str("</code><br><strong>License:</strong> <code>")?;
4603        write_html_text(output, &notice.license)?;
4604        output.push_str("</code>")?;
4605        if is_strict_https_reference(&notice.license_url) {
4606            output.push_str(" <a rel=\"noreferrer noopener\" href=\"")?;
4607            write_html_text(output, &notice.license_url)?;
4608            output.push_str("\">License terms</a>")?;
4609        } else {
4610            output.push_str("<br><strong>License URL:</strong> <code>")?;
4611            write_html_text(output, &notice.license_url)?;
4612            output.push_str("</code>")?;
4613        }
4614        output.push_str("</li>")?;
4615    }
4616    output.push_str("</ul>")
4617}
4618
4619#[cfg(feature = "scanning")]
4620fn write_markdown_actionable_code_field(
4621    output: &mut RenderBuffer,
4622    label: &'static str,
4623    value: &str,
4624) -> Result<(), ReportError> {
4625    output.push_fmt(format_args!("- {label}: "))?;
4626    write_markdown_code_span(output, value)?;
4627    output.push_char('\n')
4628}
4629
4630#[cfg(feature = "scanning")]
4631fn write_markdown_actionable_text_field(
4632    output: &mut RenderBuffer,
4633    label: &'static str,
4634    value: &str,
4635) -> Result<(), ReportError> {
4636    output.push_fmt(format_args!("- {label}: "))?;
4637    write_markdown_code_span(output, value)?;
4638    output.push_char('\n')
4639}
4640
4641#[cfg(feature = "scanning")]
4642fn write_assessment_decision_overview_markdown(
4643    output: &mut RenderBuffer,
4644    document: &AssessmentDocument<'_>,
4645) -> Result<(), ReportError> {
4646    let overview = AssessmentDecisionOverview::from_document(document)?;
4647    output.push_str(
4648        "\n## Decision overview\n\nThese counts describe typed assessment items, not a count of confirmed vulnerabilities. Unassigned or unknown severity is not low or zero severity.\n\n",
4649    )?;
4650    output.push_fmt(format_args!(
4651        "- Verifier-bound confirmed items: `{}`\n- Needs-review candidates: `{}`\n- Informational observations: `{}`\n\n",
4652        overview.confirmed, overview.needs_review, overview.informational
4653    ))?;
4654    output.push_str("**Next decision:** ")?;
4655    output.push_str(overview.priority_statement())?;
4656    output.push_char('\n')
4657}
4658
4659#[cfg(feature = "scanning")]
4660fn write_assessment_actionable_items_markdown(
4661    output: &mut RenderBuffer,
4662    document: &AssessmentDocument<'_>,
4663) -> Result<(), ReportError> {
4664    output.push_str("\n## Actionable items\n\n")?;
4665    if document.items.is_empty() {
4666        output.push_str(
4667            "No assessment items were projected. This is not evidence that the application is secure or that assessment coverage was complete.\n",
4668        )?;
4669        return Ok(());
4670    }
4671    let collection_context = assessment_item_collection_context(document);
4672    let mut index = 0usize;
4673    for disposition in ACTIONABLE_ITEM_DISPOSITION_ORDER {
4674        for item in document
4675            .items
4676            .iter()
4677            .filter(|item| item.disposition == disposition)
4678        {
4679            index = index.checked_add(1).ok_or(ReportError::Serialization)?;
4680            output.push_fmt(format_args!("### Actionable item {index}: "))?;
4681            write_markdown_code_span(output, item.title)?;
4682            output.push_str("\n\n")?;
4683            write_markdown_actionable_code_field(output, "Disposition", item.disposition)?;
4684            write_markdown_actionable_text_field(
4685                output,
4686                "What was observed",
4687                item.redacted_summary,
4688            )?;
4689            write_markdown_actionable_code_field(
4690                output,
4691                "Opaque assessment subject reference (not proof of affectedness or location)",
4692                &item.subject_reference,
4693            )?;
4694            write_markdown_actionable_code_field(
4695                output,
4696                "Assessment target kind (resource location withheld)",
4697                item.presentation_target,
4698            )?;
4699            write_markdown_actionable_text_field(
4700                output,
4701                "Collection and principal context",
4702                collection_context,
4703            )?;
4704            write_markdown_actionable_text_field(
4705                output,
4706                "Interpretation",
4707                assessment_item_interpretation(item)?,
4708            )?;
4709            write_markdown_actionable_text_field(
4710                output,
4711                "What was not established",
4712                assessment_item_unestablished(item)?,
4713            )?;
4714            write_markdown_actionable_text_field(
4715                output,
4716                "Recommended action (not a verified fix)",
4717                item.remediation.summary,
4718            )?;
4719            write_markdown_actionable_text_field(
4720                output,
4721                "Safe verification guidance",
4722                assessment_item_verification_guidance(item)?,
4723            )?;
4724            write_markdown_actionable_code_field(
4725                output,
4726                "Severity",
4727                item.severity.unwrap_or("not assigned"),
4728            )?;
4729            if item.severity.is_none() {
4730                output.push_str("  Unassigned severity does not mean low or zero.\n")?;
4731            }
4732            write_markdown_actionable_code_field(
4733                output,
4734                "Confidence",
4735                &format!("{} ppm", item.confidence_ppm),
4736            )?;
4737            output.push_str(
4738                "  Confidence is evidence-specific; it is not CVSS, impact, or exploit probability.\n",
4739            )?;
4740            write_markdown_actionable_code_field(output, "Claim basis", item.claim_basis)?;
4741            write_markdown_actionable_code_field(output, "Item schema", item.schema)?;
4742            write_markdown_actionable_code_field(output, "Capability", item.capability_id)?;
4743            write_markdown_actionable_code_field(output, "Category", item.category)?;
4744            write_markdown_actionable_code_field(
4745                output,
4746                "CWE",
4747                item.cwe.unwrap_or("not applicable"),
4748            )?;
4749            write_markdown_actionable_code_field(output, "Item fingerprint", item.fingerprint)?;
4750            write_markdown_actionable_code_field(
4751                output,
4752                "Evidence reference count",
4753                &item.evidence_count.to_string(),
4754            )?;
4755            write_markdown_actionable_code_field(output, "Recommendation ID", item.remediation.id)?;
4756            write_markdown_actionable_code_field(
4757                output,
4758                "Direct evidence references",
4759                &assessment_reference_list(&item.evidence_references),
4760            )?;
4761            write_markdown_actionable_code_field(
4762                output,
4763                "Control evidence references",
4764                &assessment_reference_list(&item.control_evidence_references),
4765            )?;
4766            write_markdown_actionable_code_field(
4767                output,
4768                "Candidate evidence references",
4769                &assessment_reference_list(&item.candidate_evidence_references),
4770            )?;
4771            write_markdown_actionable_code_field(
4772                output,
4773                "Verifier case reference",
4774                item.case_reference.as_deref().unwrap_or("not applicable"),
4775            )?;
4776            write_markdown_actionable_code_field(
4777                output,
4778                "Verifier outcome reference",
4779                item.outcome_reference
4780                    .as_deref()
4781                    .unwrap_or("not applicable"),
4782            )?;
4783            write_markdown_actionable_code_field(
4784                output,
4785                "Verification stage",
4786                item.verification_stage.unwrap_or("not applicable"),
4787            )?;
4788            output.push_char('\n')?;
4789        }
4790    }
4791    if index != document.items.len() {
4792        return Err(ReportError::Serialization);
4793    }
4794    Ok(())
4795}
4796
4797#[cfg(feature = "scanning")]
4798fn render_assessment_markdown(
4799    document: &AssessmentDocument<'_>,
4800    limit: usize,
4801) -> Result<String, ReportError> {
4802    let mut output = RenderBuffer::new(limit);
4803    output.push_str("# Termivar assessment report\n\n")?;
4804    write_assessment_decision_overview_markdown(&mut output, document)?;
4805    write_assessment_actionable_items_markdown(&mut output, document)?;
4806    output.push_str(
4807        "\n## Technical audit appendix\n\nAvailable report metadata and selected capability/source audit details follow, including their local accounting when present. These records preserve stated context and limitations; report integrity does not establish target truth or remediation.\n\n### Report metadata\n\n",
4808    )?;
4809    for (label, value) in document.metadata() {
4810        output.push_fmt(format_args!("- {label}: "))?;
4811        write_markdown_code_span(&mut output, &value)?;
4812        output.push_char('\n')?;
4813    }
4814    #[cfg(feature = "supplied-session-review")]
4815    if let Some(audit) = &document.supplied_session {
4816        output.push_str("\n### Supplied session assessment audit\n\n")?;
4817        for (label, value) in audit.metadata() {
4818            output.push_fmt(format_args!("- {label}: "))?;
4819            write_markdown_code_span(&mut output, &value)?;
4820            output.push_char('\n')?;
4821        }
4822        output.push_str("\n#### Health checkpoints\n\n")?;
4823        for checkpoint in &audit.checkpoints {
4824            output.push_str("- ")?;
4825            write_markdown_code_span(
4826                &mut output,
4827                &checkpoint
4828                    .human_summary(audit.schema == SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA),
4829            )?;
4830            output.push_char('\n')?;
4831        }
4832        if let Some(health) = audit
4833            .oauth_refresh
4834            .as_ref()
4835            .and_then(|refresh| refresh.health())
4836        {
4837            output.push_str("\n#### OAuth renewal health checkpoint\n\n- ")?;
4838            let health = AssessmentSuppliedSessionCheckpointDocument::from_checkpoint(health, true);
4839            write_markdown_code_span(&mut output, &health.human_summary(true))?;
4840            output.push_char('\n')?;
4841        }
4842        output.push_str("\n#### Protected resources\n\n")?;
4843        for resource in &audit.resources {
4844            let status = resource
4845                .status
4846                .map_or_else(|| "not_available".to_owned(), |status| status.to_string());
4847            let evidence_reference = resource
4848                .evidence_reference
4849                .as_deref()
4850                .unwrap_or("not_available");
4851            output.push_str("- ")?;
4852            write_markdown_code_span(
4853                &mut output,
4854                &format!(
4855                    "sequence={};resource_reference={};evidence_reference={};outcome={};status={};response_bytes={};epoch={}",
4856                    resource.sequence,
4857                    resource.resource_reference,
4858                    evidence_reference,
4859                    resource.outcome,
4860                    status,
4861                    resource.response_bytes,
4862                    resource.epoch,
4863                ),
4864            )?;
4865            output.push_char('\n')?;
4866        }
4867    }
4868    #[cfg(feature = "authorization-review")]
4869    if let Some(audit) = &document.authorization_review {
4870        output.push_str("\n### Resource authorization review audit\n\n")?;
4871        for (label, value) in audit.metadata() {
4872            output.push_fmt(format_args!("- {label}: "))?;
4873            write_markdown_code_span(&mut output, &value)?;
4874            output.push_char('\n')?;
4875        }
4876    }
4877    #[cfg(feature = "websocket-review")]
4878    if let Some(audit) = &document.websocket_review {
4879        output.push_str("\n### WebSocket protocol review audit\n\n")?;
4880        if audit.has_supplied_session_context() {
4881            output.push_str(
4882                "This audit separates bounded protocol-exchange completion from supplied-session health qualification. Healthy startup and terminal checkpoints qualify only the declared observation context; they do not establish continuous authentication, authenticated identity, authorization, vulnerability, impact, or remediation. It retains no credential, payload, raw URL, path, query, subprotocol token, body-derived mismatch reference, or backend error. Physical continuation-frame counts are unavailable. Transport bytes are raw socket bytes including the HTTP Upgrade and WebSocket framing; for WSS they are encrypted TLS records, not application bytes.\n\n",
4883            )?;
4884        } else {
4885            output.push_str(
4886                "This audit describes one bounded, anonymous, operator-configured HTTP/1 Upgrade exchange. It is not a finding and provides no remediation inference. It retains no payload, raw URL, path, query, subprotocol token, body-derived mismatch reference, or backend error. Physical continuation-frame counts are unavailable. Transport bytes are raw socket bytes including the HTTP Upgrade and WebSocket framing; for WSS they are encrypted TLS records, not application bytes.\n\n",
4887            )?;
4888        }
4889        for (label, value) in audit.metadata() {
4890            output.push_fmt(format_args!("- {label}: "))?;
4891            write_markdown_code_span(&mut output, &value)?;
4892            output.push_char('\n')?;
4893        }
4894        output.push_str("\n#### Configured exchange audit\n\n")?;
4895        for message in &audit.messages {
4896            output.push_str("- ")?;
4897            write_markdown_code_span(
4898                &mut output,
4899                &format!(
4900                    "message_reference={};expected_response_reference={};outbound_length={};inbound_length={};status={}",
4901                    message.message_reference,
4902                    message.expected_response_reference,
4903                    message.outbound_length,
4904                    message
4905                        .inbound_length
4906                        .map_or_else(|| "not_available".to_owned(), |length| length.to_string()),
4907                    message.status,
4908                ),
4909            )?;
4910            output.push_char('\n')?;
4911        }
4912        output.push_str("\n#### Interpretation limits\n\n")?;
4913        for limit in &audit.claim_limits {
4914            output.push_str("- ")?;
4915            write_markdown_code_span(&mut output, limit)?;
4916            output.push_char('\n')?;
4917        }
4918    }
4919    #[cfg(feature = "xml-external-entity-review")]
4920    if let Some(audit) = &document.xml_external_entity_review {
4921        output.push_str(
4922            "\n### Controlled XML external-entity review audit\n\nThis audit records a bounded callback-control review without retaining endpoint URLs or paths, provider origins, callback values, XML bodies, system literals, tokens, response bodies, or raw errors. Repeated candidate and replay interactions remain review-level evidence: they do not confirm XXE, identify a parser, prove file read, data exfiltration, internal-network access, semantic effect, or impact.\n\n",
4923        )?;
4924        for (label, value) in audit.metadata() {
4925            output.push_fmt(format_args!("- {label}: "))?;
4926            write_markdown_code_span(&mut output, &value)?;
4927            output.push_char('\n')?;
4928        }
4929        output.push_str("\n#### Claim limits\n\n")?;
4930        for limit in &audit.claim_limits {
4931            output.push_str("- ")?;
4932            write_markdown_code_span(&mut output, limit)?;
4933            output.push_char('\n')?;
4934        }
4935    }
4936    #[cfg(feature = "template-evaluation-review")]
4937    if let Some(audit) = &document.template_evaluation_review {
4938        output.push_str(
4939            "\n### Template-evaluation review audit\n\nThis audit records bounded, benign primary/replay differential semantics without retaining expressions, query values, URLs, response bodies, template-engine errors, or raw transport errors. Candidate-specific evaluation does not establish a template-engine identity, operating-system execution, file access, outbound interaction, vulnerability confirmation, or impact.\n\n",
4940        )?;
4941        for (label, value) in audit.metadata() {
4942            output.push_fmt(format_args!("- {label}: "))?;
4943            write_markdown_code_span(&mut output, &value)?;
4944            output.push_char('\n')?;
4945        }
4946        output.push_str("\n#### Claim limits\n\n")?;
4947        for limit in &audit.claim_limits {
4948            output.push_str("- ")?;
4949            write_markdown_code_span(&mut output, limit)?;
4950            output.push_char('\n')?;
4951        }
4952    }
4953    #[cfg(feature = "openapi-review")]
4954    if let Some(audit) = &document.openapi_review {
4955        output.push_str("\n### OpenAPI review audit\n\n")?;
4956        for (label, value) in audit.metadata() {
4957            output.push_fmt(format_args!("- {label}: "))?;
4958            write_markdown_code_span(&mut output, &value)?;
4959            output.push_char('\n')?;
4960        }
4961    }
4962    #[cfg(feature = "rest-review")]
4963    if let Some(audit) = &document.rest_review {
4964        output.push_str("\n### REST read-only review audit\n\n")?;
4965        for (label, value) in audit.metadata() {
4966            output.push_fmt(format_args!("- {label}: "))?;
4967            write_markdown_code_span(&mut output, &value)?;
4968            output.push_char('\n')?;
4969        }
4970    }
4971    #[cfg(feature = "secret-exposure-review")]
4972    if let Some(audit) = &document.secret_exposure_review {
4973        output.push_str(
4974            "\n### Passive response secret-exposure review audit\n\nDetector observations are redacted response-content classifications. They do not establish source authenticity, secret validity, provider acceptance, exploitability, or impact.\n\n",
4975        )?;
4976        for (label, value) in audit.metadata() {
4977            output.push_fmt(format_args!("- {label}: "))?;
4978            write_markdown_code_span(&mut output, &value)?;
4979            output.push_char('\n')?;
4980        }
4981        output.push_str("\n#### Response outcomes\n\n")?;
4982        for outcome in &audit.outcomes {
4983            output.push_str("- ")?;
4984            write_markdown_code_span(
4985                &mut output,
4986                &format!("outcome={};count={}", outcome.outcome, outcome.count),
4987            )?;
4988            output.push_char('\n')?;
4989        }
4990        output.push_str("\n#### Redacted observations\n\n")?;
4991        for observation in &audit.observations {
4992            output.push_str("- ")?;
4993            write_markdown_code_span(
4994                &mut output,
4995                &format!(
4996                    "detector_class={};capability_id={};occurrence_count={};evidence_reference={}",
4997                    observation.detector_class,
4998                    observation.capability_id,
4999                    observation.occurrence_count,
5000                    observation.evidence_references[0],
5001                ),
5002            )?;
5003            output.push_char('\n')?;
5004        }
5005    }
5006    #[cfg(feature = "tls-observation")]
5007    if let Some(audit) = &document.tls_observation {
5008        output.push_str(
5009            "\n### Existing-connection TLS observation audit\n\nThis passive view describes leaf certificates exposed by successful existing HTTPS responses under the assessment's exact-origin authority. It does not attribute a certificate to a request purpose or principal. Validity status is relative to the reported local system-clock instant, whose accuracy was not independently verified. One successful connection does not enumerate server protocol or cipher support. Negotiated protocol, ALPN, cipher suite, full chain, connection reuse, session resumption, and handshake kind are unavailable from the current backend; revocation was not checked.\n\n",
5010        )?;
5011        for (label, value) in audit.metadata() {
5012            output.push_fmt(format_args!("- {label}: "))?;
5013            write_markdown_code_span(&mut output, &value)?;
5014            output.push_char('\n')?;
5015        }
5016        output.push_str("\n#### Leaf certificate observations\n\n")?;
5017        for observation in &audit.observations {
5018            output.push_str("- ")?;
5019            write_markdown_code_span(
5020                &mut output,
5021                &format!(
5022                    "sha256={};byte_length={};not_before={};not_after={};observed_at={};dns_sans={};ip_sans={};other_sans={};san_count_truncated={};time_status={};response_occurrences={};standard_transport_validation_succeeded={}",
5023                    observation.leaf_certificate_sha256,
5024                    observation.leaf_certificate_byte_length,
5025                    observation.not_before_epoch_seconds,
5026                    observation.not_after_epoch_seconds,
5027                    observation.observed_at_epoch_seconds,
5028                    observation.dns_san_count,
5029                    observation.ip_san_count,
5030                    observation.other_san_count,
5031                    observation.san_count_truncated,
5032                    observation.certificate_time_status,
5033                    observation.response_occurrence_count,
5034                    observation.standard_transport_validation_succeeded,
5035                ),
5036            )?;
5037            output.push_char('\n')?;
5038        }
5039        output.push_str("\nCertificate names and DER bytes are not retained in this report. A digest identifies observed bytes; it is not source authentication. Active negotiation, chain enumeration, revocation retrieval, exploit execution, and impact validation were not performed.\n")?;
5040    }
5041    #[cfg(feature = "tls-negotiation-review")]
5042    if let Some(audit) = &document.tls_negotiation_review {
5043        output.push_str(
5044            "\n### Active TLS negotiation review audit\n\nThis explicitly selected, finite matrix opened at most two sequential credential-free TLS connections to the exact authorized HTTPS host, SNI, and port. It sent no HTTP or application data. Negotiation establishes compatibility only for a successful offered profile; failure does not establish server non-support, and the three legacy rows were not tested because the client backend does not implement them.\n\n",
5045        )?;
5046        for (label, value) in audit.metadata() {
5047            output.push_fmt(format_args!("- {label}: "))?;
5048            write_markdown_code_span(&mut output, &value)?;
5049            output.push_char('\n')?;
5050        }
5051        output.push_str("\n#### Fixed negotiation matrix\n\n")?;
5052        for cell in &audit.capability_observations {
5053            output.push_str("- ")?;
5054            write_markdown_code_span(
5055                &mut output,
5056                &format!(
5057                    "cell={};offer={};backend={};dispatch={};outcome={};support={};transport_validation={};negotiated_protocol={};negotiated_cipher_suite={};ingress_tls_bytes={};egress_tls_bytes={}",
5058                    cell.cell_id,
5059                    cell.offered_protocol,
5060                    cell.client_backend,
5061                    cell.dispatch_status,
5062                    cell.outcome,
5063                    cell.support,
5064                    cell.transport_validation,
5065                    cell.negotiated_protocol.unwrap_or("none"),
5066                    cell.negotiated_cipher_suite.unwrap_or("none"),
5067                    cell.accounted_ingress_tls_bytes,
5068                    cell.observed_egress_tls_bytes,
5069                ),
5070            )?;
5071            output.push_char('\n')?;
5072        }
5073        output.push_str("\n#### Claim limits\n\n")?;
5074        for limit in &audit.claim_limits {
5075            output.push_str("- ")?;
5076            write_markdown_code_span(&mut output, limit)?;
5077            output.push_char('\n')?;
5078        }
5079        output.push_str("\nThe configured trust validation is not source authentication. Revocation, certificate-transparency, AIA, CRL, and OCSP retrieval, vulnerability confirmation, exploit execution, impact validation, and remediation verification were not performed.\n")?;
5080    }
5081    #[cfg(feature = "jwt-policy-review")]
5082    if let Some(audit) = &document.jwt_policy_review {
5083        #[cfg(feature = "jwt-target-acceptance-review")]
5084        if audit.target_acceptance.is_some() {
5085            output.push_str("\n### JWT policy and target-control review audit\n\nParsing, local policy consistency, local signature verification, and bounded target behavior are separate states. This value-free audit retains no token, claim value, key material, selected marker name, or raw resource URL. Target controls remain review-level and establish neither a general authentication bypass nor authorization impact.\n\n")?;
5086        } else {
5087            output.push_str("\n### Local JWT policy review audit\n\nParsing, local policy consistency, signature verification against the explicitly supplied local public key, and target acceptance are separate states. This value-free audit retains no token, claim value, or key material. It performed no target request, remote-key retrieval, or token forwarding; target acceptance and source authentication are not established.\n\n")?;
5088        }
5089        #[cfg(not(feature = "jwt-target-acceptance-review"))]
5090        output.push_str("\n### Local JWT policy review audit\n\nParsing, local policy consistency, signature verification against the explicitly supplied local public key, and target acceptance are separate states. This value-free audit retains no token, claim value, or key material. It performed no target request, remote-key retrieval, or token forwarding; target acceptance and source authentication are not established.\n\n")?;
5091        for (label, value) in audit.metadata() {
5092            output.push_fmt(format_args!("- {label}: "))?;
5093            write_markdown_code_span(&mut output, &value)?;
5094            output.push_char('\n')?;
5095        }
5096        output.push_str("\n#### Value-free policy violations\n\n")?;
5097        if audit.policy_violations.is_empty() {
5098            output.push_str("- `none`\n")?;
5099        }
5100        for violation in &audit.policy_violations {
5101            output.push_str("- ")?;
5102            let row = violation.ordinal.map_or_else(
5103                || format!("kind={}", violation.kind),
5104                |ordinal| format!("kind={};ordinal={ordinal}", violation.kind),
5105            );
5106            write_markdown_code_span(&mut output, &row)?;
5107            output.push_char('\n')?;
5108        }
5109        #[cfg(feature = "jwt-target-acceptance-review")]
5110        if let Some(target) = &audit.target_acceptance {
5111            output.push_str("\n#### Target acceptance controls\n\n")?;
5112            if target.legs.is_empty() {
5113                output.push_str("- `no target leg dispatched`\n")?;
5114            }
5115            for leg in &target.legs {
5116                output.push_str("- ")?;
5117                write_markdown_code_span(
5118                    &mut output,
5119                    &format!(
5120                        "role={};stage={};activity={};dispatch={};commit={};response={};marker={};retained_bytes={};accounted_transport_bytes={}",
5121                        leg.role,
5122                        leg.stage,
5123                        leg.activity,
5124                        leg.dispatch_status,
5125                        leg.commit_status,
5126                        leg.response_status,
5127                        leg.marker_status,
5128                        leg.retained_response_bytes,
5129                        leg.accounted_transport_response_bytes,
5130                    ),
5131                )?;
5132                output.push_char('\n')?;
5133            }
5134            output.push_char('\n')?;
5135            for limitation in target.interpretation_limits {
5136                output.push_str("- ")?;
5137                output.push_str(limitation)?;
5138                output.push_char('\n')?;
5139            }
5140        }
5141    }
5142    #[cfg(feature = "control-reference-mapping")]
5143    if let Some(audit) = &document.control_reference_mapping {
5144        output.push_str(
5145            "\n### Versioned control-reference mapping audit\n\nThis deterministic offline mapping relates eligible assessment evidence to a finite, versioned reference catalogue. It does not assess a control, determine compliance or legal noncompliance, certify an organization, authenticate a source, or turn the absence of a finding into evidence that a control is fulfilled.\n\n",
5146        )?;
5147        for (label, value) in audit.metadata() {
5148            output.push_fmt(format_args!("- {label}: "))?;
5149            write_markdown_code_span(&mut output, &value)?;
5150            output.push_char('\n')?;
5151        }
5152        output.push_str("\n#### Reference sources\n\n")?;
5153        for source in &audit.sources {
5154            output.push_str("- ")?;
5155            write_markdown_code_span(
5156                &mut output,
5157                &format!(
5158                    "source_id={};framework_id={};edition={};revision_or_amendment={};authority_kind={};mapping_availability={};rights_basis={};source_verified_at={};source_url={}",
5159                    source.source_id,
5160                    source.framework_id,
5161                    source.edition,
5162                    source.revision_or_amendment.unwrap_or("not_applicable"),
5163                    source.authority_kind,
5164                    source.mapping_availability,
5165                    source.rights_basis,
5166                    source.source_verified_at,
5167                    source.source_url,
5168                ),
5169            )?;
5170            output.push_char('\n')?;
5171        }
5172        output.push_str("\n#### Evidence-to-reference relationships\n\n")?;
5173        if audit.relationships.is_empty() {
5174            output.push_str("- `no exact mapping in the selected catalogue`\n")?;
5175        }
5176        for relationship in &audit.relationships {
5177            let cwe = relationship.cwe.as_deref().unwrap_or("not_available");
5178            for reference in &relationship.references {
5179                output.push_str("- ")?;
5180                write_markdown_code_span(
5181                    &mut output,
5182                    &format!(
5183                        "item_fingerprint={};capability_id={};cwe={};item_basis={};rule_id={};source_id={};edition={};control_reference={};control_title={};mapping_basis={};applicability={};applicability_condition={};assurance={};rationale={}",
5184                        relationship.item_fingerprint,
5185                        relationship.capability_id,
5186                        cwe,
5187                        relationship.item_basis,
5188                        reference.rule_id,
5189                        reference.source_id,
5190                        reference.edition,
5191                        reference.control_reference,
5192                        reference.control_title.unwrap_or("not_available"),
5193                        reference.mapping_basis,
5194                        reference.applicability,
5195                        reference.applicability_condition,
5196                        reference.assurance,
5197                        reference.original_mapping_rationale,
5198                    ),
5199                )?;
5200                output.push_char('\n')?;
5201            }
5202        }
5203        output.push_str("\nFramework identifiers and short titles are source-qualified references. Rights-deferred sources are bibliographic context only, and KVKK material is relevant technical context rather than legal advice or a compliance conclusion. No target or provider request was performed for this mapping.\n")?;
5204    }
5205    #[cfg(feature = "recon-snapshot-import")]
5206    if let Some(audit) = &document.recon_snapshot_import {
5207        output.push_str("\n### Offline reconnaissance snapshot import\n\nImported names, addresses, product hints, and reputation labels are source-qualified hypotheses only. The input digest identifies supplied bytes; it does not authenticate a source, establish asset ownership, authorize scanning, prove current reachability, or create a finding.\n\n")?;
5208        for (label, value) in audit.metadata() {
5209            output.push_fmt(format_args!("- {label}: "))?;
5210            write_markdown_code_span(&mut output, &value)?;
5211            output.push_char('\n')?;
5212        }
5213        output.push_str("\n#### Declared sources\n\n")?;
5214        for source in &audit.sources {
5215            output.push_str("- ")?;
5216            write_markdown_code_span(&mut output, &source.presentation())?;
5217            output.push_char('\n')?;
5218        }
5219        output.push_str("\n#### Source-qualified hypotheses\n\n")?;
5220        for record in &audit.records {
5221            output.push_str("- ")?;
5222            write_markdown_code_span(&mut output, &record.presentation())?;
5223            output.push_char('\n')?;
5224        }
5225        output.push_str("\nThis import performed no target request, provider request, archive processing, or decompression. It did not add assessment subjects or items.\n")?;
5226    }
5227    #[cfg(feature = "recon-ct-provider")]
5228    if let Some(audit) = &document.recon_certspotter {
5229        output.push_str("\n### Cert Spotter reconnaissance provider audit\n\nReturned certificate-transparency names are source-qualified hypotheses only. They are not findings and do not establish scan authority, source authentication, ownership, or currentness.\n\n")?;
5230        for (label, value) in audit.metadata() {
5231            output.push_fmt(format_args!("- {label}: "))?;
5232            write_markdown_code_span(&mut output, &value)?;
5233            output.push_char('\n')?;
5234        }
5235        output.push_str("\n#### Source-qualified hypotheses\n\n")?;
5236        if audit.hypotheses.is_empty() {
5237            output.push_str("- `none retained`\n")?;
5238        }
5239        for hypothesis in &audit.hypotheses {
5240            output.push_str("- ")?;
5241            write_markdown_code_span(&mut output, &hypothesis.presentation())?;
5242            output.push_char('\n')?;
5243        }
5244        output.push_str("\nThe fixed claim limits apply to the collection and every displayed hypothesis. Provider transport and bounded collection do not authenticate the provider as an asset owner or authorize contact with any returned name.\n")?;
5245    }
5246    #[cfg(feature = "wordpress-review")]
5247    if let Some(audit) = &document.wordpress_review {
5248        output.push_str("\n### WordPress evidence review audit\n\n")?;
5249        for (label, value) in audit.metadata() {
5250            output.push_fmt(format_args!("- {label}: "))?;
5251            write_markdown_code_span(&mut output, &value)?;
5252            output.push_char('\n')?;
5253        }
5254        write_wordpress_presentation(
5255            &mut WordPressPresentationEmitter::Markdown(&mut output),
5256            audit,
5257        )?;
5258        write_markdown_wordpress_external_attribution(&mut output, audit)?;
5259    }
5260    #[cfg(feature = "wordpress-review")]
5261    if let Some(fingerprints) = &document.wordpress_asset_fingerprints {
5262        output.push_str("\n### WordPress observed asset fingerprint candidates\n\n")?;
5263        for (label, value) in fingerprints.metadata() {
5264            output.push_fmt(format_args!("- {label}: "))?;
5265            write_markdown_code_span(&mut output, &value)?;
5266            output.push_char('\n')?;
5267        }
5268        write_wordpress_asset_fingerprint_presentation(
5269            &mut WordPressPresentationEmitter::Markdown(&mut output),
5270            fingerprints,
5271        )?;
5272    }
5273    #[cfg(feature = "wordpress-review")]
5274    if let Some(discovery) = &document.wordpress_discovery {
5275        output.push_str("\n### WordPress public metadata discovery audit\n\n")?;
5276        for (label, value) in discovery.metadata() {
5277            output.push_fmt(format_args!("- {label}: "))?;
5278            write_markdown_code_span(&mut output, &value)?;
5279            output.push_char('\n')?;
5280        }
5281        write_wordpress_discovery_presentation(
5282            &mut WordPressPresentationEmitter::Markdown(&mut output),
5283            discovery,
5284        )?;
5285    }
5286    Ok(output.finish())
5287}
5288
5289#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
5290fn write_markdown_wordpress_external_attribution(
5291    output: &mut RenderBuffer,
5292    audit: &AssessmentWordPressAuditDocument,
5293) -> Result<(), ReportError> {
5294    let Some(external) = &audit.external_review else {
5295        return Ok(());
5296    };
5297    output.push_str("\n#### External source attribution\n\n")?;
5298    for evaluation in &external.evaluations {
5299        let Some(reference) = &evaluation.record_reference else {
5300            continue;
5301        };
5302        output.push_str("- Record ")?;
5303        write_markdown_code_span(output, &evaluation.key.upstream_id)?;
5304        output.push_str(": [Selected source reference](<")?;
5305        output.push_str(reference)?;
5306        output.push_str(">)\n")?;
5307    }
5308    output.push_str("\n##### Rights notices\n\n")?;
5309    for notice in &external.notices {
5310        output.push_str("- Party: ")?;
5311        write_markdown_code_span(output, &notice.party)?;
5312        output.push_str("; message: ")?;
5313        write_markdown_code_span(output, &notice.message)?;
5314        output.push_str("; notice: ")?;
5315        write_markdown_code_span(output, &notice.notice)?;
5316        output.push_str("; license: ")?;
5317        write_markdown_code_span(output, &notice.license)?;
5318        if is_strict_https_reference(&notice.license_url) {
5319            output.push_str("; [License terms](<")?;
5320            output.push_str(&notice.license_url)?;
5321            output.push_str(">)")?;
5322        } else {
5323            output.push_str("; license URL: ")?;
5324            write_markdown_code_span(output, &notice.license_url)?;
5325        }
5326        output.push_char('\n')?;
5327    }
5328    Ok(())
5329}
5330
5331#[cfg(feature = "scanning")]
5332#[derive(Serialize)]
5333struct AssessmentDocument<'a> {
5334    schema: &'static str,
5335    source_schema: &'a str,
5336    run_schema: &'a str,
5337    profile_schema: &'a str,
5338    profile: &'a str,
5339    status: &'static str,
5340    subject_count: u64,
5341    item_count: u64,
5342    #[cfg(feature = "supplied-session-review")]
5343    #[serde(skip_serializing_if = "Option::is_none")]
5344    supplied_session: Option<AssessmentSuppliedSessionAuditDocument>,
5345    #[cfg(feature = "authorization-review")]
5346    #[serde(skip_serializing_if = "Option::is_none")]
5347    authorization_review: Option<AssessmentAuthorizationAuditDocument>,
5348    #[cfg(feature = "websocket-review")]
5349    #[serde(skip_serializing_if = "Option::is_none")]
5350    websocket_review: Option<AssessmentWebSocketReviewAuditDocument>,
5351    #[cfg(feature = "xml-external-entity-review")]
5352    #[serde(skip_serializing_if = "Option::is_none")]
5353    xml_external_entity_review: Option<AssessmentXmlExternalEntityReviewAuditDocument>,
5354    #[cfg(feature = "template-evaluation-review")]
5355    #[serde(skip_serializing_if = "Option::is_none")]
5356    template_evaluation_review: Option<AssessmentTemplateEvaluationReviewAuditDocument>,
5357    #[cfg(feature = "openapi-review")]
5358    #[serde(skip_serializing_if = "Option::is_none")]
5359    openapi_review: Option<AssessmentOpenApiAuditDocument>,
5360    #[cfg(feature = "rest-review")]
5361    #[serde(skip_serializing_if = "Option::is_none")]
5362    rest_review: Option<AssessmentRestAuditDocument>,
5363    #[cfg(feature = "secret-exposure-review")]
5364    #[serde(skip_serializing_if = "Option::is_none")]
5365    secret_exposure_review: Option<AssessmentSecretExposureAuditDocument>,
5366    #[cfg(feature = "tls-observation")]
5367    #[serde(skip_serializing_if = "Option::is_none")]
5368    tls_observation: Option<AssessmentTlsObservationAuditDocument>,
5369    #[cfg(feature = "tls-negotiation-review")]
5370    #[serde(skip_serializing_if = "Option::is_none")]
5371    tls_negotiation_review: Option<AssessmentTlsNegotiationReviewAuditDocument>,
5372    #[cfg(feature = "jwt-policy-review")]
5373    #[serde(skip_serializing_if = "Option::is_none")]
5374    jwt_policy_review: Option<AssessmentJwtPolicyReviewAuditDocument>,
5375    #[cfg(feature = "control-reference-mapping")]
5376    #[serde(skip_serializing_if = "Option::is_none")]
5377    control_reference_mapping: Option<AssessmentControlReferenceMappingAuditDocument>,
5378    #[cfg(feature = "recon-snapshot-import")]
5379    #[serde(skip_serializing_if = "Option::is_none")]
5380    recon_snapshot_import: Option<AssessmentReconSnapshotImportAuditDocument>,
5381    #[cfg(feature = "recon-ct-provider")]
5382    #[serde(skip_serializing_if = "Option::is_none")]
5383    recon_certspotter: Option<AssessmentReconCertSpotterAuditDocument>,
5384    #[cfg(feature = "wordpress-review")]
5385    #[serde(skip_serializing_if = "Option::is_none")]
5386    wordpress_review: Option<AssessmentWordPressAuditDocument>,
5387    #[cfg(feature = "wordpress-review")]
5388    #[serde(skip_serializing_if = "Option::is_none")]
5389    wordpress_discovery: Option<AssessmentWordPressDiscoveryAuditDocument>,
5390    #[cfg(feature = "wordpress-review")]
5391    #[serde(skip_serializing_if = "Option::is_none")]
5392    wordpress_asset_fingerprints: Option<AssessmentWordPressAssetFingerprintAuditDocument>,
5393    items: Vec<AssessmentItemDocument<'a>>,
5394}
5395
5396#[cfg(feature = "scanning")]
5397impl<'a> AssessmentDocument<'a> {
5398    fn from_report(report: &'a AssessmentRunReport) -> Result<Self, ReportError> {
5399        let items = report
5400            .items()
5401            .iter()
5402            .map(AssessmentItemDocument::from_item)
5403            .collect::<Result<Vec<_>, _>>()?;
5404        #[cfg(feature = "wordpress-review")]
5405        let wordpress_discovery_evidence_references = report
5406            .wordpress_review_audit()
5407            .and_then(WebAssessmentWordPressAudit::discovery)
5408            .into_iter()
5409            .flat_map(|discovery| {
5410                discovery
5411                    .page_scope()
5412                    .into_iter()
5413                    .flat_map(|pages| pages.pages())
5414                    .flat_map(|page| page.evidence_ids())
5415                    .chain(
5416                        discovery
5417                            .supplied_session_pages()
5418                            .into_iter()
5419                            .flat_map(|pages| pages.pages())
5420                            .flat_map(|page| page.evidence_ids()),
5421                    )
5422                    .chain(
5423                        discovery
5424                            .sources()
5425                            .iter()
5426                            .flat_map(|source| source.evidence_ids()),
5427                    )
5428            })
5429            .map(|evidence_id| {
5430                report
5431                    .evidence_reference_for(evidence_id)
5432                    .map(|reference| reference.to_string())
5433                    .ok_or(ReportError::Serialization)
5434            })
5435            .collect::<Result<Vec<_>, _>>()?;
5436        #[cfg(feature = "wordpress-review")]
5437        let fingerprint_evidence_references = report
5438            .wordpress_review_audit()
5439            .and_then(WebAssessmentWordPressAudit::asset_fingerprints)
5440            .into_iter()
5441            .flat_map(WordPressAssetFingerprintExecution::resources)
5442            .flat_map(|resource| resource.evidence_ids())
5443            .map(|evidence_id| {
5444                report
5445                    .evidence_reference_for(evidence_id)
5446                    .map(|reference| reference.to_string())
5447                    .ok_or(ReportError::Serialization)
5448            })
5449            .collect::<Result<Vec<_>, _>>()?;
5450        #[cfg(feature = "wordpress-review")]
5451        let wordpress_discovery = {
5452            match report.wordpress_review_audit() {
5453                Some(audit) => AssessmentWordPressDiscoveryAuditDocument::from_wordpress_audit(
5454                    audit,
5455                    &wordpress_discovery_evidence_references,
5456                )?,
5457                None => None,
5458            }
5459        };
5460        #[cfg(feature = "wordpress-review")]
5461        let wordpress_asset_fingerprints = report
5462            .wordpress_review_audit()
5463            .and_then(WebAssessmentWordPressAudit::asset_fingerprints)
5464            .map(|execution| {
5465                AssessmentWordPressAssetFingerprintAuditDocument::from_execution(
5466                    execution,
5467                    &fingerprint_evidence_references,
5468                )
5469            })
5470            .transpose()?;
5471        #[cfg(feature = "tls-observation")]
5472        let tls_observation = report
5473            .tls_observation_audit()
5474            .map(|audit| {
5475                let assessment_request_count = report
5476                    .run_report()
5477                    .accounting()
5478                    .requests()
5479                    .consumed()
5480                    .ok_or(ReportError::Serialization)?;
5481                AssessmentTlsObservationAuditDocument::from_audit(audit, assessment_request_count)
5482            })
5483            .transpose()?;
5484        Ok(Self {
5485            schema: ASSESSMENT_REPORT_DOCUMENT_SCHEMA,
5486            source_schema: report.schema(),
5487            run_schema: report.run_report().schema(),
5488            profile_schema: report.profile().schema(),
5489            profile: report.profile().profile().id(),
5490            status: run_status_token(report.run_report().status()),
5491            subject_count: u64::try_from(report.subject_count())
5492                .map_err(|_| ReportError::Serialization)?,
5493            item_count: u64::try_from(report.item_count())
5494                .map_err(|_| ReportError::Serialization)?,
5495            #[cfg(feature = "supplied-session-review")]
5496            supplied_session: report
5497                .supplied_session_audit()
5498                .map(AssessmentSuppliedSessionAuditDocument::from_audit)
5499                .transpose()?,
5500            #[cfg(feature = "authorization-review")]
5501            authorization_review: report
5502                .authorization_review_audit()
5503                .map(AssessmentAuthorizationAuditDocument::from_audit),
5504            #[cfg(feature = "websocket-review")]
5505            websocket_review: report
5506                .websocket_review_audit()
5507                .map(AssessmentWebSocketReviewAuditDocument::from_audit)
5508                .transpose()?,
5509            #[cfg(feature = "xml-external-entity-review")]
5510            xml_external_entity_review: report
5511                .xml_external_entity_review_audit()
5512                .map(AssessmentXmlExternalEntityReviewAuditDocument::from_audit)
5513                .transpose()?,
5514            #[cfg(feature = "template-evaluation-review")]
5515            template_evaluation_review: report
5516                .template_evaluation_review_audit()
5517                .map(AssessmentTemplateEvaluationReviewAuditDocument::from_audit)
5518                .transpose()?,
5519            #[cfg(feature = "openapi-review")]
5520            openapi_review: report
5521                .openapi_review_audit()
5522                .map(AssessmentOpenApiAuditDocument::from_audit),
5523            #[cfg(feature = "rest-review")]
5524            rest_review: report
5525                .rest_review_audit()
5526                .map(AssessmentRestAuditDocument::from_audit),
5527            #[cfg(feature = "secret-exposure-review")]
5528            secret_exposure_review: report
5529                .secret_exposure_review_audit()
5530                .map(|audit| AssessmentSecretExposureAuditDocument::from_audit(report, audit))
5531                .transpose()?,
5532            #[cfg(feature = "tls-observation")]
5533            tls_observation,
5534            #[cfg(feature = "tls-negotiation-review")]
5535            tls_negotiation_review: report
5536                .tls_negotiation_review_audit()
5537                .map(AssessmentTlsNegotiationReviewAuditDocument::from_audit)
5538                .transpose()?,
5539            #[cfg(feature = "jwt-policy-review")]
5540            jwt_policy_review: match report.jwt_policy_review_audit() {
5541                Some(audit) => {
5542                    #[cfg(feature = "jwt-target-acceptance-review")]
5543                    let document = AssessmentJwtPolicyReviewAuditDocument::from_audits(
5544                        audit,
5545                        report.jwt_target_acceptance_audit(),
5546                    )?;
5547                    #[cfg(not(feature = "jwt-target-acceptance-review"))]
5548                    let document = AssessmentJwtPolicyReviewAuditDocument::from_audits(audit)?;
5549                    Some(document)
5550                },
5551                None => {
5552                    #[cfg(feature = "jwt-target-acceptance-review")]
5553                    if report.jwt_target_acceptance_audit().is_some() {
5554                        return Err(ReportError::Serialization);
5555                    }
5556                    None
5557                },
5558            },
5559            #[cfg(feature = "control-reference-mapping")]
5560            control_reference_mapping: report
5561                .control_reference_mapping_audit()
5562                .map(AssessmentControlReferenceMappingAuditDocument::from_audit)
5563                .transpose()?,
5564            #[cfg(feature = "recon-snapshot-import")]
5565            recon_snapshot_import: report
5566                .recon_snapshot_audit()
5567                .map(AssessmentReconSnapshotImportAuditDocument::from_audit)
5568                .transpose()?,
5569            #[cfg(feature = "recon-ct-provider")]
5570            recon_certspotter: report
5571                .recon_ct_provider_audit()
5572                .map(AssessmentReconCertSpotterAuditDocument::from_audit)
5573                .transpose()?,
5574            #[cfg(feature = "wordpress-review")]
5575            wordpress_review: report
5576                .wordpress_review_audit()
5577                .map(AssessmentWordPressAuditDocument::from_audit),
5578            #[cfg(feature = "wordpress-review")]
5579            wordpress_discovery,
5580            #[cfg(feature = "wordpress-review")]
5581            wordpress_asset_fingerprints,
5582            items,
5583        })
5584    }
5585
5586    fn metadata(&self) -> [(&'static str, String); 8] {
5587        [
5588            ("Schema", self.schema.to_owned()),
5589            ("Source schema", self.source_schema.to_owned()),
5590            ("Run schema", self.run_schema.to_owned()),
5591            ("Profile schema", self.profile_schema.to_owned()),
5592            ("Profile", self.profile.to_owned()),
5593            ("Status", self.status.to_owned()),
5594            ("Subject count", self.subject_count.to_string()),
5595            ("Item count", self.item_count.to_string()),
5596        ]
5597    }
5598
5599    fn validate(&self) -> Result<(), ReportError> {
5600        if self.schema != ASSESSMENT_REPORT_DOCUMENT_SCHEMA
5601            || self.item_count
5602                != u64::try_from(self.items.len()).map_err(|_| ReportError::Serialization)?
5603        {
5604            return Err(ReportError::Serialization);
5605        }
5606        #[cfg(feature = "supplied-session-review")]
5607        if let Some(audit) = &self.supplied_session {
5608            audit.validate(&self.items)?;
5609        } else if self
5610            .items
5611            .iter()
5612            .any(|item| item.capability_id == SUPPLIED_SESSION_CAPABILITY_ID)
5613        {
5614            return Err(ReportError::Serialization);
5615        }
5616        #[cfg(feature = "authorization-review")]
5617        if let Some(audit) = &self.authorization_review {
5618            audit.validate(&self.items)?;
5619        }
5620        #[cfg(feature = "websocket-review")]
5621        if let Some(audit) = &self.websocket_review {
5622            audit.validate(&self.items)?;
5623            #[cfg(feature = "supplied-session-review")]
5624            if let Some(context) = &audit.supplied_session_context {
5625                context.validate_against_supplied_session(
5626                    self.supplied_session
5627                        .as_ref()
5628                        .ok_or(ReportError::Serialization)?,
5629                )?;
5630            }
5631        } else if self
5632            .items
5633            .iter()
5634            .any(|item| item.capability_id == WEBSOCKET_REVIEW_CAPABILITY_ID)
5635        {
5636            return Err(ReportError::Serialization);
5637        }
5638        #[cfg(feature = "xml-external-entity-review")]
5639        if let Some(audit) = &self.xml_external_entity_review {
5640            audit.validate(&self.items)?;
5641        } else if self
5642            .items
5643            .iter()
5644            .any(|item| item.capability_id == XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID)
5645        {
5646            return Err(ReportError::Serialization);
5647        }
5648        #[cfg(feature = "template-evaluation-review")]
5649        if let Some(audit) = &self.template_evaluation_review {
5650            audit.validate(&self.items)?;
5651        } else if self
5652            .items
5653            .iter()
5654            .any(|item| item.capability_id == TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID)
5655        {
5656            return Err(ReportError::Serialization);
5657        }
5658        #[cfg(feature = "openapi-review")]
5659        if let Some(audit) = &self.openapi_review {
5660            audit.validate(&self.items)?;
5661        }
5662        #[cfg(feature = "rest-review")]
5663        if let Some(audit) = &self.rest_review {
5664            #[cfg(feature = "secret-exposure-review")]
5665            let response_privacy = self.secret_exposure_review.is_some();
5666            #[cfg(not(feature = "secret-exposure-review"))]
5667            let response_privacy = false;
5668            audit.validate_for_response_privacy(&self.items, response_privacy)?;
5669        } else if self
5670            .items
5671            .iter()
5672            .any(|item| item.capability_id == REST_REVIEW_CAPABILITY_ID)
5673        {
5674            return Err(ReportError::Serialization);
5675        }
5676        #[cfg(feature = "secret-exposure-review")]
5677        if let Some(audit) = &self.secret_exposure_review {
5678            audit.validate(&self.items)?;
5679        } else if self
5680            .items
5681            .iter()
5682            .any(|item| secret_exposure_capability(item.capability_id).is_some())
5683        {
5684            return Err(ReportError::Serialization);
5685        }
5686        #[cfg(feature = "tls-observation")]
5687        if let Some(audit) = &self.tls_observation {
5688            audit.validate()?;
5689        }
5690        #[cfg(feature = "tls-negotiation-review")]
5691        if let Some(audit) = &self.tls_negotiation_review {
5692            audit.validate()?;
5693        }
5694        #[cfg(feature = "jwt-policy-review")]
5695        if let Some(audit) = &self.jwt_policy_review {
5696            audit.validate()?;
5697        }
5698        #[cfg(feature = "control-reference-mapping")]
5699        if let Some(audit) = &self.control_reference_mapping {
5700            audit.validate(&self.items)?;
5701        }
5702        #[cfg(feature = "recon-snapshot-import")]
5703        if let Some(audit) = &self.recon_snapshot_import {
5704            audit.validate()?;
5705        }
5706        #[cfg(feature = "recon-ct-provider")]
5707        if let Some(audit) = &self.recon_certspotter {
5708            audit.validate()?;
5709        }
5710        #[cfg(feature = "wordpress-review")]
5711        if let Some(audit) = &self.wordpress_review {
5712            audit.validate(&self.items)?;
5713        } else if self
5714            .items
5715            .iter()
5716            .any(|item| item.capability_id == WORDPRESS_REVIEW_CAPABILITY_ID)
5717        {
5718            return Err(ReportError::Serialization);
5719        }
5720        #[cfg(feature = "wordpress-review")]
5721        let discovery_items = self
5722            .items
5723            .iter()
5724            .filter(|item| item.capability_id == WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID)
5725            .collect::<Vec<_>>();
5726        #[cfg(feature = "wordpress-review")]
5727        match (
5728            &self.wordpress_review,
5729            &self.wordpress_discovery,
5730            &self.wordpress_asset_fingerprints,
5731        ) {
5732            (Some(review), Some(discovery), fingerprints) => {
5733                discovery.validate()?;
5734                #[cfg(feature = "supplied-session-review")]
5735                if let Some(session_pages) = &discovery.supplied_session_pages {
5736                    session_pages.validate_against_supplied_session(
5737                        self.supplied_session
5738                            .as_ref()
5739                            .ok_or(ReportError::Serialization)?,
5740                    )?;
5741                }
5742                #[cfg(not(feature = "supplied-session-review"))]
5743                if discovery.supplied_session_pages.is_some() {
5744                    return Err(ReportError::Serialization);
5745                }
5746                if let Some(fingerprints) = fingerprints {
5747                    fingerprints.validate(review, discovery)?;
5748                }
5749                let page_attempted_request_count = discovery
5750                    .page_collection
5751                    .as_ref()
5752                    .map_or(0, |pages| pages.attempted_request_count);
5753                let page_committed_response_count = discovery
5754                    .page_collection
5755                    .as_ref()
5756                    .map_or(0, |pages| pages.committed_response_count);
5757                let total_attempted_request_count = discovery
5758                    .attempted_request_count
5759                    .checked_add(page_attempted_request_count)
5760                    .and_then(|count| {
5761                        fingerprints.as_ref().map_or(Some(count), |audit| {
5762                            count.checked_add(audit.attempted_request_count)
5763                        })
5764                    });
5765                let page_response_bytes = discovery
5766                    .page_collection
5767                    .as_ref()
5768                    .map_or(0, |pages| pages.response_bytes);
5769                let total_wordpress_response_bytes = discovery
5770                    .response_bytes
5771                    .checked_add(page_response_bytes)
5772                    .and_then(|bytes| {
5773                        fingerprints
5774                            .as_ref()
5775                            .map_or(Some(bytes), |audit| bytes.checked_add(audit.response_bytes))
5776                    });
5777                let total_committed_response_count = discovery
5778                    .committed_response_count
5779                    .checked_add(page_committed_response_count)
5780                    .and_then(|count| {
5781                        discovery
5782                            .supplied_session_pages
5783                            .as_ref()
5784                            .map_or(Some(count), |pages| {
5785                                u8::try_from(
5786                                    pages
5787                                        .pages
5788                                        .iter()
5789                                        .map(|page| page.evidence_reference_count)
5790                                        .sum::<usize>(),
5791                                )
5792                                .ok()
5793                                .and_then(|session_pages| count.checked_add(session_pages))
5794                            })
5795                    })
5796                    .and_then(|count| {
5797                        fingerprints.as_ref().map_or(Some(count), |audit| {
5798                            u8::try_from(audit.evidence_reference_count())
5799                                .ok()
5800                                .and_then(|fingerprints| count.checked_add(fingerprints))
5801                        })
5802                    });
5803                let expected_schema = if fingerprints.is_some() {
5804                    "security.wordpress-review-audit/v8"
5805                } else {
5806                    "security.wordpress-review-audit/v7"
5807                };
5808                let expected_evidence_references = discovery
5809                    .page_collection
5810                    .iter()
5811                    .flat_map(|pages| pages.pages.iter())
5812                    .flat_map(|page| page.evidence_references.iter())
5813                    .chain(
5814                        discovery
5815                            .supplied_session_pages
5816                            .iter()
5817                            .flat_map(|pages| pages.pages.iter())
5818                            .flat_map(|page| page.evidence_references.iter()),
5819                    )
5820                    .chain(
5821                        discovery
5822                            .sources
5823                            .iter()
5824                            .flat_map(|source| source.evidence_references.iter()),
5825                    )
5826                    .chain(
5827                        fingerprints
5828                            .iter()
5829                            .flat_map(|audit| audit.resources.iter())
5830                            .flat_map(|resource| resource.evidence_references.iter()),
5831                    )
5832                    .cloned()
5833                    .collect::<std::collections::BTreeSet<_>>();
5834                if review.schema != expected_schema
5835                    || total_attempted_request_count != Some(review.additional_request_count)
5836                    || ((discovery.page_collection.is_some() || fingerprints.is_some())
5837                        && total_wordpress_response_bytes.is_none_or(|bytes| {
5838                            bytes > MAX_WORDPRESS_DISCOVERY_AUDIT_RESPONSE_BYTES
5839                        }))
5840                    || !wordpress_discovery_matches_review(review, discovery)
5841                    || (discovery_items.len() == 1)
5842                        != total_committed_response_count.is_some_and(|count| count > 0)
5843                    || discovery_items.first().is_some_and(|item| {
5844                        item.evidence_count
5845                            != u64::from(total_committed_response_count.unwrap_or(u8::MAX))
5846                            || item
5847                                .evidence_references
5848                                .iter()
5849                                .cloned()
5850                                .collect::<std::collections::BTreeSet<_>>()
5851                                != expected_evidence_references
5852                    })
5853                {
5854                    return Err(ReportError::Serialization);
5855                }
5856            },
5857            (Some(review), None, _)
5858                if matches!(
5859                    review.schema,
5860                    "security.wordpress-review-audit/v7" | "security.wordpress-review-audit/v8"
5861                ) =>
5862            {
5863                return Err(ReportError::Serialization);
5864            },
5865            (None, Some(_), _) | (None, _, Some(_)) => return Err(ReportError::Serialization),
5866            _ if !discovery_items.is_empty() => return Err(ReportError::Serialization),
5867            _ => {},
5868        }
5869        for item in &self.items {
5870            item.validate()?;
5871        }
5872        Ok(())
5873    }
5874}
5875
5876#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
5877const SECRET_EXPOSURE_CAPABILITIES: [(&str, &str); 4] = [
5878    (
5879        "pem_private_key_block",
5880        "exposure.response-private-key-material@1",
5881    ),
5882    (
5883        "aws_access_key_pair",
5884        "exposure.response-aws-access-key-pair@1",
5885    ),
5886    (
5887        "stripe_live_secret",
5888        "exposure.response-stripe-live-secret@1",
5889    ),
5890    (
5891        "authorization_bearer_assignment",
5892        "exposure.response-bearer-authorization@1",
5893    ),
5894];
5895
5896#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
5897const SECRET_EXPOSURE_OUTCOMES: [&str; 12] = [
5898    "evaluated",
5899    "method_not_get",
5900    "status_not_200",
5901    "incomplete_body",
5902    "request_context_ineligible",
5903    "content_coded",
5904    "content_length_inconsistent",
5905    "unsupported_media_type",
5906    "invalid_utf8",
5907    "response_byte_limit_exceeded",
5908    "total_byte_limit_exceeded",
5909    "detector_limit_exceeded",
5910];
5911
5912#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
5913fn secret_exposure_capability(capability_id: &str) -> Option<&'static str> {
5914    SECRET_EXPOSURE_CAPABILITIES
5915        .iter()
5916        .find_map(|(class, expected)| (*expected == capability_id).then_some(*class))
5917}
5918
5919#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
5920fn secret_exposure_capability_for_class(detector_class: &str) -> Option<&'static str> {
5921    SECRET_EXPOSURE_CAPABILITIES
5922        .iter()
5923        .find_map(|(expected, capability)| (*expected == detector_class).then_some(*capability))
5924}
5925
5926#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
5927#[derive(Serialize)]
5928struct AssessmentSecretExposureAuditDocument {
5929    schema: &'static str,
5930    policy: &'static str,
5931    catalogue_id: &'static str,
5932    catalogue_revision: &'static str,
5933    representation: &'static str,
5934    context: &'static str,
5935    selected: bool,
5936    additional_request_count: u64,
5937    response_count: u64,
5938    evaluated_response_count: u64,
5939    not_evaluated_response_count: u64,
5940    body_derived_projection_suppressed_response_count: u64,
5941    interpreted_byte_count: u64,
5942    per_response_byte_limit: u64,
5943    total_byte_limit: u64,
5944    observation_count: u64,
5945    omitted_observation_count: u64,
5946    match_occurrence_count: u64,
5947    outcomes: Vec<AssessmentSecretExposureOutcomeDocument>,
5948    observations: Vec<AssessmentSecretExposureObservationDocument>,
5949    source_authentication: &'static str,
5950    secret_validity: &'static str,
5951    provider_validation: &'static str,
5952    exploit_execution: &'static str,
5953    impact_validation: &'static str,
5954    raw_values_retained: bool,
5955    public_secret_hashes_retained: bool,
5956}
5957
5958#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
5959#[derive(Serialize)]
5960struct AssessmentSecretExposureOutcomeDocument {
5961    outcome: &'static str,
5962    count: u64,
5963}
5964
5965#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
5966#[derive(Serialize)]
5967struct AssessmentSecretExposureObservationDocument {
5968    detector_class: &'static str,
5969    capability_id: &'static str,
5970    occurrence_count: u64,
5971    evidence_references: Vec<String>,
5972}
5973
5974#[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
5975impl AssessmentSecretExposureAuditDocument {
5976    fn from_audit(
5977        report: &AssessmentRunReport,
5978        audit: &WebAssessmentSecretExposureAudit,
5979    ) -> Result<Self, ReportError> {
5980        let mut outcomes = audit
5981            .outcomes()
5982            .iter()
5983            .map(|outcome| {
5984                Ok(AssessmentSecretExposureOutcomeDocument {
5985                    outcome: outcome.outcome().as_str(),
5986                    count: u64::from(outcome.count()),
5987                })
5988            })
5989            .collect::<Result<Vec<_>, ReportError>>()?;
5990        outcomes.sort_by_key(|outcome| outcome.outcome);
5991
5992        let mut observations = audit
5993            .observations()
5994            .iter()
5995            .map(|observation| {
5996                let detector_class = observation.detector_class();
5997                let evidence_references = observation
5998                    .evidence_ids()
5999                    .iter()
6000                    .map(|evidence_id| {
6001                        report
6002                            .evidence_reference_for(evidence_id)
6003                            .map(|reference| reference.to_string())
6004                            .ok_or(ReportError::Serialization)
6005                    })
6006                    .collect::<Result<Vec<_>, _>>()?;
6007                Ok(AssessmentSecretExposureObservationDocument {
6008                    detector_class: detector_class.as_str(),
6009                    capability_id: detector_class.capability_id(),
6010                    occurrence_count: u64::from(observation.occurrence_count()),
6011                    evidence_references,
6012                })
6013            })
6014            .collect::<Result<Vec<_>, ReportError>>()?;
6015        observations.sort_by(|left, right| {
6016            left.detector_class
6017                .cmp(right.detector_class)
6018                .then_with(|| left.evidence_references.cmp(&right.evidence_references))
6019        });
6020
6021        Ok(Self {
6022            schema: SECRET_EXPOSURE_AUDIT_SCHEMA,
6023            policy: SECRET_EXPOSURE_POLICY_ID,
6024            catalogue_id: SECRET_EXPOSURE_CATALOGUE_ID,
6025            catalogue_revision: SECRET_EXPOSURE_CATALOGUE_REVISION,
6026            representation: SECRET_EXPOSURE_REPRESENTATION,
6027            context: SECRET_EXPOSURE_CONTEXT,
6028            selected: true,
6029            additional_request_count: 0,
6030            response_count: u64::from(audit.response_count()),
6031            evaluated_response_count: u64::from(audit.evaluated_response_count()),
6032            not_evaluated_response_count: u64::from(audit.not_evaluated_response_count()),
6033            body_derived_projection_suppressed_response_count: u64::from(
6034                audit.body_derived_projection_suppressed_response_count(),
6035            ),
6036            interpreted_byte_count: audit.interpreted_byte_count(),
6037            per_response_byte_limit: u64::try_from(MAX_SECRET_EXPOSURE_BODY_BYTES)
6038                .map_err(|_| ReportError::Serialization)?,
6039            total_byte_limit: MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES,
6040            observation_count: u64::try_from(observations.len())
6041                .map_err(|_| ReportError::Serialization)?,
6042            omitted_observation_count: u64::from(audit.omitted_observation_count()),
6043            match_occurrence_count: audit.match_occurrence_count(),
6044            outcomes,
6045            observations,
6046            source_authentication: "not_established",
6047            secret_validity: "not_tested",
6048            provider_validation: "not_performed",
6049            exploit_execution: "not_performed",
6050            impact_validation: "not_performed",
6051            raw_values_retained: false,
6052            public_secret_hashes_retained: false,
6053        })
6054    }
6055
6056    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
6057        let response_partition = self
6058            .evaluated_response_count
6059            .checked_add(self.not_evaluated_response_count);
6060        let maximum_interpreted_bytes = self
6061            .evaluated_response_count
6062            .checked_mul(self.per_response_byte_limit);
6063        let maximum_occurrences = self
6064            .evaluated_response_count
6065            .checked_mul(u64::from(MAX_SECRET_EXPOSURE_OCCURRENCES));
6066        let maximum_observations = self.evaluated_response_count.checked_mul(
6067            u64::try_from(SECRET_EXPOSURE_CAPABILITIES.len())
6068                .map_err(|_| ReportError::Serialization)?,
6069        );
6070        let retained_and_omitted_observations = self
6071            .observation_count
6072            .checked_add(self.omitted_observation_count);
6073        if self.schema != SECRET_EXPOSURE_AUDIT_SCHEMA
6074            || self.policy != SECRET_EXPOSURE_POLICY_ID
6075            || self.catalogue_id != SECRET_EXPOSURE_CATALOGUE_ID
6076            || self.catalogue_revision != SECRET_EXPOSURE_CATALOGUE_REVISION
6077            || self.representation != SECRET_EXPOSURE_REPRESENTATION
6078            || self.context != SECRET_EXPOSURE_CONTEXT
6079            || !self.selected
6080            || self.additional_request_count != 0
6081            || self.response_count
6082                > u64::try_from(MAX_SECRET_EXPOSURE_RESPONSES)
6083                    .map_err(|_| ReportError::Serialization)?
6084            || response_partition != Some(self.response_count)
6085            || self.body_derived_projection_suppressed_response_count != self.response_count
6086            || self.per_response_byte_limit
6087                != u64::try_from(MAX_SECRET_EXPOSURE_BODY_BYTES)
6088                    .map_err(|_| ReportError::Serialization)?
6089            || self.total_byte_limit != MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES
6090            || self.interpreted_byte_count > self.total_byte_limit
6091            || maximum_interpreted_bytes.is_none_or(|maximum| self.interpreted_byte_count > maximum)
6092            || self.observation_count
6093                != u64::try_from(self.observations.len()).map_err(|_| ReportError::Serialization)?
6094            || self.observations.len() > MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS
6095            || (self.omitted_observation_count > 0
6096                && self.observations.len() != MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS)
6097            || maximum_observations.is_none_or(|maximum| {
6098                retained_and_omitted_observations.is_none_or(|total| total > maximum)
6099            })
6100            || maximum_occurrences.is_none_or(|maximum| self.match_occurrence_count > maximum)
6101            || self.source_authentication != "not_established"
6102            || self.secret_validity != "not_tested"
6103            || self.provider_validation != "not_performed"
6104            || self.exploit_execution != "not_performed"
6105            || self.impact_validation != "not_performed"
6106            || self.raw_values_retained
6107            || self.public_secret_hashes_retained
6108        {
6109            return Err(ReportError::Serialization);
6110        }
6111
6112        let mut outcome_names = std::collections::BTreeSet::new();
6113        let mut outcome_total = 0_u64;
6114        let mut evaluated_from_outcomes = 0_u64;
6115        for outcome in &self.outcomes {
6116            if !SECRET_EXPOSURE_OUTCOMES.contains(&outcome.outcome)
6117                || outcome.count == 0
6118                || !outcome_names.insert(outcome.outcome)
6119            {
6120                return Err(ReportError::Serialization);
6121            }
6122            outcome_total = outcome_total
6123                .checked_add(outcome.count)
6124                .ok_or(ReportError::Serialization)?;
6125            if outcome.outcome == "evaluated" {
6126                evaluated_from_outcomes = outcome.count;
6127            }
6128        }
6129        if outcome_total != self.response_count
6130            || evaluated_from_outcomes != self.evaluated_response_count
6131        {
6132            return Err(ReportError::Serialization);
6133        }
6134
6135        let secret_items = items
6136            .iter()
6137            .filter(|item| secret_exposure_capability(item.capability_id).is_some())
6138            .collect::<Vec<_>>();
6139        if secret_items.len() != self.observations.len() {
6140            return Err(ReportError::Serialization);
6141        }
6142        let mut unique_observations = std::collections::BTreeSet::new();
6143        let mut unique_observation_evidence_references = std::collections::BTreeSet::new();
6144        let mut matched_item_indexes = std::collections::BTreeSet::new();
6145        let mut retained_occurrences = 0_u64;
6146        for observation in &self.observations {
6147            if secret_exposure_capability_for_class(observation.detector_class)
6148                != Some(observation.capability_id)
6149                || observation.occurrence_count == 0
6150                || observation.occurrence_count > u64::from(MAX_SECRET_EXPOSURE_OCCURRENCES)
6151                || observation.evidence_references.len() != 1
6152                || !valid_opaque_assessment_reference(
6153                    &observation.evidence_references[0],
6154                    "evidence",
6155                )
6156                || !unique_observation_evidence_references
6157                    .insert(observation.evidence_references[0].as_str())
6158                || !unique_observations.insert((
6159                    observation.detector_class,
6160                    observation.capability_id,
6161                    observation.occurrence_count,
6162                    observation.evidence_references[0].as_str(),
6163                ))
6164            {
6165                return Err(ReportError::Serialization);
6166            }
6167            retained_occurrences = retained_occurrences
6168                .checked_add(observation.occurrence_count)
6169                .ok_or(ReportError::Serialization)?;
6170            let matching = secret_items
6171                .iter()
6172                .enumerate()
6173                .filter(|(_, item)| {
6174                    item.capability_id == observation.capability_id
6175                        && item.claim_basis == "observation"
6176                        && item.evidence_references == observation.evidence_references
6177                })
6178                .map(|(index, _)| index)
6179                .collect::<Vec<_>>();
6180            if matching.len() != 1 || !matched_item_indexes.insert(matching[0]) {
6181                return Err(ReportError::Serialization);
6182            }
6183        }
6184        let missing_occurrences = self
6185            .match_occurrence_count
6186            .checked_sub(retained_occurrences);
6187        let maximum_omitted_occurrences = self
6188            .omitted_observation_count
6189            .checked_mul(u64::from(MAX_SECRET_EXPOSURE_OCCURRENCES));
6190        if matched_item_indexes.len() != secret_items.len()
6191            || missing_occurrences
6192                .zip(maximum_omitted_occurrences)
6193                .is_none_or(|(missing, maximum)| {
6194                    missing < self.omitted_observation_count || missing > maximum
6195                })
6196        {
6197            return Err(ReportError::Serialization);
6198        }
6199        Ok(())
6200    }
6201
6202    fn metadata(&self) -> Vec<(&'static str, String)> {
6203        vec![
6204            ("Audit schema", self.schema.to_owned()),
6205            ("Policy", self.policy.to_owned()),
6206            ("Detector catalogue", self.catalogue_id.to_owned()),
6207            ("Catalogue revision", self.catalogue_revision.to_owned()),
6208            ("Representation", self.representation.to_owned()),
6209            ("Collection context", self.context.to_owned()),
6210            ("Selected", self.selected.to_string()),
6211            (
6212                "Additional target requests",
6213                self.additional_request_count.to_string(),
6214            ),
6215            ("Responses considered", self.response_count.to_string()),
6216            (
6217                "Responses evaluated",
6218                self.evaluated_response_count.to_string(),
6219            ),
6220            (
6221                "Responses not evaluated",
6222                self.not_evaluated_response_count.to_string(),
6223            ),
6224            (
6225                "Responses with body-derived projections suppressed",
6226                self.body_derived_projection_suppressed_response_count
6227                    .to_string(),
6228            ),
6229            (
6230                "Interpreted response bytes",
6231                self.interpreted_byte_count.to_string(),
6232            ),
6233            (
6234                "Per-response byte limit",
6235                self.per_response_byte_limit.to_string(),
6236            ),
6237            ("Total byte limit", self.total_byte_limit.to_string()),
6238            ("Retained observations", self.observation_count.to_string()),
6239            (
6240                "Omitted observations",
6241                self.omitted_observation_count.to_string(),
6242            ),
6243            (
6244                "Matched occurrences",
6245                self.match_occurrence_count.to_string(),
6246            ),
6247            (
6248                "Source authentication",
6249                self.source_authentication.to_owned(),
6250            ),
6251            ("Secret validity", self.secret_validity.to_owned()),
6252            ("Provider validation", self.provider_validation.to_owned()),
6253            ("Exploit execution", self.exploit_execution.to_owned()),
6254            ("Impact validation", self.impact_validation.to_owned()),
6255            (
6256                "Raw matched values retained",
6257                self.raw_values_retained.to_string(),
6258            ),
6259            (
6260                "Public secret hashes retained",
6261                self.public_secret_hashes_retained.to_string(),
6262            ),
6263        ]
6264    }
6265
6266    fn wire_json(&self) -> Result<String, ReportError> {
6267        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
6268    }
6269}
6270
6271#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6272const JWT_POLICY_CLOCK_ASSURANCE: &str = "local_system_clock_not_independently_verified";
6273#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6274const JWT_POLICY_EXTERNAL_NOT_PERFORMED: &str = "not_performed";
6275#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6276const JWT_POLICY_SOURCE_AUTHENTICATION: &str = "not_established";
6277#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6278const JWT_POLICY_ALGORITHM: &str = "es256";
6279#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6280const JWT_POLICY_REPRESENTATION: &str = "compact_jws";
6281#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6282const JWT_POLICY_LOCAL_KEY_SOURCE: &str = "explicit_operator_supplied_public_jwk";
6283#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6284const JWT_POLICY_KEY_SOURCE_ASSURANCE: &str = "operator_supplied_not_authenticated";
6285#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6286const JWT_POLICY_TOKEN_KEY_SELECTION: &str = "prohibited";
6287#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6288const MAX_JWT_POLICY_VIOLATIONS: usize = MAX_REQUIRED_CLAIMS + 5;
6289#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6290const JWT_POLICY_REVIEW_AUDIT_SCHEMA_V2: &str = "security.jwt-policy-review-audit/v2";
6291#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6292const JWT_TARGET_SOURCE_AUTHENTICATION: &str = "not_established";
6293#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6294const JWT_TARGET_INTERPRETATION_LIMITS: [&str; 6] = [
6295    "A marker relationship applies only to the selected resource and request controls; it does not establish a general authentication bypass, authorization effect, exploitability, or impact.",
6296    "An invalid-signature control marker is reported only beside matched valid and anonymous controls; it is still review-level evidence, not a confirmed vulnerability.",
6297    "A public or token-agnostic marker suppresses invalid-signature-specific interpretation, while a rejected invalid control does not prove every token verifier is secure.",
6298    "Retained response bytes are bounded interpreted bytes; accounted transport response bytes include charged discarded overrun and may exceed a retention ceiling without expanding interpreted content.",
6299    "The operator policy, selected resource, local key, target, and source are not authenticated by this report.",
6300    "The random preparation reference links local and target evidence only within this report; it is not a token identity, cross-run identity, signature, or source authentication claim.",
6301];
6302
6303#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6304#[derive(Serialize)]
6305struct AssessmentJwtPolicyReviewAuditDocument {
6306    schema: &'static str,
6307    policy: &'static str,
6308    selected: bool,
6309    parsing_status: &'static str,
6310    #[serde(skip_serializing_if = "Option::is_none")]
6311    parsing_rejection: Option<&'static str>,
6312    policy_status: &'static str,
6313    local_signature_status: &'static str,
6314    target_acceptance_status: &'static str,
6315    methodology: AssessmentJwtPolicyMethodologyDocument,
6316    external_activity: AssessmentJwtExternalActivityDocument,
6317    policy_violations: Vec<AssessmentJwtPolicyViolationDocument>,
6318    source_authentication: &'static str,
6319    #[cfg(feature = "jwt-target-acceptance-review")]
6320    #[serde(skip_serializing_if = "Option::is_none")]
6321    preparation_reference: Option<String>,
6322    #[cfg(feature = "jwt-target-acceptance-review")]
6323    #[serde(skip_serializing_if = "Option::is_none")]
6324    target_acceptance: Option<AssessmentJwtTargetAcceptanceAuditDocument>,
6325}
6326
6327#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6328#[derive(Serialize)]
6329struct AssessmentJwtPolicyMethodologyDocument {
6330    algorithm: &'static str,
6331    representation: &'static str,
6332    local_key_source: &'static str,
6333    key_source_assurance: &'static str,
6334    token_carried_key_selection: &'static str,
6335    operator_policy_reference: String,
6336    operator_policy_revision: String,
6337    local_public_key_sha256: String,
6338    expected_type_selected: bool,
6339    expected_issuer_selected: bool,
6340    expected_audience_selected: bool,
6341    required_claim_count: u64,
6342    require_expiration: bool,
6343    allowed_clock_skew_seconds: u64,
6344    evaluation_time_unix_seconds: i64,
6345    clock_assurance: &'static str,
6346}
6347
6348#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6349#[derive(Serialize)]
6350struct AssessmentJwtExternalActivityDocument {
6351    target_request_count: u64,
6352    remote_key_retrieval: &'static str,
6353    token_forwarding: &'static str,
6354}
6355
6356#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6357#[derive(Serialize)]
6358struct AssessmentJwtPolicyViolationDocument {
6359    kind: &'static str,
6360    #[serde(skip_serializing_if = "Option::is_none")]
6361    ordinal: Option<u64>,
6362}
6363
6364#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6365#[derive(Serialize)]
6366struct AssessmentJwtTargetAcceptanceAuditDocument {
6367    schema: &'static str,
6368    policy: &'static str,
6369    selected: bool,
6370    operator_policy_reference: String,
6371    operator_policy_revision: String,
6372    resource_reference: String,
6373    method: &'static str,
6374    preparation_reference: String,
6375    status: &'static str,
6376    accounting: AssessmentJwtTargetAcceptanceAccountingDocument,
6377    dimensions: AssessmentJwtTargetAcceptanceDimensionsDocument,
6378    conclusion: AssessmentJwtTargetAcceptanceConclusionDocument,
6379    legs: Vec<AssessmentJwtTargetAcceptanceLegDocument>,
6380    source_authentication: &'static str,
6381    interpretation_limits: [&'static str; 6],
6382}
6383
6384#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6385#[derive(Serialize)]
6386struct AssessmentJwtTargetAcceptanceAccountingDocument {
6387    request_limit: u64,
6388    active_request_limit: u64,
6389    retained_response_byte_limit: u64,
6390    total_retained_response_byte_limit: u64,
6391    dispatched_request_count: u64,
6392    dispatched_passive_request_count: u64,
6393    dispatched_active_request_count: u64,
6394    committed_response_count: u64,
6395    retained_response_bytes: u64,
6396    accounted_transport_response_bytes: u64,
6397}
6398
6399#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6400#[derive(Serialize)]
6401struct AssessmentJwtTargetAcceptanceDimensionsDocument {
6402    valid_marker: &'static str,
6403    anonymous_marker: &'static str,
6404    invalid_marker: &'static str,
6405}
6406
6407#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6408#[derive(Serialize)]
6409struct AssessmentJwtTargetAcceptanceConclusionDocument {
6410    kind: &'static str,
6411    #[serde(skip_serializing_if = "Option::is_none")]
6412    incomplete_reason: Option<AssessmentJwtTargetAcceptanceIncompleteReasonDocument>,
6413}
6414
6415#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6416#[derive(Serialize)]
6417struct AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
6418    kind: &'static str,
6419    #[serde(skip_serializing_if = "Option::is_none")]
6420    role: Option<&'static str>,
6421    #[serde(skip_serializing_if = "Option::is_none")]
6422    not_eligible_reason: Option<&'static str>,
6423}
6424
6425#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6426#[derive(Serialize)]
6427struct AssessmentJwtTargetAcceptanceLegDocument {
6428    role: &'static str,
6429    stage: &'static str,
6430    activity: &'static str,
6431    dispatch_status: &'static str,
6432    commit_status: &'static str,
6433    response_status: &'static str,
6434    marker_status: &'static str,
6435    retained_response_bytes: u64,
6436    accounted_transport_response_bytes: u64,
6437    #[serde(skip_serializing_if = "Option::is_none")]
6438    evidence_reference: Option<String>,
6439}
6440
6441#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
6442impl AssessmentJwtPolicyReviewAuditDocument {
6443    fn from_audits(
6444        audit: &JwtPolicyReviewAudit,
6445        #[cfg(feature = "jwt-target-acceptance-review")] target_acceptance: Option<
6446            &JwtTargetAcceptanceAudit,
6447        >,
6448    ) -> Result<Self, ReportError> {
6449        let (parsing_status, parsing_rejection) = match audit.parsing_status() {
6450            JwtParsingStatus::Parsed => ("parsed", None),
6451            JwtParsingStatus::Rejected(reason) => ("rejected", Some(jwt_parse_rejection(reason))),
6452        };
6453        let methodology = audit.methodology();
6454        let external = audit.external_activity();
6455        let policy_violations = audit
6456            .policy_violations()
6457            .iter()
6458            .copied()
6459            .map(jwt_policy_violation)
6460            .collect::<Vec<_>>();
6461        #[cfg(feature = "jwt-target-acceptance-review")]
6462        let target_acceptance = target_acceptance
6463            .map(AssessmentJwtTargetAcceptanceAuditDocument::from_audit)
6464            .transpose()?;
6465        #[cfg(feature = "jwt-target-acceptance-review")]
6466        let preparation_reference = audit
6467            .target_preparation_binding()
6468            .map(|binding| binding.reference());
6469        let document = Self {
6470            #[cfg(not(feature = "jwt-target-acceptance-review"))]
6471            schema: audit.schema(),
6472            #[cfg(feature = "jwt-target-acceptance-review")]
6473            schema: if target_acceptance.is_some() {
6474                JWT_POLICY_REVIEW_AUDIT_SCHEMA_V2
6475            } else {
6476                audit.schema()
6477            },
6478            policy: audit.policy(),
6479            selected: audit.selected(),
6480            parsing_status,
6481            parsing_rejection,
6482            policy_status: match audit.policy_status() {
6483                JwtPolicyStatus::NotEvaluated => "not_evaluated",
6484                JwtPolicyStatus::Consistent => "consistent",
6485                JwtPolicyStatus::Inconsistent => "inconsistent",
6486            },
6487            local_signature_status: match audit.local_signature_status() {
6488                JwtLocalSignatureStatus::NotEvaluated => "not_evaluated",
6489                JwtLocalSignatureStatus::Verified => "verified",
6490                JwtLocalSignatureStatus::Invalid => "invalid",
6491            },
6492            #[cfg(not(feature = "jwt-target-acceptance-review"))]
6493            target_acceptance_status: match audit.target_acceptance_status() {
6494                JwtTargetAcceptanceStatus::NotPerformed => "not_performed",
6495            },
6496            #[cfg(feature = "jwt-target-acceptance-review")]
6497            target_acceptance_status: target_acceptance.as_ref().map_or_else(
6498                || match audit.target_acceptance_status() {
6499                    JwtTargetAcceptanceStatus::NotPerformed => "not_performed",
6500                },
6501                |target| target.status,
6502            ),
6503            methodology: AssessmentJwtPolicyMethodologyDocument {
6504                algorithm: JWT_POLICY_ALGORITHM,
6505                representation: JWT_POLICY_REPRESENTATION,
6506                local_key_source: JWT_POLICY_LOCAL_KEY_SOURCE,
6507                key_source_assurance: JWT_POLICY_KEY_SOURCE_ASSURANCE,
6508                token_carried_key_selection: JWT_POLICY_TOKEN_KEY_SELECTION,
6509                operator_policy_reference: methodology.operator_policy_reference().to_owned(),
6510                operator_policy_revision: methodology.operator_policy_revision().to_owned(),
6511                local_public_key_sha256: methodology.local_public_key_sha256().to_owned(),
6512                expected_type_selected: methodology.expected_type_selected(),
6513                expected_issuer_selected: methodology.expected_issuer_selected(),
6514                expected_audience_selected: methodology.expected_audience_selected(),
6515                required_claim_count: u64::from(methodology.required_claim_count()),
6516                require_expiration: methodology.require_expiration(),
6517                allowed_clock_skew_seconds: u64::from(methodology.allowed_clock_skew_seconds()),
6518                evaluation_time_unix_seconds: methodology.evaluation_time_unix_seconds(),
6519                clock_assurance: match methodology.clock_assurance() {
6520                    JwtClockAssurance::LocalSystemClockNotIndependentlyVerified => {
6521                        JWT_POLICY_CLOCK_ASSURANCE
6522                    },
6523                },
6524            },
6525            external_activity: AssessmentJwtExternalActivityDocument {
6526                #[cfg(not(feature = "jwt-target-acceptance-review"))]
6527                target_request_count: u64::from(external.target_request_count()),
6528                #[cfg(feature = "jwt-target-acceptance-review")]
6529                target_request_count: target_acceptance.as_ref().map_or_else(
6530                    || u64::from(external.target_request_count()),
6531                    |target| target.accounting.dispatched_request_count,
6532                ),
6533                remote_key_retrieval: match external.remote_key_retrieval() {
6534                    JwtExternalOperationStatus::NotPerformed => JWT_POLICY_EXTERNAL_NOT_PERFORMED,
6535                },
6536                #[cfg(not(feature = "jwt-target-acceptance-review"))]
6537                token_forwarding: match external.token_forwarding() {
6538                    JwtExternalOperationStatus::NotPerformed => JWT_POLICY_EXTERNAL_NOT_PERFORMED,
6539                },
6540                #[cfg(feature = "jwt-target-acceptance-review")]
6541                token_forwarding: target_acceptance.as_ref().map_or_else(
6542                    || match external.token_forwarding() {
6543                        JwtExternalOperationStatus::NotPerformed => {
6544                            JWT_POLICY_EXTERNAL_NOT_PERFORMED
6545                        },
6546                    },
6547                    |target| {
6548                        if target.legs.iter().any(|leg| {
6549                            leg.dispatch_status == "dispatched"
6550                                && leg.role != "anonymous_candidate"
6551                                && leg.role != "anonymous_replay"
6552                        }) {
6553                            "performed"
6554                        } else {
6555                            JWT_POLICY_EXTERNAL_NOT_PERFORMED
6556                        }
6557                    },
6558                ),
6559            },
6560            policy_violations,
6561            source_authentication: JWT_POLICY_SOURCE_AUTHENTICATION,
6562            #[cfg(feature = "jwt-target-acceptance-review")]
6563            preparation_reference,
6564            #[cfg(feature = "jwt-target-acceptance-review")]
6565            target_acceptance,
6566        };
6567        document.validate()?;
6568        Ok(document)
6569    }
6570
6571    fn validate(&self) -> Result<(), ReportError> {
6572        #[cfg(feature = "jwt-target-acceptance-review")]
6573        let target_contract_valid = match (&self.target_acceptance, self.schema) {
6574            (None, JWT_POLICY_REVIEW_AUDIT_SCHEMA) => {
6575                self.preparation_reference.is_none()
6576                    && self.target_acceptance_status == "not_performed"
6577                    && self.external_activity.target_request_count == 0
6578                    && self.external_activity.token_forwarding == JWT_POLICY_EXTERNAL_NOT_PERFORMED
6579            },
6580            (Some(target), JWT_POLICY_REVIEW_AUDIT_SCHEMA_V2) => {
6581                target.validate().is_ok()
6582                    && self.preparation_reference.as_deref()
6583                        == Some(target.preparation_reference.as_str())
6584                    && self
6585                        .preparation_reference
6586                        .as_deref()
6587                        .is_some_and(valid_jwt_target_preparation_reference)
6588                    && self.target_local_state_is_consistent(target)
6589                    && self.target_acceptance_status == target.status
6590                    && self.external_activity.target_request_count
6591                        == target.accounting.dispatched_request_count
6592                    && self.external_activity.token_forwarding
6593                        == if target.legs.iter().any(|leg| {
6594                            leg.dispatch_status == "dispatched"
6595                                && leg.role != "anonymous_candidate"
6596                                && leg.role != "anonymous_replay"
6597                        }) {
6598                            "performed"
6599                        } else {
6600                            JWT_POLICY_EXTERNAL_NOT_PERFORMED
6601                        }
6602            },
6603            _ => false,
6604        };
6605        #[cfg(not(feature = "jwt-target-acceptance-review"))]
6606        let target_contract_valid = self.schema == JWT_POLICY_REVIEW_AUDIT_SCHEMA
6607            && self.target_acceptance_status == "not_performed"
6608            && self.external_activity.target_request_count == 0
6609            && self.external_activity.token_forwarding == JWT_POLICY_EXTERNAL_NOT_PERFORMED;
6610
6611        if !target_contract_valid
6612            || self.policy != JWT_POLICY_REVIEW_POLICY_ID
6613            || !self.selected
6614            || !valid_jwt_policy_reference(&self.methodology.operator_policy_reference)
6615            || !valid_jwt_policy_reference(&self.methodology.operator_policy_revision)
6616            || !valid_jwt_public_key_sha256(&self.methodology.local_public_key_sha256)
6617            || self.methodology.algorithm != JWT_POLICY_ALGORITHM
6618            || self.methodology.representation != JWT_POLICY_REPRESENTATION
6619            || self.methodology.local_key_source != JWT_POLICY_LOCAL_KEY_SOURCE
6620            || self.methodology.key_source_assurance != JWT_POLICY_KEY_SOURCE_ASSURANCE
6621            || self.methodology.token_carried_key_selection != JWT_POLICY_TOKEN_KEY_SELECTION
6622            || !self.methodology.expected_type_selected
6623            || !self.methodology.expected_issuer_selected
6624            || !self.methodology.expected_audience_selected
6625            || self.methodology.required_claim_count
6626                > u64::try_from(MAX_REQUIRED_CLAIMS).map_err(|_| ReportError::Serialization)?
6627            || self.methodology.allowed_clock_skew_seconds > u64::from(MAX_CLOCK_SKEW_SECONDS)
6628            || self.methodology.clock_assurance != JWT_POLICY_CLOCK_ASSURANCE
6629            || self.external_activity.remote_key_retrieval != JWT_POLICY_EXTERNAL_NOT_PERFORMED
6630            || self.source_authentication != JWT_POLICY_SOURCE_AUTHENTICATION
6631            || self.policy_violations.len() > MAX_JWT_POLICY_VIOLATIONS
6632        {
6633            return Err(ReportError::Serialization);
6634        }
6635        let state_valid = match self.parsing_status {
6636            "rejected" => {
6637                self.parsing_rejection.is_some()
6638                    && self.policy_status == "not_evaluated"
6639                    && self.local_signature_status == "not_evaluated"
6640                    && self.policy_violations.is_empty()
6641            },
6642            "parsed" => {
6643                self.parsing_rejection.is_none()
6644                    && matches!(self.local_signature_status, "verified" | "invalid")
6645                    && match self.policy_status {
6646                        "consistent" => self.policy_violations.is_empty(),
6647                        "inconsistent" => !self.policy_violations.is_empty(),
6648                        _ => false,
6649                    }
6650            },
6651            _ => false,
6652        };
6653        if !state_valid {
6654            return Err(ReportError::Serialization);
6655        }
6656        if let Some(reason) = self.parsing_rejection {
6657            if !JWT_PARSE_REJECTION_TOKENS.contains(&reason) {
6658                return Err(ReportError::Serialization);
6659            }
6660        }
6661        let mut unique = std::collections::BTreeSet::new();
6662        let mut exclusive_categories = std::collections::BTreeSet::new();
6663        for violation in &self.policy_violations {
6664            let (valid, category) = match violation.kind {
6665                "missing_type" | "type_mismatch" => (
6666                    violation.ordinal.is_none() && self.methodology.expected_type_selected,
6667                    Some("type"),
6668                ),
6669                "missing_issuer" | "invalid_issuer_type" | "issuer_mismatch" => (
6670                    violation.ordinal.is_none() && self.methodology.expected_issuer_selected,
6671                    Some("issuer"),
6672                ),
6673                "missing_audience" | "invalid_audience_type" | "audience_mismatch" => (
6674                    violation.ordinal.is_none() && self.methodology.expected_audience_selected,
6675                    Some("audience"),
6676                ),
6677                "missing_required_claim" => (
6678                    violation
6679                        .ordinal
6680                        .is_some_and(|ordinal| ordinal < self.methodology.required_claim_count),
6681                    None,
6682                ),
6683                "missing_expiration" => (
6684                    violation.ordinal.is_none() && self.methodology.require_expiration,
6685                    Some("expiration"),
6686                ),
6687                "invalid_expiration" | "expired" => {
6688                    (violation.ordinal.is_none(), Some("expiration"))
6689                },
6690                "invalid_not_before" | "not_yet_valid" => {
6691                    (violation.ordinal.is_none(), Some("not_before"))
6692                },
6693                _ => (false, None),
6694            };
6695            if !valid
6696                || !unique.insert((violation.kind, violation.ordinal))
6697                || category.is_some_and(|category| !exclusive_categories.insert(category))
6698            {
6699                return Err(ReportError::Serialization);
6700            }
6701        }
6702        Ok(())
6703    }
6704
6705    #[cfg(feature = "jwt-target-acceptance-review")]
6706    fn target_local_state_is_consistent(
6707        &self,
6708        target: &AssessmentJwtTargetAcceptanceAuditDocument,
6709    ) -> bool {
6710        let local_eligible = self.parsing_status == "parsed"
6711            && self.policy_status == "consistent"
6712            && self.local_signature_status == "verified";
6713        if !target.legs.is_empty() {
6714            return local_eligible;
6715        }
6716        let reason = target
6717            .conclusion
6718            .incomplete_reason
6719            .as_ref()
6720            .and_then(|reason| reason.not_eligible_reason);
6721        match reason {
6722            Some("local_parsing_not_established") => self.parsing_status == "rejected",
6723            Some("local_policy_not_established") => {
6724                self.parsing_status == "parsed" && self.policy_status == "inconsistent"
6725            },
6726            Some("local_signature_not_established") => {
6727                self.parsing_status == "parsed"
6728                    && self.policy_status == "consistent"
6729                    && self.local_signature_status == "invalid"
6730            },
6731            Some("runtime_authority_unavailable" | "request_budget_unavailable") => local_eligible,
6732            _ => false,
6733        }
6734    }
6735
6736    fn metadata(&self) -> Vec<(&'static str, String)> {
6737        let metadata = vec![
6738            ("Audit schema", self.schema.to_owned()),
6739            ("Policy", self.policy.to_owned()),
6740            ("Selected", self.selected.to_string()),
6741            ("Parsing status", self.parsing_status.to_owned()),
6742            ("Policy status", self.policy_status.to_owned()),
6743            (
6744                "Local signature status",
6745                self.local_signature_status.to_owned(),
6746            ),
6747            (
6748                "Target acceptance status",
6749                self.target_acceptance_status.to_owned(),
6750            ),
6751            (
6752                "Operator policy reference",
6753                self.methodology.operator_policy_reference.clone(),
6754            ),
6755            (
6756                "Operator policy revision",
6757                self.methodology.operator_policy_revision.clone(),
6758            ),
6759            (
6760                "Local public key SHA-256",
6761                self.methodology.local_public_key_sha256.clone(),
6762            ),
6763            ("Algorithm", self.methodology.algorithm.to_owned()),
6764            ("Representation", self.methodology.representation.to_owned()),
6765            (
6766                "Local key source",
6767                self.methodology.local_key_source.to_owned(),
6768            ),
6769            (
6770                "Key source assurance",
6771                self.methodology.key_source_assurance.to_owned(),
6772            ),
6773            (
6774                "Token-carried key selection",
6775                self.methodology.token_carried_key_selection.to_owned(),
6776            ),
6777            (
6778                "Required claim count",
6779                self.methodology.required_claim_count.to_string(),
6780            ),
6781            (
6782                "Allowed clock skew seconds",
6783                self.methodology.allowed_clock_skew_seconds.to_string(),
6784            ),
6785            (
6786                "Evaluation time (Unix seconds)",
6787                self.methodology.evaluation_time_unix_seconds.to_string(),
6788            ),
6789            (
6790                "Policy violation count",
6791                self.policy_violations.len().to_string(),
6792            ),
6793            (
6794                "Target request count",
6795                self.external_activity.target_request_count.to_string(),
6796            ),
6797            (
6798                "Source authentication",
6799                self.source_authentication.to_owned(),
6800            ),
6801        ];
6802        #[cfg(feature = "jwt-target-acceptance-review")]
6803        let metadata = {
6804            let mut metadata = metadata;
6805            if let Some(target) = &self.target_acceptance {
6806                metadata.extend([
6807                    (
6808                        "Target policy reference",
6809                        target.operator_policy_reference.clone(),
6810                    ),
6811                    (
6812                        "Target policy revision",
6813                        target.operator_policy_revision.clone(),
6814                    ),
6815                    (
6816                        "Target resource reference",
6817                        target.resource_reference.clone(),
6818                    ),
6819                    (
6820                        "Target dispatched requests",
6821                        target.accounting.dispatched_request_count.to_string(),
6822                    ),
6823                    (
6824                        "Target committed responses",
6825                        target.accounting.committed_response_count.to_string(),
6826                    ),
6827                    (
6828                        "Target retained response bytes",
6829                        target.accounting.retained_response_bytes.to_string(),
6830                    ),
6831                    (
6832                        "Target accounted transport response bytes",
6833                        target
6834                            .accounting
6835                            .accounted_transport_response_bytes
6836                            .to_string(),
6837                    ),
6838                    ("Target conclusion", target.conclusion.kind.to_owned()),
6839                ]);
6840                if let Some(reason) = &target.conclusion.incomplete_reason {
6841                    let value = match (reason.role, reason.not_eligible_reason) {
6842                        (Some(role), None) => {
6843                            format!("kind={};role={role}", reason.kind)
6844                        },
6845                        (None, Some(not_eligible_reason)) => format!(
6846                            "kind={};not_eligible_reason={not_eligible_reason}",
6847                            reason.kind
6848                        ),
6849                        (Some(role), Some(not_eligible_reason)) => format!(
6850                            "kind={};role={role};not_eligible_reason={not_eligible_reason}",
6851                            reason.kind
6852                        ),
6853                        (None, None) => format!("kind={}", reason.kind),
6854                    };
6855                    metadata.push(("Target incomplete reason", value));
6856                }
6857            }
6858            metadata
6859        };
6860        metadata
6861    }
6862
6863    fn wire_json(&self) -> Result<String, ReportError> {
6864        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
6865    }
6866}
6867
6868#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
6869impl AssessmentJwtTargetAcceptanceAuditDocument {
6870    fn from_audit(audit: &JwtTargetAcceptanceAudit) -> Result<Self, ReportError> {
6871        let accounting = audit.accounting();
6872        let conclusion = jwt_target_conclusion_document(audit.conclusion());
6873        let status = if accounting.dispatched_request_count() == 0 {
6874            "not_performed"
6875        } else if matches!(
6876            audit.conclusion(),
6877            JwtTargetAcceptanceConclusion::Incomplete(_)
6878        ) {
6879            "partially_performed"
6880        } else {
6881            "completed"
6882        };
6883        let document = Self {
6884            schema: audit.schema(),
6885            policy: audit.policy(),
6886            selected: true,
6887            operator_policy_reference: audit.operator_policy_reference().to_owned(),
6888            operator_policy_revision: audit.operator_policy_revision().to_owned(),
6889            resource_reference: audit.resource_reference().to_owned(),
6890            method: match audit.method() {
6891                JwtTargetAcceptanceMethod::Get => "get",
6892            },
6893            preparation_reference: audit
6894                .preparation_binding()
6895                .ok_or(ReportError::Serialization)?
6896                .reference(),
6897            status,
6898            accounting: AssessmentJwtTargetAcceptanceAccountingDocument {
6899                request_limit: u64::from(MAX_JWT_TARGET_ACCEPTANCE_REQUESTS),
6900                active_request_limit: u64::from(MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS),
6901                retained_response_byte_limit: MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
6902                total_retained_response_byte_limit: MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES,
6903                dispatched_request_count: u64::from(accounting.dispatched_request_count()),
6904                dispatched_passive_request_count: u64::from(
6905                    accounting.dispatched_passive_request_count(),
6906                ),
6907                dispatched_active_request_count: u64::from(
6908                    accounting.dispatched_active_request_count(),
6909                ),
6910                committed_response_count: u64::from(accounting.committed_response_count()),
6911                retained_response_bytes: accounting.retained_response_bytes(),
6912                accounted_transport_response_bytes: accounting.accounted_response_bytes(),
6913            },
6914            dimensions: AssessmentJwtTargetAcceptanceDimensionsDocument {
6915                valid_marker: jwt_target_dimension(audit.valid_marker()),
6916                anonymous_marker: jwt_target_dimension(audit.anonymous_marker()),
6917                invalid_marker: jwt_target_dimension(audit.invalid_marker()),
6918            },
6919            conclusion,
6920            legs: audit
6921                .legs()
6922                .iter()
6923                .map(|leg| AssessmentJwtTargetAcceptanceLegDocument {
6924                    role: jwt_target_role(leg.role()),
6925                    stage: jwt_target_stage(leg.role().stage()),
6926                    activity: jwt_target_activity(leg.role().activity()),
6927                    dispatch_status: match leg.dispatch_status() {
6928                        JwtTargetAcceptanceDispatchStatus::NotDispatched => "not_dispatched",
6929                        JwtTargetAcceptanceDispatchStatus::Dispatched => "dispatched",
6930                    },
6931                    commit_status: match leg.commit_status() {
6932                        JwtTargetAcceptanceCommitStatus::NotCommitted => "not_committed",
6933                        JwtTargetAcceptanceCommitStatus::Committed => "committed",
6934                    },
6935                    response_status: match leg.response_status() {
6936                        JwtTargetAcceptanceResponseStatus::NotClassified => "not_classified",
6937                        JwtTargetAcceptanceResponseStatus::CompleteAndClassified => {
6938                            "complete_and_classified"
6939                        },
6940                        JwtTargetAcceptanceResponseStatus::Incomplete => "incomplete",
6941                    },
6942                    marker_status: match leg.marker_status() {
6943                        JwtTargetAcceptanceMarkerStatus::Observed => "observed",
6944                        JwtTargetAcceptanceMarkerStatus::NotObserved => "not_observed",
6945                        JwtTargetAcceptanceMarkerStatus::NotEvaluated => "not_evaluated",
6946                    },
6947                    retained_response_bytes: leg.retained_response_bytes(),
6948                    accounted_transport_response_bytes: leg.accounted_response_bytes(),
6949                    evidence_reference: leg.evidence_reference().map(str::to_owned),
6950                })
6951                .collect(),
6952            source_authentication: JWT_TARGET_SOURCE_AUTHENTICATION,
6953            interpretation_limits: JWT_TARGET_INTERPRETATION_LIMITS,
6954        };
6955        document.validate()?;
6956        Ok(document)
6957    }
6958
6959    fn validate(&self) -> Result<(), ReportError> {
6960        if self.schema != JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA
6961            || self.policy != JWT_TARGET_ACCEPTANCE_POLICY_ID
6962            || !self.selected
6963            || !valid_jwt_policy_reference(&self.operator_policy_reference)
6964            || !valid_jwt_policy_reference(&self.operator_policy_revision)
6965            || !valid_jwt_policy_reference(&self.resource_reference)
6966            || !valid_jwt_target_preparation_reference(&self.preparation_reference)
6967            || self.method != "get"
6968            || self.source_authentication != JWT_TARGET_SOURCE_AUTHENTICATION
6969            || self.interpretation_limits != JWT_TARGET_INTERPRETATION_LIMITS
6970            || self.accounting.request_limit != u64::from(MAX_JWT_TARGET_ACCEPTANCE_REQUESTS)
6971            || self.accounting.active_request_limit
6972                != u64::from(MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS)
6973            || self.accounting.retained_response_byte_limit
6974                != MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES
6975            || self.accounting.total_retained_response_byte_limit
6976                != MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES
6977        {
6978            return Err(ReportError::Serialization);
6979        }
6980
6981        let conclusion_incomplete = self.conclusion.kind == "incomplete";
6982        if conclusion_incomplete != self.conclusion.incomplete_reason.is_some()
6983            || !matches!(
6984                self.conclusion.kind,
6985                "invalid_signature_control_marker_observed_with_anonymous_control"
6986                    | "public_or_token_agnostic_marker_observed"
6987                    | "invalid_control_marker_not_observed"
6988                    | "unstable_inconclusive"
6989                    | "inconclusive"
6990                    | "incomplete"
6991            )
6992            || ![
6993                "observed_stable",
6994                "not_observed_stable",
6995                "unstable",
6996                "not_evaluated",
6997                "incomplete",
6998            ]
6999            .contains(&self.dimensions.valid_marker)
7000            || ![
7001                "observed_stable",
7002                "not_observed_stable",
7003                "unstable",
7004                "not_evaluated",
7005                "incomplete",
7006            ]
7007            .contains(&self.dimensions.anonymous_marker)
7008            || ![
7009                "observed_stable",
7010                "not_observed_stable",
7011                "unstable",
7012                "not_evaluated",
7013                "incomplete",
7014            ]
7015            .contains(&self.dimensions.invalid_marker)
7016        {
7017            return Err(ReportError::Serialization);
7018        }
7019        if let Some(reason) = &self.conclusion.incomplete_reason {
7020            let valid = match reason.kind {
7021                "not_eligible" => {
7022                    reason.role.is_none()
7023                        && matches!(
7024                            reason.not_eligible_reason,
7025                            Some(
7026                                "local_parsing_not_established"
7027                                    | "local_policy_not_established"
7028                                    | "local_signature_not_established"
7029                                    | "runtime_authority_unavailable"
7030                                    | "request_budget_unavailable"
7031                            )
7032                        )
7033                },
7034                "leg_not_dispatched"
7035                | "leg_not_committed"
7036                | "response_incomplete"
7037                | "response_not_classified" => {
7038                    reason.not_eligible_reason.is_none()
7039                        && reason.role.is_some_and(valid_jwt_target_role)
7040                },
7041                _ => false,
7042            };
7043            if !valid {
7044                return Err(ReportError::Serialization);
7045            }
7046        }
7047
7048        if !(self.legs.is_empty()
7049            || self.legs.len() == usize::from(MAX_JWT_TARGET_ACCEPTANCE_REQUESTS))
7050        {
7051            return Err(ReportError::Serialization);
7052        }
7053        let mut dispatched = 0_u64;
7054        let mut passive = 0_u64;
7055        let mut active = 0_u64;
7056        let mut committed = 0_u64;
7057        let mut retained_bytes = 0_u64;
7058        let mut accounted_transport_bytes = 0_u64;
7059        let mut evidence_references = std::collections::BTreeSet::new();
7060        let mut dispatches = Vec::with_capacity(self.legs.len());
7061        let mut commits = Vec::with_capacity(self.legs.len());
7062        let mut responses = Vec::with_capacity(self.legs.len());
7063        let mut markers = Vec::with_capacity(self.legs.len());
7064        let mut retained_by_leg = Vec::with_capacity(self.legs.len());
7065        for (index, leg) in self.legs.iter().enumerate() {
7066            let expected_role = jwt_target_role(JwtTargetAcceptanceLegRole::ORDERED[index]);
7067            let expected_stage =
7068                jwt_target_stage(JwtTargetAcceptanceLegRole::ORDERED[index].stage());
7069            let expected_activity =
7070                jwt_target_activity(JwtTargetAcceptanceLegRole::ORDERED[index].activity());
7071            if leg.role != expected_role
7072                || leg.stage != expected_stage
7073                || leg.activity != expected_activity
7074                || leg.retained_response_bytes > MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES
7075                || leg.accounted_transport_response_bytes < leg.retained_response_bytes
7076            {
7077                return Err(ReportError::Serialization);
7078            }
7079            let leg_valid = match (leg.dispatch_status, leg.commit_status) {
7080                ("not_dispatched", "not_committed") => {
7081                    leg.response_status == "not_classified"
7082                        && leg.marker_status == "not_evaluated"
7083                        && leg.retained_response_bytes == 0
7084                        && leg.accounted_transport_response_bytes == 0
7085                        && leg.evidence_reference.is_none()
7086                },
7087                ("dispatched", "not_committed") => {
7088                    leg.response_status != "complete_and_classified"
7089                        && leg.marker_status == "not_evaluated"
7090                        && leg.evidence_reference.is_none()
7091                },
7092                ("dispatched", "committed") => {
7093                    leg.evidence_reference
7094                        .as_deref()
7095                        .is_some_and(valid_jwt_target_evidence_reference)
7096                        && match leg.response_status {
7097                            "complete_and_classified" => {
7098                                matches!(leg.marker_status, "observed" | "not_observed")
7099                            },
7100                            "not_classified" | "incomplete" => leg.marker_status == "not_evaluated",
7101                            _ => false,
7102                        }
7103                },
7104                _ => false,
7105            };
7106            if !leg_valid {
7107                return Err(ReportError::Serialization);
7108            }
7109            if leg.dispatch_status == "dispatched" {
7110                dispatched += 1;
7111                if leg.activity == "active" {
7112                    active += 1;
7113                } else {
7114                    passive += 1;
7115                }
7116            }
7117            if leg.commit_status == "committed" {
7118                committed += 1;
7119            }
7120            retained_bytes = retained_bytes
7121                .checked_add(leg.retained_response_bytes)
7122                .ok_or(ReportError::Serialization)?;
7123            accounted_transport_bytes = accounted_transport_bytes
7124                .checked_add(leg.accounted_transport_response_bytes)
7125                .ok_or(ReportError::Serialization)?;
7126            if let Some(reference) = leg.evidence_reference.as_deref() {
7127                if !evidence_references.insert(reference) {
7128                    return Err(ReportError::Serialization);
7129                }
7130            }
7131            dispatches.push(leg.dispatch_status);
7132            commits.push(leg.commit_status);
7133            responses.push(leg.response_status);
7134            markers.push(leg.marker_status);
7135            retained_by_leg.push(leg.retained_response_bytes);
7136        }
7137        if self.accounting.dispatched_request_count != dispatched
7138            || self.accounting.dispatched_passive_request_count != passive
7139            || self.accounting.dispatched_active_request_count != active
7140            || self.accounting.committed_response_count != committed
7141            || self.accounting.retained_response_bytes != retained_bytes
7142            || self.accounting.accounted_transport_response_bytes != accounted_transport_bytes
7143            || dispatched > self.accounting.request_limit
7144            || active > self.accounting.active_request_limit
7145            || retained_bytes > self.accounting.total_retained_response_byte_limit
7146            || accounted_transport_bytes < retained_bytes
7147        {
7148            return Err(ReportError::Serialization);
7149        }
7150        if self.legs.len() == usize::from(MAX_JWT_TARGET_ACCEPTANCE_REQUESTS) {
7151            let passive_prerequisite_is_established =
7152                |valid_index: usize, anonymous_index: usize| {
7153                    commits[valid_index] == "committed"
7154                        && responses[valid_index] == "complete_and_classified"
7155                        && markers[valid_index] == "observed"
7156                        && commits[anonymous_index] == "committed"
7157                        && responses[anonymous_index] == "complete_and_classified"
7158                        && markers[anonymous_index] == "not_observed"
7159                };
7160            for (valid_index, anonymous_index, invalid_index) in [(0, 1, 2), (3, 4, 5)] {
7161                if dispatches[invalid_index] == "dispatched"
7162                    && !passive_prerequisite_is_established(valid_index, anonymous_index)
7163                {
7164                    return Err(ReportError::Serialization);
7165                }
7166            }
7167
7168            let mut stopped = false;
7169            let mut sequential_retained_bytes = 0_u64;
7170            for index in 0..self.legs.len() {
7171                if stopped {
7172                    if dispatches[index] == "dispatched" {
7173                        return Err(ReportError::Serialization);
7174                    }
7175                    continue;
7176                }
7177                if dispatches[index] == "not_dispatched" {
7178                    let prerequisite_skip = match index {
7179                        2 => !passive_prerequisite_is_established(0, 1),
7180                        5 => !passive_prerequisite_is_established(3, 4),
7181                        _ => false,
7182                    };
7183                    if !prerequisite_skip {
7184                        stopped = true;
7185                    }
7186                    continue;
7187                }
7188                sequential_retained_bytes = sequential_retained_bytes
7189                    .checked_add(retained_by_leg[index])
7190                    .ok_or(ReportError::Serialization)?;
7191                if commits[index] != "committed"
7192                    || sequential_retained_bytes >= MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES
7193                {
7194                    stopped = true;
7195                }
7196            }
7197
7198            let pair_dimension = |candidate: usize, replay: usize| {
7199                if dispatches[candidate] == "not_dispatched"
7200                    && dispatches[replay] == "not_dispatched"
7201                {
7202                    "not_evaluated"
7203                } else if commits[candidate] != "committed"
7204                    || commits[replay] != "committed"
7205                    || responses[candidate] != "complete_and_classified"
7206                    || responses[replay] != "complete_and_classified"
7207                {
7208                    "incomplete"
7209                } else {
7210                    match (markers[candidate], markers[replay]) {
7211                        ("observed", "observed") => "observed_stable",
7212                        ("not_observed", "not_observed") => "not_observed_stable",
7213                        _ => "unstable",
7214                    }
7215                }
7216            };
7217            let expected_valid = pair_dimension(0, 3);
7218            let expected_anonymous = pair_dimension(1, 4);
7219            let expected_invalid = pair_dimension(2, 5);
7220            if self.dimensions.valid_marker != expected_valid
7221                || self.dimensions.anonymous_marker != expected_anonymous
7222                || self.dimensions.invalid_marker != expected_invalid
7223            {
7224                return Err(ReportError::Serialization);
7225            }
7226            let expected_conclusion =
7227                if [expected_valid, expected_anonymous, expected_invalid].contains(&"unstable") {
7228                    "unstable_inconclusive"
7229                } else if expected_anonymous == "observed_stable" {
7230                    "public_or_token_agnostic_marker_observed"
7231                } else if expected_valid == "observed_stable"
7232                    && expected_anonymous == "not_observed_stable"
7233                    && expected_invalid == "observed_stable"
7234                {
7235                    "invalid_signature_control_marker_observed_with_anonymous_control"
7236                } else if expected_valid == "observed_stable"
7237                    && expected_anonymous == "not_observed_stable"
7238                    && expected_invalid == "not_observed_stable"
7239                {
7240                    "invalid_control_marker_not_observed"
7241                } else if [expected_valid, expected_anonymous, expected_invalid]
7242                    .iter()
7243                    .any(|value| matches!(*value, "incomplete" | "not_evaluated"))
7244                {
7245                    "incomplete"
7246                } else {
7247                    "inconclusive"
7248                };
7249            if self.conclusion.kind != expected_conclusion {
7250                return Err(ReportError::Serialization);
7251            }
7252            if expected_conclusion == "incomplete" {
7253                let (kind, role) = self
7254                    .legs
7255                    .iter()
7256                    .find_map(|leg| {
7257                        if leg.dispatch_status == "not_dispatched" {
7258                            Some(("leg_not_dispatched", leg.role))
7259                        } else if leg.commit_status != "committed" {
7260                            Some(("leg_not_committed", leg.role))
7261                        } else {
7262                            match leg.response_status {
7263                                "incomplete" => Some(("response_incomplete", leg.role)),
7264                                "not_classified" => Some(("response_not_classified", leg.role)),
7265                                _ => None,
7266                            }
7267                        }
7268                    })
7269                    .ok_or(ReportError::Serialization)?;
7270                let reason = self
7271                    .conclusion
7272                    .incomplete_reason
7273                    .as_ref()
7274                    .ok_or(ReportError::Serialization)?;
7275                if reason.kind != kind
7276                    || reason.role != Some(role)
7277                    || reason.not_eligible_reason.is_some()
7278                {
7279                    return Err(ReportError::Serialization);
7280                }
7281            }
7282        }
7283        let expected_status = if dispatched == 0 {
7284            "not_performed"
7285        } else if conclusion_incomplete {
7286            "partially_performed"
7287        } else {
7288            "completed"
7289        };
7290        let empty_leg_contract_valid = !self.legs.is_empty()
7291            || self
7292                .conclusion
7293                .incomplete_reason
7294                .as_ref()
7295                .is_some_and(|reason| {
7296                    reason.kind == "not_eligible"
7297                        && reason.role.is_none()
7298                        && reason.not_eligible_reason.is_some()
7299                });
7300        if self.status != expected_status
7301            || !empty_leg_contract_valid
7302            || (self.legs.is_empty()
7303                && !(self.dimensions.valid_marker == "not_evaluated"
7304                    && self.dimensions.anonymous_marker == "not_evaluated"
7305                    && self.dimensions.invalid_marker == "not_evaluated"
7306                    && conclusion_incomplete))
7307        {
7308            return Err(ReportError::Serialization);
7309        }
7310        Ok(())
7311    }
7312}
7313
7314#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7315fn jwt_target_role(role: JwtTargetAcceptanceLegRole) -> &'static str {
7316    match role {
7317        JwtTargetAcceptanceLegRole::ValidCandidate => "valid_candidate",
7318        JwtTargetAcceptanceLegRole::AnonymousCandidate => "anonymous_candidate",
7319        JwtTargetAcceptanceLegRole::InvalidCandidate => "invalid_candidate",
7320        JwtTargetAcceptanceLegRole::ValidReplay => "valid_replay",
7321        JwtTargetAcceptanceLegRole::AnonymousReplay => "anonymous_replay",
7322        JwtTargetAcceptanceLegRole::InvalidReplay => "invalid_replay",
7323    }
7324}
7325
7326#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7327fn valid_jwt_target_role(role: &str) -> bool {
7328    JwtTargetAcceptanceLegRole::ORDERED
7329        .iter()
7330        .copied()
7331        .map(jwt_target_role)
7332        .any(|expected| expected == role)
7333}
7334
7335#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7336fn jwt_target_stage(stage: JwtTargetAcceptanceLegStage) -> &'static str {
7337    match stage {
7338        JwtTargetAcceptanceLegStage::Candidate => "candidate",
7339        JwtTargetAcceptanceLegStage::Replay => "replay",
7340    }
7341}
7342
7343#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7344fn jwt_target_activity(activity: JwtTargetAcceptanceActivity) -> &'static str {
7345    match activity {
7346        JwtTargetAcceptanceActivity::Passive => "passive",
7347        JwtTargetAcceptanceActivity::Active => "active",
7348    }
7349}
7350
7351#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7352fn jwt_target_dimension(dimension: JwtTargetAcceptanceDimension) -> &'static str {
7353    match dimension {
7354        JwtTargetAcceptanceDimension::ObservedStable => "observed_stable",
7355        JwtTargetAcceptanceDimension::NotObservedStable => "not_observed_stable",
7356        JwtTargetAcceptanceDimension::Unstable => "unstable",
7357        JwtTargetAcceptanceDimension::NotEvaluated => "not_evaluated",
7358        JwtTargetAcceptanceDimension::Incomplete => "incomplete",
7359    }
7360}
7361
7362#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7363fn jwt_target_conclusion_document(
7364    conclusion: JwtTargetAcceptanceConclusion,
7365) -> AssessmentJwtTargetAcceptanceConclusionDocument {
7366    let (kind, incomplete_reason) = match conclusion {
7367        JwtTargetAcceptanceConclusion::InvalidSignatureControlMarkerObservedWithAnonymousControl => (
7368            "invalid_signature_control_marker_observed_with_anonymous_control",
7369            None,
7370        ),
7371        JwtTargetAcceptanceConclusion::PublicOrTokenAgnosticMarkerObserved => {
7372            ("public_or_token_agnostic_marker_observed", None)
7373        },
7374        JwtTargetAcceptanceConclusion::InvalidControlMarkerNotObserved => {
7375            ("invalid_control_marker_not_observed", None)
7376        },
7377        JwtTargetAcceptanceConclusion::UnstableInconclusive => ("unstable_inconclusive", None),
7378        JwtTargetAcceptanceConclusion::Inconclusive => ("inconclusive", None),
7379        JwtTargetAcceptanceConclusion::Incomplete(reason) => (
7380            "incomplete",
7381            Some(jwt_target_incomplete_reason_document(reason)),
7382        ),
7383    };
7384    AssessmentJwtTargetAcceptanceConclusionDocument {
7385        kind,
7386        incomplete_reason,
7387    }
7388}
7389
7390#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7391fn jwt_target_incomplete_reason_document(
7392    reason: JwtTargetAcceptanceIncompleteReason,
7393) -> AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
7394    match reason {
7395        JwtTargetAcceptanceIncompleteReason::NotEligible(reason) => {
7396            AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
7397                kind: "not_eligible",
7398                role: None,
7399                not_eligible_reason: Some(match reason {
7400                    JwtTargetAcceptanceNotEligibleReason::LocalParsingNotEstablished => {
7401                        "local_parsing_not_established"
7402                    },
7403                    JwtTargetAcceptanceNotEligibleReason::LocalPolicyNotEstablished => {
7404                        "local_policy_not_established"
7405                    },
7406                    JwtTargetAcceptanceNotEligibleReason::LocalSignatureNotEstablished => {
7407                        "local_signature_not_established"
7408                    },
7409                    JwtTargetAcceptanceNotEligibleReason::RuntimeAuthorityUnavailable => {
7410                        "runtime_authority_unavailable"
7411                    },
7412                    JwtTargetAcceptanceNotEligibleReason::RequestBudgetUnavailable => {
7413                        "request_budget_unavailable"
7414                    },
7415                }),
7416            }
7417        },
7418        JwtTargetAcceptanceIncompleteReason::LegNotDispatched { role } => {
7419            jwt_target_role_incomplete_reason("leg_not_dispatched", role)
7420        },
7421        JwtTargetAcceptanceIncompleteReason::LegNotCommitted { role } => {
7422            jwt_target_role_incomplete_reason("leg_not_committed", role)
7423        },
7424        JwtTargetAcceptanceIncompleteReason::ResponseIncomplete { role } => {
7425            jwt_target_role_incomplete_reason("response_incomplete", role)
7426        },
7427        JwtTargetAcceptanceIncompleteReason::ResponseNotClassified { role } => {
7428            jwt_target_role_incomplete_reason("response_not_classified", role)
7429        },
7430    }
7431}
7432
7433#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7434fn jwt_target_role_incomplete_reason(
7435    kind: &'static str,
7436    role: JwtTargetAcceptanceLegRole,
7437) -> AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
7438    AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
7439        kind,
7440        role: Some(jwt_target_role(role)),
7441        not_eligible_reason: None,
7442    }
7443}
7444
7445#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7446fn valid_jwt_target_evidence_reference(value: &str) -> bool {
7447    value.strip_prefix("jwt-target-leg:").is_some_and(|digest| {
7448        digest.len() == 64
7449            && digest
7450                .bytes()
7451                .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
7452    })
7453}
7454
7455#[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
7456fn valid_jwt_target_preparation_reference(value: &str) -> bool {
7457    value
7458        .strip_prefix("jwt-target-preparation:")
7459        .is_some_and(|binding| {
7460            binding.len() == 64
7461                && binding
7462                    .bytes()
7463                    .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
7464        })
7465}
7466
7467#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
7468const JWT_PARSE_REJECTION_TOKENS: [&str; 17] = [
7469    "encrypted_token_unsupported",
7470    "invalid_segment_count",
7471    "empty_segment",
7472    "invalid_base64_url",
7473    "decoded_segment_too_large",
7474    "malformed_json",
7475    "duplicate_json_key",
7476    "json_limit_exceeded",
7477    "malformed_protected_header",
7478    "unsecured_algorithm_unsupported",
7479    "algorithm_unsupported",
7480    "critical_header_unsupported",
7481    "unencoded_payload_unsupported",
7482    "compression_unsupported",
7483    "nested_token_unsupported",
7484    "key_metadata_unsupported",
7485    "invalid_signature_length",
7486];
7487
7488#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
7489fn jwt_parse_rejection(reason: JwtParseRejection) -> &'static str {
7490    match reason {
7491        JwtParseRejection::EncryptedTokenUnsupported => "encrypted_token_unsupported",
7492        JwtParseRejection::InvalidSegmentCount => "invalid_segment_count",
7493        JwtParseRejection::EmptySegment => "empty_segment",
7494        JwtParseRejection::InvalidBase64Url => "invalid_base64_url",
7495        JwtParseRejection::DecodedSegmentTooLarge => "decoded_segment_too_large",
7496        JwtParseRejection::MalformedJson => "malformed_json",
7497        JwtParseRejection::DuplicateJsonKey => "duplicate_json_key",
7498        JwtParseRejection::JsonLimitExceeded => "json_limit_exceeded",
7499        JwtParseRejection::MalformedProtectedHeader => "malformed_protected_header",
7500        JwtParseRejection::UnsecuredAlgorithmUnsupported => "unsecured_algorithm_unsupported",
7501        JwtParseRejection::AlgorithmUnsupported => "algorithm_unsupported",
7502        JwtParseRejection::CriticalHeaderUnsupported => "critical_header_unsupported",
7503        JwtParseRejection::UnencodedPayloadUnsupported => "unencoded_payload_unsupported",
7504        JwtParseRejection::CompressionUnsupported => "compression_unsupported",
7505        JwtParseRejection::NestedTokenUnsupported => "nested_token_unsupported",
7506        JwtParseRejection::KeyMetadataUnsupported => "key_metadata_unsupported",
7507        JwtParseRejection::InvalidSignatureLength => "invalid_signature_length",
7508    }
7509}
7510
7511#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
7512fn jwt_policy_violation(violation: JwtPolicyViolation) -> AssessmentJwtPolicyViolationDocument {
7513    let (kind, ordinal) = match violation {
7514        JwtPolicyViolation::MissingType => ("missing_type", None),
7515        JwtPolicyViolation::TypeMismatch => ("type_mismatch", None),
7516        JwtPolicyViolation::MissingIssuer => ("missing_issuer", None),
7517        JwtPolicyViolation::InvalidIssuerType => ("invalid_issuer_type", None),
7518        JwtPolicyViolation::IssuerMismatch => ("issuer_mismatch", None),
7519        JwtPolicyViolation::MissingAudience => ("missing_audience", None),
7520        JwtPolicyViolation::InvalidAudienceType => ("invalid_audience_type", None),
7521        JwtPolicyViolation::AudienceMismatch => ("audience_mismatch", None),
7522        JwtPolicyViolation::MissingRequiredClaim { ordinal } => {
7523            ("missing_required_claim", Some(u64::from(ordinal)))
7524        },
7525        JwtPolicyViolation::MissingExpiration => ("missing_expiration", None),
7526        JwtPolicyViolation::InvalidExpiration => ("invalid_expiration", None),
7527        JwtPolicyViolation::Expired => ("expired", None),
7528        JwtPolicyViolation::InvalidNotBefore => ("invalid_not_before", None),
7529        JwtPolicyViolation::NotYetValid => ("not_yet_valid", None),
7530    };
7531    AssessmentJwtPolicyViolationDocument { kind, ordinal }
7532}
7533
7534#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
7535fn valid_jwt_policy_reference(value: &str) -> bool {
7536    !value.is_empty()
7537        && value.len() <= 64
7538        && value.bytes().enumerate().all(|(index, byte)| {
7539            byte.is_ascii_lowercase()
7540                || byte.is_ascii_digit()
7541                || (index > 0 && matches!(byte, b'-' | b'_' | b'.'))
7542        })
7543}
7544
7545#[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
7546fn valid_jwt_public_key_sha256(value: &str) -> bool {
7547    value.strip_prefix("sha256:").is_some_and(|digest| {
7548        digest.len() == 64
7549            && digest
7550                .bytes()
7551                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
7552    })
7553}
7554
7555#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7556#[derive(Serialize)]
7557struct AssessmentControlReferenceMappingAuditDocument {
7558    schema: &'static str,
7559    policy: &'static str,
7560    selected: bool,
7561    catalogue: AssessmentControlReferenceCatalogueDocument,
7562    sources: Vec<AssessmentControlReferenceSourceDocument>,
7563    coverage: AssessmentControlReferenceCoverageDocument,
7564    external_activity: AssessmentControlReferenceExternalActivityDocument,
7565    claim_limits: AssessmentControlReferenceClaimLimitsDocument,
7566    relationships: Vec<AssessmentControlReferenceRelationshipDocument>,
7567}
7568
7569#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7570#[derive(Serialize)]
7571struct AssessmentControlReferenceCatalogueDocument {
7572    id: &'static str,
7573    revision: &'static str,
7574}
7575
7576#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7577#[derive(Serialize)]
7578struct AssessmentControlReferenceSourceDocument {
7579    source_id: &'static str,
7580    framework_id: &'static str,
7581    edition: &'static str,
7582    revision_or_amendment: Option<&'static str>,
7583    authority_kind: &'static str,
7584    source_url: &'static str,
7585    source_verified_at: &'static str,
7586    mapping_availability: &'static str,
7587    rights_basis: &'static str,
7588}
7589
7590#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7591#[derive(Serialize)]
7592struct AssessmentControlReferenceCoverageDocument {
7593    considered_item_count: u64,
7594    mapped_item_count: u64,
7595    unmapped_item_count: u64,
7596    relationship_count: u64,
7597    reference_link_count: u64,
7598    omitted_item_count: u64,
7599    omitted_reference_link_count: u64,
7600}
7601
7602#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7603#[derive(Serialize)]
7604struct AssessmentControlReferenceExternalActivityDocument {
7605    target_request_count: u64,
7606    provider_request_count: u64,
7607    source_retrieval: &'static str,
7608}
7609
7610#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7611#[derive(Serialize)]
7612struct AssessmentControlReferenceClaimLimitsDocument {
7613    control_assessment: &'static str,
7614    compliance: &'static str,
7615    certification: &'static str,
7616    legal_conclusion: &'static str,
7617    source_authentication: &'static str,
7618}
7619
7620#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7621#[derive(Serialize)]
7622struct AssessmentControlReferenceRelationshipDocument {
7623    item_fingerprint: String,
7624    capability_id: String,
7625    cwe: Option<String>,
7626    item_basis: &'static str,
7627    references: Vec<AssessmentControlReferenceLinkDocument>,
7628}
7629
7630#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7631#[derive(Serialize)]
7632struct AssessmentControlReferenceLinkDocument {
7633    rule_id: &'static str,
7634    source_id: &'static str,
7635    framework_id: &'static str,
7636    edition: &'static str,
7637    control_reference: &'static str,
7638    control_title: Option<&'static str>,
7639    mapping_basis: &'static str,
7640    applicability: &'static str,
7641    applicability_condition: &'static str,
7642    assurance: &'static str,
7643    original_mapping_rationale: &'static str,
7644}
7645
7646#[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
7647impl AssessmentControlReferenceMappingAuditDocument {
7648    fn from_audit(audit: &ControlReferenceMappingAudit) -> Result<Self, ReportError> {
7649        let mut sources = audit
7650            .sources()
7651            .iter()
7652            .map(|source| AssessmentControlReferenceSourceDocument {
7653                source_id: source.source_id(),
7654                framework_id: source.framework_id(),
7655                edition: source.edition(),
7656                revision_or_amendment: source.revision_or_amendment(),
7657                authority_kind: source.authority_kind().as_str(),
7658                source_url: source.source_url(),
7659                source_verified_at: source.source_verified_at(),
7660                mapping_availability: source.mapping_availability().as_str(),
7661                rights_basis: source.rights_basis().as_str(),
7662            })
7663            .collect::<Vec<_>>();
7664        sources.sort_by(|left, right| left.source_id.cmp(right.source_id));
7665
7666        let mut relationships = Vec::with_capacity(audit.relationships().len());
7667        for relationship in audit.relationships() {
7668            let mut references = Vec::with_capacity(relationship.references().len());
7669            for reference in relationship.references() {
7670                let source = sources
7671                    .iter()
7672                    .find(|source| source.source_id == reference.source_id())
7673                    .ok_or(ReportError::Serialization)?;
7674                if source.framework_id != reference.framework_id() {
7675                    return Err(ReportError::Serialization);
7676                }
7677                references.push(AssessmentControlReferenceLinkDocument {
7678                    rule_id: reference.rule_id(),
7679                    source_id: reference.source_id(),
7680                    framework_id: reference.framework_id(),
7681                    edition: source.edition,
7682                    control_reference: reference.control_reference(),
7683                    control_title: reference.control_title(),
7684                    mapping_basis: reference.mapping_basis().as_str(),
7685                    applicability: reference.applicability().as_str(),
7686                    applicability_condition: reference.applicability_condition(),
7687                    assurance: reference.assurance().as_str(),
7688                    original_mapping_rationale: reference.original_mapping_rationale(),
7689                });
7690            }
7691            references.sort_by(|left, right| {
7692                left.rule_id
7693                    .cmp(right.rule_id)
7694                    .then(left.source_id.cmp(right.source_id))
7695                    .then(left.edition.cmp(right.edition))
7696                    .then(left.control_reference.cmp(right.control_reference))
7697            });
7698            relationships.push(AssessmentControlReferenceRelationshipDocument {
7699                item_fingerprint: relationship.item_fingerprint().to_owned(),
7700                capability_id: relationship.capability_id().to_owned(),
7701                cwe: relationship.cwe().map(str::to_owned),
7702                item_basis: relationship.item_basis().as_str(),
7703                references,
7704            });
7705        }
7706        relationships.sort_by(|left, right| {
7707            left.item_fingerprint
7708                .cmp(&right.item_fingerprint)
7709                .then(left.capability_id.cmp(&right.capability_id))
7710        });
7711        let external = audit.external_activity();
7712        let claims = audit.claim_limits();
7713        Ok(Self {
7714            schema: audit.schema(),
7715            policy: audit.policy_id(),
7716            selected: audit.selected(),
7717            catalogue: AssessmentControlReferenceCatalogueDocument {
7718                id: audit.catalogue_id(),
7719                revision: audit.catalogue_revision(),
7720            },
7721            sources,
7722            coverage: AssessmentControlReferenceCoverageDocument {
7723                considered_item_count: u64::try_from(audit.considered_item_count())
7724                    .map_err(|_| ReportError::Serialization)?,
7725                mapped_item_count: u64::try_from(audit.mapped_item_count())
7726                    .map_err(|_| ReportError::Serialization)?,
7727                unmapped_item_count: u64::try_from(audit.unmapped_item_count())
7728                    .map_err(|_| ReportError::Serialization)?,
7729                relationship_count: u64::try_from(audit.relationship_count())
7730                    .map_err(|_| ReportError::Serialization)?,
7731                reference_link_count: u64::try_from(audit.reference_link_count())
7732                    .map_err(|_| ReportError::Serialization)?,
7733                omitted_item_count: u64::try_from(audit.omitted_item_count())
7734                    .map_err(|_| ReportError::Serialization)?,
7735                omitted_reference_link_count: u64::try_from(audit.omitted_reference_link_count())
7736                    .map_err(|_| ReportError::Serialization)?,
7737            },
7738            external_activity: AssessmentControlReferenceExternalActivityDocument {
7739                target_request_count: external.target_request_count(),
7740                provider_request_count: external.provider_request_count(),
7741                source_retrieval: external.source_retrieval().as_str(),
7742            },
7743            claim_limits: AssessmentControlReferenceClaimLimitsDocument {
7744                control_assessment: claims.control_assessment().as_str(),
7745                compliance: claims.compliance().as_str(),
7746                certification: claims.certification().as_str(),
7747                legal_conclusion: claims.legal_conclusion().as_str(),
7748                source_authentication: claims.source_authentication().as_str(),
7749            },
7750            relationships,
7751        })
7752    }
7753
7754    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
7755        let considered = u64::try_from(items.len()).map_err(|_| ReportError::Serialization)?;
7756        let partition = self
7757            .coverage
7758            .mapped_item_count
7759            .checked_add(self.coverage.unmapped_item_count)
7760            .and_then(|count| count.checked_add(self.coverage.omitted_item_count));
7761        let reference_count = self.relationships.iter().try_fold(0_u64, |sum, row| {
7762            u64::try_from(row.references.len())
7763                .ok()
7764                .and_then(|count| sum.checked_add(count))
7765        });
7766        let mut source_ids = std::collections::BTreeSet::new();
7767        let sources_valid = self.sources.len() == 5
7768            && self.sources.iter().all(|source| {
7769                source_ids.insert(source.source_id)
7770                    && !source.source_id.is_empty()
7771                    && !source.framework_id.is_empty()
7772                    && !source.edition.is_empty()
7773                    && is_strict_https_reference(source.source_url)
7774                    && source.source_verified_at == "2026-09-20"
7775                    && matches!(
7776                        source.mapping_availability,
7777                        "reviewed_identifiers_and_titles"
7778                            | "bibliographic_only_rights_deferred"
7779                            | "relevant_technical_context_only"
7780                    )
7781            });
7782        let mut mapped_fingerprints = std::collections::BTreeSet::new();
7783        let relationships_valid = self.relationships.iter().all(|relationship| {
7784            let source_item = items.iter().find(|item| {
7785                item.fingerprint == relationship.item_fingerprint
7786                    && item.capability_id == relationship.capability_id
7787                    && item.cwe == relationship.cwe.as_deref()
7788                    && item.claim_basis == relationship.item_basis
7789            });
7790            let mut references = std::collections::BTreeSet::new();
7791            source_item.is_some()
7792                && mapped_fingerprints.insert(relationship.item_fingerprint.as_str())
7793                && !relationship.references.is_empty()
7794                && relationship.references.len() <= 8
7795                && relationship.references.iter().all(|reference| {
7796                    self.sources.iter().any(|source| {
7797                        source.source_id == reference.source_id
7798                            && source.framework_id == reference.framework_id
7799                            && source.edition == reference.edition
7800                            && source.mapping_availability != "bibliographic_only_rights_deferred"
7801                    }) && references.insert((
7802                        reference.rule_id,
7803                        reference.source_id,
7804                        reference.edition,
7805                        reference.control_reference,
7806                    )) && !reference.rule_id.is_empty()
7807                        && !reference.control_reference.is_empty()
7808                        && !reference.applicability_condition.is_empty()
7809                        && !reference.original_mapping_rationale.is_empty()
7810                        && matches!(
7811                            reference.mapping_basis,
7812                            "exact_capability_cwe_and_assessment_basis"
7813                                | "exact_capability_and_assessment_basis"
7814                        )
7815                        && matches!(
7816                            reference.applicability,
7817                            "conditional_technical_context" | "applicability_unestablished"
7818                        )
7819                        && matches!(
7820                            reference.assurance,
7821                            "reviewed_primary_source_identifier" | "relevant_technical_context"
7822                        )
7823                })
7824        });
7825        if self.schema != CONTROL_REFERENCE_MAPPING_AUDIT_SCHEMA
7826            || self.policy != CONTROL_REFERENCE_MAPPING_POLICY_ID
7827            || !self.selected
7828            || self.catalogue.id != CONTROL_REFERENCE_MAPPING_CATALOGUE_ID
7829            || self.catalogue.revision != CONTROL_REFERENCE_MAPPING_CATALOGUE_REVISION
7830            || self.coverage.considered_item_count != considered
7831            || self.coverage.relationship_count
7832                != u64::try_from(self.relationships.len())
7833                    .map_err(|_| ReportError::Serialization)?
7834            || self.coverage.mapped_item_count != self.coverage.relationship_count
7835            || partition != Some(self.coverage.considered_item_count)
7836            || self.coverage.omitted_item_count != 0
7837            || self.coverage.omitted_reference_link_count != 0
7838            || reference_count != Some(self.coverage.reference_link_count)
7839            || self.coverage.reference_link_count > 16_384
7840            || !sources_valid
7841            || !relationships_valid
7842            || self.external_activity.target_request_count != 0
7843            || self.external_activity.provider_request_count != 0
7844            || self.external_activity.source_retrieval != "not_performed"
7845            || self.claim_limits.control_assessment != "not_performed"
7846            || self.claim_limits.compliance != "not_established"
7847            || self.claim_limits.certification != "not_established"
7848            || self.claim_limits.legal_conclusion != "not_established"
7849            || self.claim_limits.source_authentication != "not_established"
7850        {
7851            return Err(ReportError::Serialization);
7852        }
7853        Ok(())
7854    }
7855
7856    fn metadata(&self) -> [(&'static str, String); 13] {
7857        [
7858            ("Audit schema", self.schema.to_owned()),
7859            ("Policy", self.policy.to_owned()),
7860            ("Catalogue", self.catalogue.id.to_owned()),
7861            ("Catalogue revision", self.catalogue.revision.to_owned()),
7862            ("Reviewed sources", self.sources.len().to_string()),
7863            (
7864                "Items considered",
7865                self.coverage.considered_item_count.to_string(),
7866            ),
7867            ("Items mapped", self.coverage.mapped_item_count.to_string()),
7868            (
7869                "Items without an exact mapping",
7870                self.coverage.unmapped_item_count.to_string(),
7871            ),
7872            (
7873                "Reference links",
7874                self.coverage.reference_link_count.to_string(),
7875            ),
7876            (
7877                "Additional target requests",
7878                self.external_activity.target_request_count.to_string(),
7879            ),
7880            (
7881                "Provider requests",
7882                self.external_activity.provider_request_count.to_string(),
7883            ),
7884            (
7885                "Compliance determination",
7886                self.claim_limits.compliance.to_owned(),
7887            ),
7888            (
7889                "Legal conclusion",
7890                self.claim_limits.legal_conclusion.to_owned(),
7891            ),
7892        ]
7893    }
7894
7895    fn wire_json(&self) -> Result<String, ReportError> {
7896        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
7897    }
7898}
7899
7900#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
7901#[derive(Serialize)]
7902struct AssessmentReconCertSpotterAuditDocument {
7903    schema: &'static str,
7904    policy: &'static str,
7905    selected: bool,
7906    provider: AssessmentReconCertSpotterProviderDocument,
7907    methodology: AssessmentReconCertSpotterMethodologyDocument,
7908    coverage: AssessmentReconCertSpotterCoverageDocument,
7909    hypotheses: Vec<AssessmentReconCertSpotterHypothesisDocument>,
7910    claim_limits: [&'static str; 5],
7911}
7912
7913#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
7914#[derive(Serialize)]
7915struct AssessmentReconCertSpotterProviderDocument {
7916    name: &'static str,
7917    policy_schema: &'static str,
7918    policy_revision: String,
7919    query_domain: String,
7920    query_reference: String,
7921    execution_mode: &'static str,
7922    origin: String,
7923}
7924
7925#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
7926#[derive(Serialize)]
7927struct AssessmentReconCertSpotterMethodologyDocument {
7928    request_path: &'static str,
7929    pagination: &'static str,
7930    retries: &'static str,
7931    polling: &'static str,
7932    credentials: &'static str,
7933    maximum_pages: u64,
7934    maximum_response_page_bytes: u64,
7935    maximum_response_bytes: u64,
7936    maximum_elapsed_milliseconds: u64,
7937    maximum_in_flight_requests: u64,
7938    maximum_retained_names: u64,
7939}
7940
7941#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
7942#[derive(Serialize)]
7943struct AssessmentReconCertSpotterCoverageDocument {
7944    terminal: &'static str,
7945    completeness: &'static str,
7946    first_failure: Option<&'static str>,
7947    initial_after_reference: Option<String>,
7948    initial_after_byte_length: Option<u64>,
7949    last_cursor_reference: Option<String>,
7950    last_cursor_byte_length: Option<u64>,
7951    request_attempt_count: u64,
7952    request_admitted_count: u64,
7953    response_completed_count: u64,
7954    observed_response_bytes: u64,
7955    accepted_page_count: u64,
7956    accepted_response_bytes: u64,
7957    issuance_count: u64,
7958    retained_name_count: u64,
7959    foreign_name_count: u64,
7960    duplicate_name_count: u64,
7961    omitted_name_count: u64,
7962}
7963
7964#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
7965#[derive(Serialize)]
7966struct AssessmentReconCertSpotterHypothesisDocument {
7967    name: String,
7968    source: AssessmentReconCertSpotterHypothesisSourceDocument,
7969    claim_limits: [&'static str; 5],
7970}
7971
7972#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
7973#[derive(Serialize)]
7974struct AssessmentReconCertSpotterHypothesisSourceDocument {
7975    provider: &'static str,
7976    origin: String,
7977    first_issuance_reference: String,
7978    first_issuance_byte_length: u64,
7979}
7980
7981#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
7982impl AssessmentReconCertSpotterHypothesisDocument {
7983    fn presentation(&self) -> String {
7984        format!(
7985            "name={};source_provider={};source_origin={};first_issuance_reference={};first_issuance_byte_length={};claim_limits={}",
7986            self.name,
7987            self.source.provider,
7988            self.source.origin,
7989            self.source.first_issuance_reference,
7990            self.source.first_issuance_byte_length,
7991            self.claim_limits.join(","),
7992        )
7993    }
7994}
7995
7996#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
7997impl AssessmentReconCertSpotterAuditDocument {
7998    fn from_audit(audit: &WebAssessmentReconCtProviderAudit) -> Result<Self, ReportError> {
7999        if !audit.is_consistent() {
8000            return Err(ReportError::Serialization);
8001        }
8002        let result = audit.result();
8003        let collection = result.audit();
8004        if !result
8005            .claim_limits()
8006            .iter()
8007            .map(|limit| limit.as_str())
8008            .eq(RECON_CERTSPOTTER_CLAIM_LIMITS)
8009            || result.hypotheses().iter().any(|hypothesis| {
8010                !hypothesis
8011                    .claim_limits()
8012                    .iter()
8013                    .map(|limit| limit.as_str())
8014                    .eq(RECON_CERTSPOTTER_CLAIM_LIMITS)
8015            })
8016        {
8017            return Err(ReportError::Serialization);
8018        }
8019        let hypotheses = result
8020            .hypotheses()
8021            .iter()
8022            .map(|hypothesis| {
8023                let (first_issuance_reference, first_issuance_byte_length) =
8024                    recon_certspotter_issuance_reference(hypothesis.first_issuance_id())?;
8025                Ok(AssessmentReconCertSpotterHypothesisDocument {
8026                    name: hypothesis.name().to_owned(),
8027                    source: AssessmentReconCertSpotterHypothesisSourceDocument {
8028                        provider: RECON_CERTSPOTTER_PROVIDER,
8029                        origin: collection.provider_origin().to_owned(),
8030                        first_issuance_reference,
8031                        first_issuance_byte_length,
8032                    },
8033                    claim_limits: RECON_CERTSPOTTER_CLAIM_LIMITS,
8034                })
8035            })
8036            .collect::<Result<Vec<_>, ReportError>>()?;
8037        let (initial_after_reference, initial_after_byte_length) =
8038            recon_certspotter_optional_issuance_reference(collection.initial_after())?;
8039        let (last_cursor_reference, last_cursor_byte_length) =
8040            recon_certspotter_optional_issuance_reference(collection.last_cursor())?;
8041        let document = Self {
8042            schema: RECON_CERTSPOTTER_AUDIT_SCHEMA,
8043            policy: RECON_CERTSPOTTER_POLICY_ID,
8044            selected: true,
8045            provider: AssessmentReconCertSpotterProviderDocument {
8046                name: RECON_CERTSPOTTER_PROVIDER,
8047                policy_schema: collection.schema(),
8048                policy_revision: collection.policy_revision().to_owned(),
8049                query_domain: collection.query_domain().to_owned(),
8050                query_reference: collection.query_reference().to_owned(),
8051                execution_mode: collection.execution_mode().as_str(),
8052                origin: collection.provider_origin().to_owned(),
8053            },
8054            methodology: AssessmentReconCertSpotterMethodologyDocument {
8055                request_path: "/v1/issuances",
8056                pagination: "after_cursor",
8057                retries: "none",
8058                polling: "none",
8059                credentials: "none",
8060                maximum_pages: u64::try_from(collection.maximum_pages())
8061                    .map_err(|_| ReportError::Serialization)?,
8062                maximum_response_page_bytes: u64::try_from(MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES)
8063                    .map_err(|_| ReportError::Serialization)?,
8064                maximum_response_bytes: u64::try_from(collection.maximum_response_bytes())
8065                    .map_err(|_| ReportError::Serialization)?,
8066                maximum_elapsed_milliseconds: u64::try_from(
8067                    collection.maximum_elapsed().as_millis(),
8068                )
8069                .map_err(|_| ReportError::Serialization)?,
8070                maximum_in_flight_requests: u64::try_from(collection.maximum_in_flight_requests())
8071                    .map_err(|_| ReportError::Serialization)?,
8072                maximum_retained_names: u64::try_from(MAX_CERT_SPOTTER_RETAINED_NAMES)
8073                    .map_err(|_| ReportError::Serialization)?,
8074            },
8075            coverage: AssessmentReconCertSpotterCoverageDocument {
8076                terminal: collection.terminal().as_str(),
8077                completeness: collection.completeness().as_str(),
8078                first_failure: audit.first_failure().map(|failure| failure.as_str()),
8079                initial_after_reference,
8080                initial_after_byte_length,
8081                last_cursor_reference,
8082                last_cursor_byte_length,
8083                request_attempt_count: u64::try_from(audit.request_attempt_count())
8084                    .map_err(|_| ReportError::Serialization)?,
8085                request_admitted_count: u64::try_from(audit.request_admitted_count())
8086                    .map_err(|_| ReportError::Serialization)?,
8087                response_completed_count: u64::try_from(audit.response_completed_count())
8088                    .map_err(|_| ReportError::Serialization)?,
8089                observed_response_bytes: audit.observed_response_bytes(),
8090                accepted_page_count: u64::try_from(collection.page_count())
8091                    .map_err(|_| ReportError::Serialization)?,
8092                accepted_response_bytes: u64::try_from(collection.response_bytes())
8093                    .map_err(|_| ReportError::Serialization)?,
8094                issuance_count: u64::try_from(collection.issuance_count())
8095                    .map_err(|_| ReportError::Serialization)?,
8096                retained_name_count: u64::try_from(collection.retained_name_count())
8097                    .map_err(|_| ReportError::Serialization)?,
8098                foreign_name_count: u64::try_from(collection.foreign_name_count())
8099                    .map_err(|_| ReportError::Serialization)?,
8100                duplicate_name_count: u64::try_from(collection.duplicate_name_count())
8101                    .map_err(|_| ReportError::Serialization)?,
8102                omitted_name_count: u64::try_from(collection.omitted_name_count())
8103                    .map_err(|_| ReportError::Serialization)?,
8104            },
8105            hypotheses,
8106            claim_limits: RECON_CERTSPOTTER_CLAIM_LIMITS,
8107        };
8108        document.validate()?;
8109        Ok(document)
8110    }
8111
8112    fn validate(&self) -> Result<(), ReportError> {
8113        const MAX_ISSUANCES: u64 = 8_192;
8114        const MAX_ISSUANCES_PER_PAGE: u64 = 4_096;
8115        const MAX_NAME_EVENTS: u64 = 65_536;
8116
8117        let coverage = &self.coverage;
8118        let mut names = std::collections::BTreeSet::new();
8119        let mut identities = std::collections::BTreeSet::new();
8120        let hypotheses_are_valid = self.hypotheses.iter().all(|hypothesis| {
8121            names.insert(hypothesis.name.as_str())
8122                && identities.insert((
8123                    hypothesis.name.as_str(),
8124                    hypothesis.source.provider,
8125                    hypothesis.source.origin.as_str(),
8126                    hypothesis.source.first_issuance_reference.as_str(),
8127                    hypothesis.source.first_issuance_byte_length,
8128                ))
8129                && valid_recon_certspotter_name(&hypothesis.name, &self.provider.query_domain)
8130                && hypothesis.source.provider == self.provider.name
8131                && hypothesis.source.origin == self.provider.origin
8132                && valid_recon_certspotter_issuance_reference(
8133                    &hypothesis.source.first_issuance_reference,
8134                )
8135                && valid_recon_certspotter_issuance_byte_length(
8136                    hypothesis.source.first_issuance_byte_length,
8137                )
8138                && hypothesis.claim_limits == RECON_CERTSPOTTER_CLAIM_LIMITS
8139        });
8140        let retained_count =
8141            u64::try_from(self.hypotheses.len()).map_err(|_| ReportError::Serialization)?;
8142        let total_name_events = coverage
8143            .retained_name_count
8144            .checked_add(coverage.foreign_name_count)
8145            .and_then(|count| count.checked_add(coverage.duplicate_name_count))
8146            .and_then(|count| count.checked_add(coverage.omitted_name_count));
8147        let request_counts_are_valid = coverage.request_admitted_count
8148            <= coverage.request_attempt_count
8149            && coverage.request_attempt_count <= self.methodology.maximum_pages
8150            && coverage.response_completed_count <= coverage.request_admitted_count
8151            && coverage.accepted_page_count <= coverage.response_completed_count;
8152        let completed_collection_accounting_is_exact = coverage.request_attempt_count
8153            == coverage.accepted_page_count
8154            && coverage.request_admitted_count == coverage.accepted_page_count
8155            && coverage.response_completed_count == coverage.accepted_page_count
8156            && coverage.observed_response_bytes == coverage.accepted_response_bytes;
8157        let terminal_is_valid = match coverage.terminal {
8158            "provider_exhausted" => {
8159                coverage.completeness == "provider_exhausted_as_observed"
8160                    && coverage.first_failure.is_none()
8161                    && coverage.accepted_page_count > 0
8162                    && completed_collection_accounting_is_exact
8163            },
8164            "hypothesis_limit_reached" => {
8165                coverage.completeness == "bounded_partial"
8166                    && coverage.first_failure.is_none()
8167                    && coverage.retained_name_count == self.methodology.maximum_retained_names
8168                    && completed_collection_accounting_is_exact
8169            },
8170            "page_limit_reached" => {
8171                coverage.completeness == "bounded_partial"
8172                    && coverage.first_failure.is_none()
8173                    && coverage.accepted_page_count == self.methodology.maximum_pages
8174                    && completed_collection_accounting_is_exact
8175            },
8176            "deadline_reached" => {
8177                coverage.completeness == "unknown"
8178                    && coverage.first_failure == Some("deadline_reached")
8179                    && coverage.accepted_page_count < self.methodology.maximum_pages
8180            },
8181            "parent_authority_unavailable" => {
8182                coverage.completeness == "unknown"
8183                    && coverage.first_failure == Some("parent_authority_unavailable")
8184                    && coverage.accepted_page_count < self.methodology.maximum_pages
8185            },
8186            "cancelled" => {
8187                coverage.completeness == "unknown"
8188                    && coverage.first_failure == Some("cancelled")
8189                    && coverage.accepted_page_count < self.methodology.maximum_pages
8190            },
8191            "transport_failed" => {
8192                coverage.completeness == "unknown"
8193                    && coverage
8194                        .first_failure
8195                        .is_some_and(valid_recon_certspotter_transport_failure)
8196                    && coverage.accepted_page_count < self.methodology.maximum_pages
8197            },
8198            _ => false,
8199        };
8200        let issuance_capacity = coverage
8201            .accepted_page_count
8202            .checked_mul(MAX_ISSUANCES_PER_PAGE);
8203        let zero_issuance_accounting_is_empty = coverage.issuance_count != 0
8204            || (coverage.retained_name_count == 0
8205                && coverage.foreign_name_count == 0
8206                && coverage.duplicate_name_count == 0
8207                && coverage.omitted_name_count == 0);
8208        if self.schema != RECON_CERTSPOTTER_AUDIT_SCHEMA
8209            || self.policy != RECON_CERTSPOTTER_POLICY_ID
8210            || !self.selected
8211            || self.provider.name != RECON_CERTSPOTTER_PROVIDER
8212            || self.provider.policy_schema != "security.recon-certspotter-policy/v1"
8213            || !valid_recon_certspotter_opaque(&self.provider.policy_revision)
8214            || !valid_recon_certspotter_query_domain(&self.provider.query_domain)
8215            || !valid_recon_certspotter_opaque(&self.provider.query_reference)
8216            || !valid_recon_certspotter_origin(self.provider.execution_mode, &self.provider.origin)
8217            || self.methodology.request_path != "/v1/issuances"
8218            || self.methodology.pagination != "after_cursor"
8219            || self.methodology.retries != "none"
8220            || self.methodology.polling != "none"
8221            || self.methodology.credentials != "none"
8222            || self.methodology.maximum_pages != MAX_CERT_SPOTTER_RESPONSE_PAGES as u64
8223            || self.methodology.maximum_response_page_bytes
8224                != MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES as u64
8225            || self.methodology.maximum_response_bytes
8226                != MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES as u64
8227            || self.methodology.maximum_elapsed_milliseconds
8228                != MAX_CERT_SPOTTER_ELAPSED.as_millis() as u64
8229            || self.methodology.maximum_in_flight_requests
8230                != MAX_CERT_SPOTTER_IN_FLIGHT_REQUESTS as u64
8231            || self.methodology.maximum_retained_names != MAX_CERT_SPOTTER_RETAINED_NAMES as u64
8232            || self.claim_limits != RECON_CERTSPOTTER_CLAIM_LIMITS
8233            || !hypotheses_are_valid
8234            || retained_count != coverage.retained_name_count
8235            || coverage.retained_name_count > self.methodology.maximum_retained_names
8236            || coverage.accepted_page_count > self.methodology.maximum_pages
8237            || coverage.accepted_response_bytes > self.methodology.maximum_response_bytes
8238            || coverage.accepted_response_bytes > coverage.observed_response_bytes
8239            || coverage.issuance_count > MAX_ISSUANCES
8240            || issuance_capacity.is_none_or(|capacity| coverage.issuance_count > capacity)
8241            || !zero_issuance_accounting_is_empty
8242            || total_name_events.is_none_or(|count| count > MAX_NAME_EVENTS)
8243            || !request_counts_are_valid
8244            || !terminal_is_valid
8245            || coverage.initial_after_reference.is_some()
8246            || coverage.initial_after_byte_length.is_some()
8247            || !valid_recon_certspotter_optional_issuance_reference(
8248                coverage.last_cursor_reference.as_deref(),
8249                coverage.last_cursor_byte_length,
8250            )
8251            || (coverage.issuance_count == 0) != coverage.last_cursor_reference.is_none()
8252            || (coverage.accepted_page_count == 0) != (coverage.accepted_response_bytes == 0)
8253        {
8254            return Err(ReportError::Serialization);
8255        }
8256        Ok(())
8257    }
8258
8259    fn metadata(&self) -> [(&'static str, String); 19] {
8260        [
8261            ("Audit schema", self.schema.to_owned()),
8262            ("Policy", self.policy.to_owned()),
8263            ("Provider", self.provider.name.to_owned()),
8264            ("Provider origin", self.provider.origin.clone()),
8265            ("Execution mode", self.provider.execution_mode.to_owned()),
8266            ("Policy schema", self.provider.policy_schema.to_owned()),
8267            ("Policy revision", self.provider.policy_revision.clone()),
8268            ("Query domain", self.provider.query_domain.clone()),
8269            ("Query reference", self.provider.query_reference.clone()),
8270            ("Terminal", self.coverage.terminal.to_owned()),
8271            ("Completeness", self.coverage.completeness.to_owned()),
8272            (
8273                "First failure",
8274                self.coverage.first_failure.unwrap_or("none").to_owned(),
8275            ),
8276            (
8277                "Request attempts",
8278                self.coverage.request_attempt_count.to_string(),
8279            ),
8280            (
8281                "Requests admitted",
8282                self.coverage.request_admitted_count.to_string(),
8283            ),
8284            (
8285                "Responses completed",
8286                self.coverage.response_completed_count.to_string(),
8287            ),
8288            (
8289                "Observed response bytes",
8290                self.coverage.observed_response_bytes.to_string(),
8291            ),
8292            (
8293                "Accepted provider pages",
8294                self.coverage.accepted_page_count.to_string(),
8295            ),
8296            (
8297                "Retained hypotheses",
8298                self.coverage.retained_name_count.to_string(),
8299            ),
8300            ("Claim limits", self.claim_limits.join(",")),
8301        ]
8302    }
8303
8304    fn wire_json(&self) -> Result<String, ReportError> {
8305        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
8306    }
8307}
8308
8309#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8310fn valid_recon_certspotter_opaque(value: &str) -> bool {
8311    !value.is_empty()
8312        && value.len() <= 128
8313        && value.as_bytes()[0].is_ascii_alphanumeric()
8314        && value
8315            .bytes()
8316            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-'))
8317}
8318
8319#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8320fn valid_recon_certspotter_query_domain(value: &str) -> bool {
8321    value.contains('.')
8322        && !value.starts_with("*.")
8323        && value.parse::<std::net::IpAddr>().is_err()
8324        && valid_recon_certspotter_dns_name(value)
8325}
8326
8327#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8328fn valid_recon_certspotter_name(value: &str, query_domain: &str) -> bool {
8329    let bare = value.strip_prefix("*.").unwrap_or(value);
8330    valid_recon_certspotter_dns_name(bare)
8331        && bare.parse::<std::net::IpAddr>().is_err()
8332        && (bare == query_domain
8333            || bare
8334                .strip_suffix(query_domain)
8335                .is_some_and(|prefix| prefix.ends_with('.') && prefix.len() > 1))
8336}
8337
8338#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8339fn valid_recon_certspotter_dns_name(value: &str) -> bool {
8340    !value.is_empty()
8341        && value.len() <= 253
8342        && value.is_ascii()
8343        && value == value.to_ascii_lowercase()
8344        && !value.starts_with('.')
8345        && !value.ends_with('.')
8346        && value.split('.').all(|label| {
8347            !label.is_empty()
8348                && label.len() <= 63
8349                && label.as_bytes()[0].is_ascii_alphanumeric()
8350                && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
8351                && label
8352                    .bytes()
8353                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
8354        })
8355}
8356
8357#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8358fn valid_recon_certspotter_issuance_id(value: &str) -> bool {
8359    !value.is_empty()
8360        && value.len() <= MAX_RECON_CERTSPOTTER_ISSUANCE_ID_BYTES
8361        && !value.chars().any(char::is_control)
8362}
8363
8364#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8365fn recon_certspotter_issuance_reference(value: &str) -> Result<(String, u64), ReportError> {
8366    if !valid_recon_certspotter_issuance_id(value) {
8367        return Err(ReportError::Serialization);
8368    }
8369    let byte_length = u64::try_from(value.len()).map_err(|_| ReportError::Serialization)?;
8370    Ok((
8371        format!(
8372            "{RECON_CERTSPOTTER_ISSUANCE_REFERENCE_PREFIX}{:x}",
8373            Sha256::digest(value.as_bytes())
8374        ),
8375        byte_length,
8376    ))
8377}
8378
8379#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8380fn recon_certspotter_optional_issuance_reference(
8381    value: Option<&str>,
8382) -> Result<(Option<String>, Option<u64>), ReportError> {
8383    value
8384        .map(recon_certspotter_issuance_reference)
8385        .transpose()
8386        .map(|parts| match parts {
8387            Some((reference, byte_length)) => (Some(reference), Some(byte_length)),
8388            None => (None, None),
8389        })
8390}
8391
8392#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8393fn valid_recon_certspotter_issuance_reference(value: &str) -> bool {
8394    value
8395        .strip_prefix(RECON_CERTSPOTTER_ISSUANCE_REFERENCE_PREFIX)
8396        .is_some_and(|digest| {
8397            digest.len() == 64
8398                && digest
8399                    .bytes()
8400                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
8401        })
8402}
8403
8404#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8405fn valid_recon_certspotter_issuance_byte_length(value: u64) -> bool {
8406    (1..=MAX_RECON_CERTSPOTTER_ISSUANCE_ID_BYTES as u64).contains(&value)
8407}
8408
8409#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8410fn valid_recon_certspotter_optional_issuance_reference(
8411    reference: Option<&str>,
8412    byte_length: Option<u64>,
8413) -> bool {
8414    match (reference, byte_length) {
8415        (Some(reference), Some(byte_length)) => {
8416            valid_recon_certspotter_issuance_reference(reference)
8417                && valid_recon_certspotter_issuance_byte_length(byte_length)
8418        },
8419        (None, None) => true,
8420        _ => false,
8421    }
8422}
8423
8424#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8425fn valid_recon_certspotter_origin(mode: &str, origin: &str) -> bool {
8426    if mode == "production" {
8427        return origin == "https://api.certspotter.com";
8428    }
8429    if mode != "owned_loopback_fixture" || origin.is_empty() || origin.len() > 256 {
8430        return false;
8431    }
8432    let Ok(parsed) = url::Url::parse(origin) else {
8433        return false;
8434    };
8435    parsed.scheme() == "http"
8436        && parsed.username().is_empty()
8437        && parsed.password().is_none()
8438        && parsed.port().is_some_and(|port| port > 0)
8439        && parsed.path() == "/"
8440        && parsed.query().is_none()
8441        && parsed.fragment().is_none()
8442        && parsed.host().is_some_and(|host| match host {
8443            url::Host::Ipv4(address) => address.is_loopback(),
8444            url::Host::Ipv6(address) => address.is_loopback(),
8445            url::Host::Domain(_) => false,
8446        })
8447        && parsed.origin().ascii_serialization() == origin
8448}
8449
8450#[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
8451fn valid_recon_certspotter_transport_failure(value: &str) -> bool {
8452    matches!(
8453        value,
8454        "client_initialization"
8455            | "request_construction"
8456            | "transport_failed"
8457            | "redirect_refused"
8458            | "access_rejected"
8459            | "throttled"
8460            | "provider_not_found"
8461            | "server_failure"
8462            | "unexpected_status"
8463            | "unsupported_media"
8464            | "unsupported_content_coding"
8465            | "response_too_large"
8466            | "invalid_response"
8467    )
8468}
8469
8470#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8471#[derive(Serialize)]
8472struct AssessmentReconSnapshotImportAuditDocument {
8473    schema: &'static str,
8474    policy: &'static str,
8475    selected: bool,
8476    input: AssessmentReconSnapshotInputDocument,
8477    snapshot: AssessmentReconSnapshotIdentityDocument,
8478    sources: Vec<AssessmentReconSnapshotSourceDocument>,
8479    records: Vec<AssessmentReconSnapshotRecordDocument>,
8480    accounting: AssessmentReconSnapshotAccountingDocument,
8481    external_activity: AssessmentReconSnapshotExternalActivityDocument,
8482    claim_limits: AssessmentReconSnapshotClaimLimitsDocument,
8483}
8484
8485#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8486#[derive(Serialize)]
8487struct AssessmentReconSnapshotInputDocument {
8488    source_schema: &'static str,
8489    byte_length: u64,
8490    sha256: String,
8491}
8492
8493#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8494#[derive(Serialize)]
8495struct AssessmentReconSnapshotIdentityDocument {
8496    id: String,
8497    revision: String,
8498}
8499
8500#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8501#[derive(Serialize)]
8502struct AssessmentReconSnapshotSourceDocument {
8503    source_id: String,
8504    namespace: String,
8505    revision: String,
8506    provider_time: Option<String>,
8507    observed_time: String,
8508    collection_method: String,
8509    completeness: &'static str,
8510    origin: String,
8511    rights: AssessmentReconSnapshotRightsDocument,
8512}
8513
8514#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8515#[derive(Serialize)]
8516struct AssessmentReconSnapshotRightsDocument {
8517    status: &'static str,
8518    attribution: Option<String>,
8519    notice: Option<String>,
8520}
8521
8522#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8523#[derive(Serialize)]
8524#[serde(tag = "kind", rename_all = "snake_case")]
8525enum AssessmentReconSnapshotRecordDocument {
8526    CtName {
8527        record_id: String,
8528        source_ids: Vec<String>,
8529        name: String,
8530    },
8531    DnsHistory {
8532        record_id: String,
8533        source_ids: Vec<String>,
8534        name: String,
8535        address: String,
8536    },
8537    ServiceBannerProduct {
8538        record_id: String,
8539        source_ids: Vec<String>,
8540        host: String,
8541        port: u16,
8542        transport: &'static str,
8543        product: String,
8544        version: Option<String>,
8545    },
8546    ReputationLabel {
8547        record_id: String,
8548        source_ids: Vec<String>,
8549        subject: String,
8550        label: String,
8551    },
8552}
8553
8554#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8555impl AssessmentReconSnapshotRecordDocument {
8556    fn record_id(&self) -> &str {
8557        match self {
8558            Self::CtName { record_id, .. }
8559            | Self::DnsHistory { record_id, .. }
8560            | Self::ServiceBannerProduct { record_id, .. }
8561            | Self::ReputationLabel { record_id, .. } => record_id,
8562        }
8563    }
8564
8565    fn source_ids(&self) -> &[String] {
8566        match self {
8567            Self::CtName { source_ids, .. }
8568            | Self::DnsHistory { source_ids, .. }
8569            | Self::ServiceBannerProduct { source_ids, .. }
8570            | Self::ReputationLabel { source_ids, .. } => source_ids,
8571        }
8572    }
8573
8574    fn kind(&self) -> &'static str {
8575        match self {
8576            Self::CtName { .. } => "ct_name",
8577            Self::DnsHistory { .. } => "dns_history",
8578            Self::ServiceBannerProduct { .. } => "service_banner_product",
8579            Self::ReputationLabel { .. } => "reputation_label",
8580        }
8581    }
8582
8583    fn presentation(&self) -> String {
8584        match self {
8585            Self::CtName {
8586                record_id,
8587                source_ids,
8588                name,
8589            } => format!(
8590                "record_id={record_id};kind=ct_name;source_ids={};name={name}",
8591                source_ids.join(",")
8592            ),
8593            Self::DnsHistory {
8594                record_id,
8595                source_ids,
8596                name,
8597                address,
8598            } => format!(
8599                "record_id={record_id};kind=dns_history;source_ids={};name={name};address={address}",
8600                source_ids.join(",")
8601            ),
8602            Self::ServiceBannerProduct {
8603                record_id,
8604                source_ids,
8605                host,
8606                port,
8607                transport,
8608                product,
8609                version,
8610            } => format!(
8611                "record_id={record_id};kind=service_banner_product;source_ids={};host={host};port={port};transport={transport};product={product};version={}",
8612                source_ids.join(","),
8613                version.as_deref().unwrap_or("not_declared")
8614            ),
8615            Self::ReputationLabel {
8616                record_id,
8617                source_ids,
8618                subject,
8619                label,
8620            } => format!(
8621                "record_id={record_id};kind=reputation_label;source_ids={};subject={subject};label={label}",
8622                source_ids.join(",")
8623            ),
8624        }
8625    }
8626}
8627
8628#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8629#[derive(Serialize)]
8630struct AssessmentReconSnapshotAccountingDocument {
8631    source_count: u64,
8632    record_count: u64,
8633    source_association_count: u64,
8634    prepared_index_bytes: u64,
8635}
8636
8637#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8638#[derive(Serialize)]
8639struct AssessmentReconSnapshotExternalActivityDocument {
8640    target_request_count: u64,
8641    provider_request_count: u64,
8642    archive_processing: &'static str,
8643    decompression: &'static str,
8644}
8645
8646#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8647#[derive(Serialize)]
8648struct AssessmentReconSnapshotClaimLimitsDocument {
8649    record_interpretation: &'static str,
8650    source_authentication: &'static str,
8651    asset_ownership: &'static str,
8652    current_reachability: &'static str,
8653    scan_authority: &'static str,
8654    vulnerability: &'static str,
8655    impact: &'static str,
8656}
8657
8658#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8659impl AssessmentReconSnapshotSourceDocument {
8660    fn presentation(&self) -> String {
8661        format!(
8662            "source_id={};namespace={};revision={};provider_time={};observed_time={};collection_method={};completeness={};origin={};rights_status={};attribution={};notice={}",
8663            self.source_id,
8664            self.namespace,
8665            self.revision,
8666            self.provider_time.as_deref().unwrap_or("not_declared"),
8667            self.observed_time,
8668            self.collection_method,
8669            self.completeness,
8670            self.origin,
8671            self.rights.status,
8672            self.rights.attribution.as_deref().unwrap_or("not_declared"),
8673            self.rights.notice.as_deref().unwrap_or("not_declared"),
8674        )
8675    }
8676}
8677
8678#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8679impl AssessmentReconSnapshotImportAuditDocument {
8680    fn from_audit(audit: &ReconSnapshot) -> Result<Self, ReportError> {
8681        let mut sources = audit
8682            .sources()
8683            .iter()
8684            .map(|source| AssessmentReconSnapshotSourceDocument {
8685                source_id: source.source_id().to_owned(),
8686                namespace: source.namespace().to_owned(),
8687                revision: source.revision().to_owned(),
8688                provider_time: source.provider_time().map(str::to_owned),
8689                observed_time: source.observed_time().to_owned(),
8690                collection_method: source.collection_method().to_owned(),
8691                completeness: source.completeness().as_str(),
8692                origin: source.origin().to_owned(),
8693                rights: AssessmentReconSnapshotRightsDocument {
8694                    status: source.rights().status().as_str(),
8695                    attribution: source.rights().attribution().map(str::to_owned),
8696                    notice: source.rights().notice().map(str::to_owned),
8697                },
8698            })
8699            .collect::<Vec<_>>();
8700        sources.sort_by(|left, right| left.source_id.cmp(&right.source_id));
8701
8702        let mut records = audit
8703            .records()
8704            .iter()
8705            .map(|record| {
8706                let record_id = record.record_id().to_owned();
8707                let source_ids = record.source_ids().to_vec();
8708                match record.value() {
8709                    ReconnaissanceRecordValue::CtName(value) => {
8710                        AssessmentReconSnapshotRecordDocument::CtName {
8711                            record_id,
8712                            source_ids,
8713                            name: value.name().to_owned(),
8714                        }
8715                    },
8716                    ReconnaissanceRecordValue::DnsHistory(value) => {
8717                        AssessmentReconSnapshotRecordDocument::DnsHistory {
8718                            record_id,
8719                            source_ids,
8720                            name: value.name().to_owned(),
8721                            address: value.address().to_string(),
8722                        }
8723                    },
8724                    ReconnaissanceRecordValue::ServiceBannerProduct(value) => {
8725                        AssessmentReconSnapshotRecordDocument::ServiceBannerProduct {
8726                            record_id,
8727                            source_ids,
8728                            host: value.host().to_owned(),
8729                            port: value.port(),
8730                            transport: value.transport().as_str(),
8731                            product: value.product().to_owned(),
8732                            version: value.version().map(str::to_owned),
8733                        }
8734                    },
8735                    ReconnaissanceRecordValue::ReputationLabel(value) => {
8736                        AssessmentReconSnapshotRecordDocument::ReputationLabel {
8737                            record_id,
8738                            source_ids,
8739                            subject: value.subject().to_owned(),
8740                            label: value.label().to_owned(),
8741                        }
8742                    },
8743                }
8744            })
8745            .collect::<Vec<_>>();
8746        records.sort_by(|left, right| left.record_id().cmp(right.record_id()));
8747        let external = audit.external_activity();
8748        let document = Self {
8749            schema: RECON_SNAPSHOT_IMPORT_AUDIT_SCHEMA,
8750            policy: audit.policy(),
8751            selected: true,
8752            input: AssessmentReconSnapshotInputDocument {
8753                source_schema: audit.schema(),
8754                byte_length: audit.input().byte_length(),
8755                sha256: format!("sha256:{}", audit.input().sha256_hex()),
8756            },
8757            snapshot: AssessmentReconSnapshotIdentityDocument {
8758                id: audit.snapshot_id().to_owned(),
8759                revision: audit.snapshot_revision().to_owned(),
8760            },
8761            sources,
8762            records,
8763            accounting: AssessmentReconSnapshotAccountingDocument {
8764                source_count: u64::try_from(audit.source_count())
8765                    .map_err(|_| ReportError::Serialization)?,
8766                record_count: u64::try_from(audit.record_count())
8767                    .map_err(|_| ReportError::Serialization)?,
8768                source_association_count: u64::try_from(audit.source_association_count())
8769                    .map_err(|_| ReportError::Serialization)?,
8770                prepared_index_bytes: u64::try_from(audit.prepared_index_bytes())
8771                    .map_err(|_| ReportError::Serialization)?,
8772            },
8773            external_activity: AssessmentReconSnapshotExternalActivityDocument {
8774                target_request_count: u64::from(external.target_request_count()),
8775                provider_request_count: u64::from(external.provider_request_count()),
8776                archive_processing: external.archive_processing().as_str(),
8777                decompression: external.decompression().as_str(),
8778            },
8779            claim_limits: AssessmentReconSnapshotClaimLimitsDocument {
8780                record_interpretation: audit.claim_limit().as_str(),
8781                source_authentication: "not_established",
8782                asset_ownership: "not_established",
8783                current_reachability: "not_established",
8784                scan_authority: "not_granted",
8785                vulnerability: "not_established",
8786                impact: "not_established",
8787            },
8788        };
8789        document.validate()?;
8790        Ok(document)
8791    }
8792
8793    fn validate(&self) -> Result<(), ReportError> {
8794        let source_count =
8795            u64::try_from(self.sources.len()).map_err(|_| ReportError::Serialization)?;
8796        let record_count =
8797            u64::try_from(self.records.len()).map_err(|_| ReportError::Serialization)?;
8798        let mut source_ids = std::collections::BTreeSet::new();
8799        let sources_valid = !self.sources.is_empty()
8800            && self.sources.len() <= MAX_RECONNAISSANCE_SNAPSHOT_SOURCES
8801            && self.sources.iter().all(|source| {
8802                source_ids.insert(source.source_id.as_str())
8803                    && valid_recon_report_text(&source.source_id, 128)
8804                    && valid_recon_report_text(&source.namespace, 128)
8805                    && valid_recon_report_text(&source.revision, 128)
8806                    && source
8807                        .provider_time
8808                        .as_deref()
8809                        .is_none_or(|value| valid_recon_report_text(value, 96))
8810                    && valid_recon_report_text(&source.observed_time, 96)
8811                    && valid_recon_report_text(&source.collection_method, 128)
8812                    && matches!(
8813                        source.completeness,
8814                        "complete_as_declared" | "partial_as_declared" | "unknown"
8815                    )
8816                    && valid_recon_report_text(&source.origin, 2_048)
8817                    && matches!(
8818                        source.rights.status,
8819                        "permitted_as_declared" | "restricted_as_declared" | "unknown"
8820                    )
8821                    && source
8822                        .rights
8823                        .attribution
8824                        .as_deref()
8825                        .is_none_or(|value| valid_recon_report_text(value, 4_096))
8826                    && source
8827                        .rights
8828                        .notice
8829                        .as_deref()
8830                        .is_none_or(|value| valid_recon_report_text(value, 4_096))
8831            });
8832        let mut record_ids = std::collections::BTreeSet::new();
8833        let mut association_count = 0_u64;
8834        let records_valid = self.records.len() <= MAX_RECONNAISSANCE_SNAPSHOT_RECORDS
8835            && self.records.iter().all(|record| {
8836                let references = record.source_ids();
8837                let mut unique_references = std::collections::BTreeSet::new();
8838                let count = u64::try_from(references.len()).ok();
8839                association_count = count
8840                    .and_then(|count| association_count.checked_add(count))
8841                    .unwrap_or(u64::MAX);
8842                record_ids.insert(record.record_id())
8843                    && valid_recon_report_text(record.record_id(), 128)
8844                    && !references.is_empty()
8845                    && references.iter().all(|source_id| {
8846                        unique_references.insert(source_id.as_str())
8847                            && source_ids.contains(source_id.as_str())
8848                    })
8849                    && matches!(
8850                        record.kind(),
8851                        "ct_name" | "dns_history" | "service_banner_product" | "reputation_label"
8852                    )
8853                    && record.presentation().len() <= 8_192
8854            });
8855        if self.schema != RECON_SNAPSHOT_IMPORT_AUDIT_SCHEMA
8856            || self.policy != "termivar.recon-snapshot-import/v1"
8857            || !self.selected
8858            || self.input.source_schema != "security.recon-snapshot/v1"
8859            || self.input.byte_length == 0
8860            || self.input.byte_length > 1_048_576
8861            || !valid_recon_report_sha256(&self.input.sha256)
8862            || !valid_recon_report_text(&self.snapshot.id, 128)
8863            || !valid_recon_report_text(&self.snapshot.revision, 128)
8864            || !sources_valid
8865            || !records_valid
8866            || association_count > MAX_RECONNAISSANCE_SNAPSHOT_SOURCE_ASSOCIATIONS as u64
8867            || self.accounting.source_count != source_count
8868            || self.accounting.record_count != record_count
8869            || self.accounting.source_association_count != association_count
8870            || self.accounting.prepared_index_bytes
8871                > MAX_RECONNAISSANCE_SNAPSHOT_PREPARED_INDEX_BYTES as u64
8872            || self.external_activity.target_request_count != 0
8873            || self.external_activity.provider_request_count != 0
8874            || self.external_activity.archive_processing != "not_performed"
8875            || self.external_activity.decompression != "not_performed"
8876            || self.claim_limits.record_interpretation != "source_qualified_hypotheses_only"
8877            || self.claim_limits.source_authentication != "not_established"
8878            || self.claim_limits.asset_ownership != "not_established"
8879            || self.claim_limits.current_reachability != "not_established"
8880            || self.claim_limits.scan_authority != "not_granted"
8881            || self.claim_limits.vulnerability != "not_established"
8882            || self.claim_limits.impact != "not_established"
8883        {
8884            return Err(ReportError::Serialization);
8885        }
8886        Ok(())
8887    }
8888
8889    fn metadata(&self) -> [(&'static str, String); 13] {
8890        [
8891            ("Audit schema", self.schema.to_owned()),
8892            ("Policy", self.policy.to_owned()),
8893            ("Source schema", self.input.source_schema.to_owned()),
8894            ("Input bytes", self.input.byte_length.to_string()),
8895            ("Input SHA-256", self.input.sha256.clone()),
8896            ("Snapshot ID", self.snapshot.id.clone()),
8897            ("Snapshot revision", self.snapshot.revision.clone()),
8898            ("Declared sources", self.accounting.source_count.to_string()),
8899            (
8900                "Hypothesis records",
8901                self.accounting.record_count.to_string(),
8902            ),
8903            (
8904                "Source associations",
8905                self.accounting.source_association_count.to_string(),
8906            ),
8907            (
8908                "Prepared index bytes",
8909                self.accounting.prepared_index_bytes.to_string(),
8910            ),
8911            (
8912                "Additional target requests",
8913                self.external_activity.target_request_count.to_string(),
8914            ),
8915            (
8916                "Provider requests",
8917                self.external_activity.provider_request_count.to_string(),
8918            ),
8919        ]
8920    }
8921
8922    fn wire_json(&self) -> Result<String, ReportError> {
8923        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
8924    }
8925}
8926
8927#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8928fn valid_recon_report_text(value: &str, maximum_bytes: usize) -> bool {
8929    !value.is_empty() && value.len() <= maximum_bytes && !value.chars().any(char::is_control)
8930}
8931
8932#[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
8933fn valid_recon_report_sha256(value: &str) -> bool {
8934    value.strip_prefix("sha256:").is_some_and(|digest| {
8935        digest.len() == 64
8936            && digest
8937                .bytes()
8938                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
8939    })
8940}
8941
8942#[cfg(all(feature = "scanning", feature = "tls-observation"))]
8943#[derive(Serialize)]
8944struct AssessmentTlsObservationAuditDocument {
8945    schema: &'static str,
8946    policy: &'static str,
8947    selected: bool,
8948    additional_request_count: u64,
8949    assessment_request_count: u64,
8950    target_scheme: &'static str,
8951    observation_source_scope: &'static str,
8952    observation_clock_assurance: &'static str,
8953    successful_https_response_count: u64,
8954    plaintext_response_count: u64,
8955    tls_info_unavailable_count: u64,
8956    malformed_certificate_count: u64,
8957    certificate_limit_rejection_count: u64,
8958    unretained_leaf_response_count: u64,
8959    leaf_observation_count: u64,
8960    protocol: &'static str,
8961    cipher_suite: &'static str,
8962    alpn_protocol: &'static str,
8963    full_chain: &'static str,
8964    connection_reuse: &'static str,
8965    session_resumption: &'static str,
8966    handshake_kind: &'static str,
8967    revocation: &'static str,
8968    transport_validation_scope: &'static str,
8969    active_tls_matrix: &'static str,
8970    source_authentication: &'static str,
8971    observations: Vec<AssessmentTlsLeafObservationDocument>,
8972}
8973
8974#[cfg(all(feature = "scanning", feature = "tls-observation"))]
8975#[derive(Serialize)]
8976struct AssessmentTlsLeafObservationDocument {
8977    leaf_certificate_sha256: String,
8978    leaf_certificate_byte_length: u64,
8979    not_before_epoch_seconds: i64,
8980    not_after_epoch_seconds: i64,
8981    observed_at_epoch_seconds: i64,
8982    dns_san_count: u64,
8983    ip_san_count: u64,
8984    other_san_count: u64,
8985    san_count_truncated: bool,
8986    certificate_time_status: &'static str,
8987    response_occurrence_count: u64,
8988    standard_transport_validation_succeeded: bool,
8989}
8990
8991#[cfg(all(feature = "scanning", feature = "tls-observation"))]
8992impl AssessmentTlsObservationAuditDocument {
8993    fn from_audit(
8994        audit: &WebAssessmentTlsObservationAudit,
8995        assessment_request_count: u64,
8996    ) -> Result<Self, ReportError> {
8997        let mut observations = audit
8998            .leaf_observations()
8999            .iter()
9000            .map(|observation| AssessmentTlsLeafObservationDocument {
9001                leaf_certificate_sha256: observation.sha256().to_owned(),
9002                leaf_certificate_byte_length: observation.byte_length(),
9003                not_before_epoch_seconds: observation.not_before_epoch_seconds(),
9004                not_after_epoch_seconds: observation.not_after_epoch_seconds(),
9005                observed_at_epoch_seconds: observation.observed_at_epoch_seconds(),
9006                dns_san_count: u64::from(observation.dns_san_count()),
9007                ip_san_count: u64::from(observation.ip_san_count()),
9008                other_san_count: u64::from(observation.other_san_count()),
9009                san_count_truncated: observation.san_count_truncated(),
9010                certificate_time_status: observation.certificate_time_status().as_str(),
9011                response_occurrence_count: observation.response_occurrence_count(),
9012                standard_transport_validation_succeeded: observation
9013                    .standard_transport_validation_succeeded(),
9014            })
9015            .collect::<Vec<_>>();
9016        observations.sort_by(|left, right| {
9017            left.leaf_certificate_sha256
9018                .cmp(&right.leaf_certificate_sha256)
9019        });
9020        Ok(Self {
9021            schema: audit.schema(),
9022            policy: audit.policy(),
9023            selected: audit.selected(),
9024            additional_request_count: audit.additional_request_count(),
9025            assessment_request_count,
9026            target_scheme: audit.target_scheme().as_str(),
9027            observation_source_scope: audit.observation_source_scope(),
9028            observation_clock_assurance: audit.observation_clock_assurance(),
9029            successful_https_response_count: audit.successful_https_response_count(),
9030            plaintext_response_count: audit.plaintext_response_count(),
9031            tls_info_unavailable_count: audit.tls_info_unavailable_count(),
9032            malformed_certificate_count: audit.malformed_certificate_count(),
9033            certificate_limit_rejection_count: audit.certificate_limit_rejection_count(),
9034            unretained_leaf_response_count: audit.unretained_leaf_response_count(),
9035            leaf_observation_count: u64::try_from(observations.len())
9036                .map_err(|_| ReportError::Serialization)?,
9037            protocol: audit.protocol(),
9038            cipher_suite: audit.cipher_suite(),
9039            alpn_protocol: audit.alpn_protocol(),
9040            full_chain: audit.full_chain(),
9041            connection_reuse: audit.connection_reuse(),
9042            session_resumption: audit.session_resumption(),
9043            handshake_kind: audit.handshake_kind(),
9044            revocation: audit.revocation(),
9045            transport_validation_scope: audit.standard_transport_validation_scope(),
9046            active_tls_matrix: "not_performed",
9047            source_authentication: "not_established",
9048            observations,
9049        })
9050    }
9051
9052    fn validate(&self) -> Result<(), ReportError> {
9053        let mut unique_digests = std::collections::BTreeSet::new();
9054        let retained_response_count = self.observations.iter().try_fold(0_u64, |sum, row| {
9055            let san_count = row
9056                .dns_san_count
9057                .checked_add(row.ip_san_count)
9058                .and_then(|count| count.checked_add(row.other_san_count));
9059            let time_consistent = match row.certificate_time_status {
9060                "valid_at_observation" => {
9061                    row.not_before_epoch_seconds <= row.observed_at_epoch_seconds
9062                        && row.observed_at_epoch_seconds <= row.not_after_epoch_seconds
9063                },
9064                "not_yet_valid_at_observation" => {
9065                    row.observed_at_epoch_seconds < row.not_before_epoch_seconds
9066                        && row.not_before_epoch_seconds <= row.not_after_epoch_seconds
9067                },
9068                "expired_at_observation" => {
9069                    row.not_before_epoch_seconds <= row.not_after_epoch_seconds
9070                        && row.not_after_epoch_seconds < row.observed_at_epoch_seconds
9071                },
9072                _ => false,
9073            };
9074            if row.leaf_certificate_sha256.len() != 64
9075                || !row
9076                    .leaf_certificate_sha256
9077                    .bytes()
9078                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
9079                || row.leaf_certificate_byte_length == 0
9080                || row.leaf_certificate_byte_length > 64 * 1024
9081                || row.response_occurrence_count == 0
9082                || san_count
9083                    .is_none_or(|count| count > 256 || (row.san_count_truncated && count != 256))
9084                || !time_consistent
9085                || !row.standard_transport_validation_succeeded
9086                || !unique_digests.insert(row.leaf_certificate_sha256.as_str())
9087            {
9088                return None;
9089            }
9090            sum.checked_add(row.response_occurrence_count)
9091        });
9092        let https_partition = self
9093            .tls_info_unavailable_count
9094            .checked_add(self.malformed_certificate_count)
9095            .and_then(|count| count.checked_add(self.certificate_limit_rejection_count))
9096            .and_then(|count| count.checked_add(self.unretained_leaf_response_count))
9097            .and_then(|count| {
9098                retained_response_count.and_then(|retained| count.checked_add(retained))
9099            });
9100        let observed_response_count = self
9101            .successful_https_response_count
9102            .checked_add(self.plaintext_response_count);
9103        if self.schema != TLS_OBSERVATION_AUDIT_SCHEMA
9104            || self.policy != TLS_OBSERVATION_POLICY_ID
9105            || !self.selected
9106            || self.additional_request_count != 0
9107            || self.assessment_request_count > u64::from(HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS)
9108            || observed_response_count.is_none_or(|count| count > self.assessment_request_count)
9109            || !matches!(self.target_scheme, "http" | "https")
9110            || self.observation_source_scope != TLS_OBSERVATION_SOURCE_SCOPE
9111            || self.observation_clock_assurance != TLS_OBSERVATION_CLOCK_ASSURANCE
9112            || self.leaf_observation_count
9113                != u64::try_from(self.observations.len()).map_err(|_| ReportError::Serialization)?
9114            || self.observations.len() > 16
9115            || self.successful_https_response_count > 10_000
9116            || self.plaintext_response_count > 10_000
9117            || https_partition != Some(self.successful_https_response_count)
9118            || (self.target_scheme == "https" && self.plaintext_response_count != 0)
9119            || (self.target_scheme == "http" && self.successful_https_response_count != 0)
9120            || self.protocol != TLS_OBSERVATION_BACKEND_LIMIT
9121            || self.cipher_suite != TLS_OBSERVATION_BACKEND_LIMIT
9122            || self.alpn_protocol != TLS_OBSERVATION_BACKEND_LIMIT
9123            || self.full_chain != TLS_OBSERVATION_BACKEND_LIMIT
9124            || self.connection_reuse != TLS_OBSERVATION_BACKEND_LIMIT
9125            || self.session_resumption != TLS_OBSERVATION_BACKEND_LIMIT
9126            || self.handshake_kind != TLS_OBSERVATION_BACKEND_LIMIT
9127            || self.revocation != TLS_OBSERVATION_REVOCATION_STATUS
9128            || self.transport_validation_scope != TLS_OBSERVATION_VALIDATION_SCOPE
9129            || self.active_tls_matrix != "not_performed"
9130            || self.source_authentication != "not_established"
9131        {
9132            return Err(ReportError::Serialization);
9133        }
9134        Ok(())
9135    }
9136
9137    fn metadata(&self) -> Vec<(&'static str, String)> {
9138        vec![
9139            ("Audit schema", self.schema.to_owned()),
9140            ("Policy", self.policy.to_owned()),
9141            ("Selected", self.selected.to_string()),
9142            ("Target scheme", self.target_scheme.to_owned()),
9143            (
9144                "Observation source scope",
9145                self.observation_source_scope.to_owned(),
9146            ),
9147            (
9148                "Observation clock assurance",
9149                self.observation_clock_assurance.to_owned(),
9150            ),
9151            (
9152                "Additional target requests",
9153                self.additional_request_count.to_string(),
9154            ),
9155            (
9156                "Assessment requests",
9157                self.assessment_request_count.to_string(),
9158            ),
9159            (
9160                "Successful HTTPS responses",
9161                self.successful_https_response_count.to_string(),
9162            ),
9163            (
9164                "Plaintext responses",
9165                self.plaintext_response_count.to_string(),
9166            ),
9167            (
9168                "TLS information unavailable",
9169                self.tls_info_unavailable_count.to_string(),
9170            ),
9171            (
9172                "Malformed leaf certificates",
9173                self.malformed_certificate_count.to_string(),
9174            ),
9175            (
9176                "Certificate limit rejections",
9177                self.certificate_limit_rejection_count.to_string(),
9178            ),
9179            (
9180                "Unretained leaf responses",
9181                self.unretained_leaf_response_count.to_string(),
9182            ),
9183            (
9184                "Unique retained leaf certificates",
9185                self.leaf_observation_count.to_string(),
9186            ),
9187            ("Negotiated protocol", self.protocol.to_owned()),
9188            ("Negotiated cipher suite", self.cipher_suite.to_owned()),
9189            ("ALPN protocol", self.alpn_protocol.to_owned()),
9190            ("Full chain", self.full_chain.to_owned()),
9191            ("Connection reuse", self.connection_reuse.to_owned()),
9192            ("Session resumption", self.session_resumption.to_owned()),
9193            ("Handshake kind", self.handshake_kind.to_owned()),
9194            ("Revocation", self.revocation.to_owned()),
9195            (
9196                "Transport validation scope",
9197                self.transport_validation_scope.to_owned(),
9198            ),
9199            ("Active TLS matrix", self.active_tls_matrix.to_owned()),
9200            (
9201                "Source authentication",
9202                self.source_authentication.to_owned(),
9203            ),
9204        ]
9205    }
9206
9207    fn wire_json(&self) -> Result<String, ReportError> {
9208        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
9209    }
9210}
9211
9212#[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
9213const TLS_NEGOTIATION_REVIEW_CLAIM_LIMITS: [&str; 6] = [
9214    "finite_matrix_not_complete_server_capability_enumeration",
9215    "failed_handshake_does_not_establish_server_non_support",
9216    "configured_trust_validation_not_source_authentication",
9217    "revocation_and_transparency_retrieval_not_performed",
9218    "application_data_and_credentials_not_sent",
9219    "vulnerability_exploitability_impact_and_remediation_not_established",
9220];
9221
9222#[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
9223#[derive(Serialize)]
9224struct AssessmentTlsNegotiationReviewAuditDocument {
9225    schema: &'static str,
9226    policy: &'static str,
9227    selected: bool,
9228    methodology: AssessmentTlsNegotiationMethodologyDocument,
9229    coverage: AssessmentTlsNegotiationCoverageDocument,
9230    capability_observations: Vec<AssessmentTlsNegotiationCellDocument>,
9231    claim_limits: [&'static str; 6],
9232}
9233
9234#[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
9235#[derive(Serialize)]
9236struct AssessmentTlsNegotiationMethodologyDocument {
9237    target_binding: &'static str,
9238    address_selection: &'static str,
9239    execution_order: &'static str,
9240    maximum_in_flight_connections: u8,
9241    network_capable_cell_count: u8,
9242    matrix_cell_count: u8,
9243    retries: &'static str,
9244    redirects: &'static str,
9245    proxy: &'static str,
9246    application_data: &'static str,
9247    credentials: &'static str,
9248    client_certificate: &'static str,
9249    alpn: &'static str,
9250    session_resumption: &'static str,
9251    certificate_validation: &'static str,
9252}
9253
9254#[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
9255#[derive(Serialize)]
9256struct AssessmentTlsNegotiationCoverageDocument {
9257    terminal: &'static str,
9258    connection_attempt_limit: u8,
9259    connection_in_flight_limit: u8,
9260    per_connection_ingress_byte_limit: u64,
9261    total_ingress_byte_limit: u64,
9262    per_connection_egress_byte_limit: u64,
9263    total_egress_byte_limit: u64,
9264    attempted_connection_count: u8,
9265    completed_handshake_count: u8,
9266    accounted_ingress_tls_bytes: u64,
9267    observed_egress_tls_bytes: u64,
9268    client_backend_unsupported_cell_count: u8,
9269}
9270
9271#[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
9272#[derive(Serialize)]
9273struct AssessmentTlsNegotiationCellDocument {
9274    cell_id: &'static str,
9275    offered_protocol: &'static str,
9276    client_backend: &'static str,
9277    dispatch_status: &'static str,
9278    outcome: &'static str,
9279    support: &'static str,
9280    transport_validation: &'static str,
9281    negotiated_protocol: Option<&'static str>,
9282    negotiated_cipher_suite: Option<&'static str>,
9283    accounted_ingress_tls_bytes: u64,
9284    observed_egress_tls_bytes: u64,
9285}
9286
9287#[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
9288impl AssessmentTlsNegotiationReviewAuditDocument {
9289    fn from_audit(audit: &WebAssessmentTlsNegotiationReviewAudit) -> Result<Self, ReportError> {
9290        if !audit.is_valid() {
9291            return Err(ReportError::Serialization);
9292        }
9293        let document = Self {
9294            schema: audit.schema(),
9295            policy: audit.policy(),
9296            selected: audit.selected(),
9297            methodology: AssessmentTlsNegotiationMethodologyDocument {
9298                target_binding: "selected_https_target_exact_host_sni_port",
9299                address_selection: "single_deterministic_address_under_selected_authority",
9300                execution_order: "tls13_then_tls12_then_backend_unsupported_rows",
9301                maximum_in_flight_connections: 1,
9302                network_capable_cell_count: 2,
9303                matrix_cell_count: 5,
9304                retries: "none",
9305                redirects: "none",
9306                proxy: "not_used",
9307                application_data: "not_sent",
9308                credentials: "not_sent",
9309                client_certificate: "not_sent",
9310                alpn: "not_offered",
9311                session_resumption: "disabled",
9312                certificate_validation: "standard_configured_trust",
9313            },
9314            coverage: AssessmentTlsNegotiationCoverageDocument {
9315                terminal: audit.terminal(),
9316                connection_attempt_limit: 2,
9317                connection_in_flight_limit: 1,
9318                per_connection_ingress_byte_limit: 128 * 1_024,
9319                total_ingress_byte_limit: 256 * 1_024,
9320                per_connection_egress_byte_limit: 64 * 1_024,
9321                total_egress_byte_limit: 128 * 1_024,
9322                attempted_connection_count: audit.attempted_connection_count(),
9323                completed_handshake_count: audit.completed_handshake_count(),
9324                accounted_ingress_tls_bytes: audit.accounted_ingress_tls_bytes(),
9325                observed_egress_tls_bytes: audit.observed_egress_tls_bytes(),
9326                client_backend_unsupported_cell_count: 3,
9327            },
9328            capability_observations: audit
9329                .cells()
9330                .iter()
9331                .map(|cell| AssessmentTlsNegotiationCellDocument {
9332                    cell_id: cell.id(),
9333                    offered_protocol: cell.offered_protocol(),
9334                    client_backend: cell.client_backend(),
9335                    dispatch_status: cell.dispatch_status(),
9336                    outcome: cell.outcome(),
9337                    support: cell.support(),
9338                    transport_validation: cell.transport_validation(),
9339                    negotiated_protocol: cell.negotiated_protocol(),
9340                    negotiated_cipher_suite: cell.negotiated_cipher_suite(),
9341                    accounted_ingress_tls_bytes: cell.accounted_ingress_tls_bytes(),
9342                    observed_egress_tls_bytes: cell.observed_egress_tls_bytes(),
9343                })
9344                .collect(),
9345            claim_limits: TLS_NEGOTIATION_REVIEW_CLAIM_LIMITS,
9346        };
9347        document.validate()?;
9348        Ok(document)
9349    }
9350
9351    fn validate(&self) -> Result<(), ReportError> {
9352        const CELL_IDS: [&str; 5] = [
9353            "tls13_default_provider",
9354            "tls12_default_provider",
9355            "tls11_legacy_protocol",
9356            "tls10_legacy_protocol",
9357            "legacy_cipher_suites",
9358        ];
9359        const OFFERED_PROTOCOLS: [&str; 5] = [
9360            "tls1.3",
9361            "tls1.2",
9362            "tls1.1",
9363            "tls1.0",
9364            "legacy_cipher_suites",
9365        ];
9366        if self.schema != TLS_NEGOTIATION_REVIEW_AUDIT_SCHEMA
9367            || self.policy != TLS_NEGOTIATION_REVIEW_POLICY_ID
9368            || !self.selected
9369            || self.methodology.target_binding != "selected_https_target_exact_host_sni_port"
9370            || self.methodology.address_selection
9371                != "single_deterministic_address_under_selected_authority"
9372            || self.methodology.execution_order != "tls13_then_tls12_then_backend_unsupported_rows"
9373            || self.methodology.maximum_in_flight_connections != 1
9374            || self.methodology.network_capable_cell_count != 2
9375            || self.methodology.matrix_cell_count != 5
9376            || self.methodology.retries != "none"
9377            || self.methodology.redirects != "none"
9378            || self.methodology.proxy != "not_used"
9379            || self.methodology.application_data != "not_sent"
9380            || self.methodology.credentials != "not_sent"
9381            || self.methodology.client_certificate != "not_sent"
9382            || self.methodology.alpn != "not_offered"
9383            || self.methodology.session_resumption != "disabled"
9384            || self.methodology.certificate_validation != "standard_configured_trust"
9385            || self.coverage.connection_attempt_limit != 2
9386            || self.coverage.connection_in_flight_limit != 1
9387            || self.coverage.per_connection_ingress_byte_limit != 128 * 1_024
9388            || self.coverage.total_ingress_byte_limit != 256 * 1_024
9389            || self.coverage.per_connection_egress_byte_limit != 64 * 1_024
9390            || self.coverage.total_egress_byte_limit != 128 * 1_024
9391            || self.coverage.client_backend_unsupported_cell_count != 3
9392            || self.capability_observations.len() != 5
9393            || self.claim_limits != TLS_NEGOTIATION_REVIEW_CLAIM_LIMITS
9394        {
9395            return Err(ReportError::Serialization);
9396        }
9397        let mut attempted = 0_u8;
9398        let mut completed = 0_u8;
9399        let mut ingress = 0_u64;
9400        let mut egress = 0_u64;
9401        let mut terminal_stop_seen = false;
9402        let mut terminal_sequence_valid = true;
9403        for (index, cell) in self.capability_observations.iter().enumerate() {
9404            if cell.cell_id != CELL_IDS[index]
9405                || cell.offered_protocol != OFFERED_PROTOCOLS[index]
9406                || cell.client_backend != "rustls-ring"
9407                || cell.accounted_ingress_tls_bytes > 128 * 1_024
9408                || cell.observed_egress_tls_bytes > 64 * 1_024
9409            {
9410                return Err(ReportError::Serialization);
9411            }
9412            ingress = ingress
9413                .checked_add(cell.accounted_ingress_tls_bytes)
9414                .ok_or(ReportError::Serialization)?;
9415            egress = egress
9416                .checked_add(cell.observed_egress_tls_bytes)
9417                .ok_or(ReportError::Serialization)?;
9418            if index >= 2 {
9419                if cell.dispatch_status != "not_dispatched"
9420                    || cell.outcome != "client_backend_unsupported"
9421                    || cell.support != "not_tested_client_backend_unsupported"
9422                    || cell.transport_validation != "not_applicable"
9423                    || cell.negotiated_protocol.is_some()
9424                    || cell.negotiated_cipher_suite.is_some()
9425                    || cell.accounted_ingress_tls_bytes != 0
9426                    || cell.observed_egress_tls_bytes != 0
9427                {
9428                    return Err(ReportError::Serialization);
9429                }
9430                continue;
9431            }
9432            if cell.dispatch_status == "dispatched" {
9433                attempted = attempted.checked_add(1).ok_or(ReportError::Serialization)?;
9434                if matches!(
9435                    cell.outcome,
9436                    "client_backend_unsupported" | "parent_authority_unavailable" | "not_reached"
9437                ) {
9438                    return Err(ReportError::Serialization);
9439                }
9440            } else if cell.dispatch_status != "not_dispatched"
9441                || cell.accounted_ingress_tls_bytes != 0
9442                || cell.observed_egress_tls_bytes != 0
9443                || !matches!(
9444                    cell.outcome,
9445                    "parent_authority_unavailable"
9446                        | "budget_exhausted"
9447                        | "cancelled"
9448                        | "deadline_reached"
9449                        | "not_reached"
9450                )
9451            {
9452                return Err(ReportError::Serialization);
9453            }
9454            if index == 1
9455                && self.capability_observations[0].dispatch_status != "dispatched"
9456                && cell.dispatch_status == "dispatched"
9457            {
9458                return Err(ReportError::Serialization);
9459            }
9460            let stop_family = matches!(
9461                cell.outcome,
9462                "parent_authority_unavailable"
9463                    | "budget_exhausted"
9464                    | "cancelled"
9465                    | "deadline_reached"
9466            );
9467            match self.coverage.terminal {
9468                "completed" => terminal_sequence_valid &= !stop_family,
9469                "parent_authority_unavailable" => {},
9470                "budget_exhausted" | "cancelled" | "deadline_reached" => {
9471                    if terminal_stop_seen {
9472                        terminal_sequence_valid &= cell.dispatch_status == "not_dispatched"
9473                            && cell.outcome == "not_reached";
9474                    } else if stop_family {
9475                        let matches_terminal = cell.outcome == self.coverage.terminal
9476                            || (self.coverage.terminal == "deadline_reached"
9477                                && cell.outcome == "budget_exhausted");
9478                        terminal_sequence_valid &= matches_terminal;
9479                        terminal_stop_seen = matches_terminal;
9480                    } else if cell.outcome == "not_reached" {
9481                        terminal_sequence_valid = false;
9482                    }
9483                },
9484                _ => terminal_sequence_valid = false,
9485            }
9486            if cell.outcome == "negotiated" {
9487                completed = completed.checked_add(1).ok_or(ReportError::Serialization)?;
9488                let protocol_matches = cell.negotiated_protocol == Some(OFFERED_PROTOCOLS[index]);
9489                let cipher_matches = cell.negotiated_cipher_suite.is_some_and(|cipher| {
9490                    if index == 0 {
9491                        matches!(
9492                            cipher,
9493                            "tls13_aes_256_gcm_sha384"
9494                                | "tls13_aes_128_gcm_sha256"
9495                                | "tls13_chacha20_poly1305_sha256"
9496                        )
9497                    } else {
9498                        matches!(
9499                            cipher,
9500                            "tls12_ecdhe_ecdsa_aes_256_gcm_sha384"
9501                                | "tls12_ecdhe_ecdsa_aes_128_gcm_sha256"
9502                                | "tls12_ecdhe_ecdsa_chacha20_poly1305_sha256"
9503                                | "tls12_ecdhe_rsa_aes_256_gcm_sha384"
9504                                | "tls12_ecdhe_rsa_aes_128_gcm_sha256"
9505                                | "tls12_ecdhe_rsa_chacha20_poly1305_sha256"
9506                        )
9507                    }
9508                });
9509                if cell.dispatch_status != "dispatched"
9510                    || cell.support != "observed_supported"
9511                    || cell.transport_validation != "succeeded"
9512                    || !protocol_matches
9513                    || !cipher_matches
9514                    || cell.accounted_ingress_tls_bytes == 0
9515                    || cell.observed_egress_tls_bytes == 0
9516                {
9517                    return Err(ReportError::Serialization);
9518                }
9519            } else if !matches!(
9520                cell.outcome,
9521                "peer_alert_received"
9522                    | "certificate_validation_failed"
9523                    | "handshake_failed"
9524                    | "transport_failed"
9525                    | "parent_authority_unavailable"
9526                    | "budget_exhausted"
9527                    | "cancelled"
9528                    | "deadline_reached"
9529                    | "not_reached"
9530            ) || cell.support != "not_established"
9531                || cell.transport_validation != "not_established"
9532                || cell.negotiated_protocol.is_some()
9533                || cell.negotiated_cipher_suite.is_some()
9534            {
9535                return Err(ReportError::Serialization);
9536            }
9537        }
9538        let completed_terminal_is_consistent =
9539            self.coverage.terminal == "completed" && terminal_sequence_valid && attempted == 2;
9540        if attempted != self.coverage.attempted_connection_count
9541            || completed != self.coverage.completed_handshake_count
9542            || ingress != self.coverage.accounted_ingress_tls_bytes
9543            || egress != self.coverage.observed_egress_tls_bytes
9544            || ingress > self.coverage.total_ingress_byte_limit
9545            || egress > self.coverage.total_egress_byte_limit
9546            || !completed_terminal_is_consistent
9547        {
9548            return Err(ReportError::Serialization);
9549        }
9550        Ok(())
9551    }
9552
9553    fn metadata(&self) -> Vec<(&'static str, String)> {
9554        vec![
9555            ("Audit schema", self.schema.to_owned()),
9556            ("Policy", self.policy.to_owned()),
9557            ("Terminal", self.coverage.terminal.to_owned()),
9558            (
9559                "Attempted connections",
9560                self.coverage.attempted_connection_count.to_string(),
9561            ),
9562            (
9563                "Completed handshakes",
9564                self.coverage.completed_handshake_count.to_string(),
9565            ),
9566            (
9567                "Accounted TLS ingress bytes",
9568                self.coverage.accounted_ingress_tls_bytes.to_string(),
9569            ),
9570            (
9571                "Observed TLS egress bytes",
9572                self.coverage.observed_egress_tls_bytes.to_string(),
9573            ),
9574            (
9575                "Client-backend-unsupported cells",
9576                self.coverage
9577                    .client_backend_unsupported_cell_count
9578                    .to_string(),
9579            ),
9580        ]
9581    }
9582
9583    fn wire_json(&self) -> Result<String, ReportError> {
9584        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
9585    }
9586}
9587
9588#[cfg(all(feature = "scanning", feature = "openapi-review"))]
9589#[derive(Serialize)]
9590struct AssessmentOpenApiAuditDocument {
9591    schema: &'static str,
9592    capability_id: &'static str,
9593    outcome: &'static str,
9594    candidate_source: crate::web_runtime::OpenApiCandidateSource,
9595    request_count: u8,
9596    active_verification_count: u8,
9597    version: Option<&'static str>,
9598    semantic_digest: Option<String>,
9599    path_count: u32,
9600    operation_count: u32,
9601    get_operation_count: u32,
9602    write_operation_count: u32,
9603    path_parameter_count: u32,
9604    query_parameter_count: u32,
9605    explicit_auth_operation_count: u32,
9606    anonymous_operation_count: u32,
9607    url_like_operation_count: u32,
9608    multipart_operation_count: u32,
9609    deprecated_operation_count: u32,
9610    replay_matched: bool,
9611    item_projected: bool,
9612}
9613#[cfg(all(feature = "scanning", feature = "openapi-review"))]
9614impl AssessmentOpenApiAuditDocument {
9615    fn from_audit(a: &crate::web_runtime::WebAssessmentOpenApiAudit) -> Self {
9616        Self {
9617            schema: "security.openapi-review-audit/v1",
9618            capability_id: OPENAPI_REVIEW_CAPABILITY_ID,
9619            outcome: openapi_outcome(a.outcome()),
9620            candidate_source: a.candidate_source(),
9621            request_count: a.request_count(),
9622            active_verification_count: a.active_verification_count(),
9623            version: a.version(),
9624            semantic_digest: a.semantic_digest().map(str::to_owned),
9625            path_count: a.path_count(),
9626            operation_count: a.operation_count(),
9627            get_operation_count: a.get_operation_count(),
9628            write_operation_count: a.write_operation_count(),
9629            path_parameter_count: a.path_parameter_count(),
9630            query_parameter_count: a.query_parameter_count(),
9631            explicit_auth_operation_count: a.explicit_auth_operation_count(),
9632            anonymous_operation_count: a.anonymous_operation_count(),
9633            url_like_operation_count: a.url_like_operation_count(),
9634            multipart_operation_count: a.multipart_operation_count(),
9635            deprecated_operation_count: a.deprecated_operation_count(),
9636            replay_matched: a.replay_matched(),
9637            item_projected: a.item_projected(),
9638        }
9639    }
9640    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
9641        let count = items
9642            .iter()
9643            .filter(|i| i.capability_id == OPENAPI_REVIEW_CAPABILITY_ID)
9644            .count();
9645        if self.request_count > 2
9646            || self.active_verification_count > 1
9647            || count > 1
9648            || self.item_projected != (count == 1)
9649            || self.replay_matched != self.item_projected
9650        {
9651            return Err(ReportError::Serialization);
9652        }
9653        Ok(())
9654    }
9655    fn summary(&self) -> String {
9656        format!("source={};version={};digest={};paths={};operations={};get={};write={};path_parameters={};query_parameters={};explicit_auth={};anonymous={};url_like={};multipart={};deprecated={};replay_matched={};item_projected={}",self.candidate_source.as_str(),self.version.unwrap_or("none"),self.semantic_digest.as_deref().unwrap_or("none"),self.path_count,self.operation_count,self.get_operation_count,self.write_operation_count,self.path_parameter_count,self.query_parameter_count,self.explicit_auth_operation_count,self.anonymous_operation_count,self.url_like_operation_count,self.multipart_operation_count,self.deprecated_operation_count,self.replay_matched,self.item_projected)
9657    }
9658    fn metadata(&self) -> Vec<(&'static str, String)> {
9659        vec![
9660            ("Audit schema", self.schema.into()),
9661            ("Capability", self.capability_id.into()),
9662            ("Outcome", self.outcome.into()),
9663            ("Request count", self.request_count.to_string()),
9664            (
9665                "Active verification count",
9666                self.active_verification_count.to_string(),
9667            ),
9668            ("Summary", self.summary()),
9669        ]
9670    }
9671}
9672#[cfg(all(feature = "scanning", feature = "openapi-review"))]
9673const fn openapi_outcome(o: OpenApiRuntimeOutcome) -> &'static str {
9674    match o {
9675        OpenApiRuntimeOutcome::NotEligible => "not_eligible",
9676        OpenApiRuntimeOutcome::DocumentObserved => "document_observed",
9677        OpenApiRuntimeOutcome::Swagger20MetadataOnly => "swagger_20_metadata_only",
9678        OpenApiRuntimeOutcome::UnsupportedVersion => "unsupported_version",
9679        OpenApiRuntimeOutcome::ReplayMismatch => "replay_mismatch",
9680        OpenApiRuntimeOutcome::UnsupportedMedia => "unsupported_media",
9681        OpenApiRuntimeOutcome::Malformed => "malformed",
9682        OpenApiRuntimeOutcome::LimitExceeded => "limit_exceeded",
9683        OpenApiRuntimeOutcome::TooLarge => "too_large",
9684        OpenApiRuntimeOutcome::RedirectObserved => "redirect_observed",
9685        OpenApiRuntimeOutcome::RateLimited => "rate_limited",
9686        OpenApiRuntimeOutcome::DefensiveInterference => "defensive_interference",
9687        OpenApiRuntimeOutcome::HttpError => "http_error",
9688        OpenApiRuntimeOutcome::Truncated => "truncated",
9689        OpenApiRuntimeOutcome::Incomplete => "incomplete",
9690        OpenApiRuntimeOutcome::BudgetExhausted => "budget_exhausted",
9691        OpenApiRuntimeOutcome::Cancelled => "cancelled",
9692    }
9693}
9694
9695#[cfg(all(feature = "scanning", feature = "rest-review"))]
9696#[derive(Serialize)]
9697struct AssessmentRestAuditDocument {
9698    schema: &'static str,
9699    capability_id: &'static str,
9700    enabled: bool,
9701    method: &'static str,
9702    outcome: &'static str,
9703    request_count: u8,
9704    active_verification_count: u8,
9705    eligible_operation_count: u32,
9706    #[serde(skip_serializing_if = "Option::is_none")]
9707    selected_operation_identity: Option<String>,
9708    #[serde(skip_serializing_if = "Option::is_none")]
9709    documented_response: Option<&'static str>,
9710    observed_media: &'static str,
9711    #[serde(skip_serializing_if = "Option::is_none")]
9712    status_class: Option<u8>,
9713    replay_stable: bool,
9714    item_projected: bool,
9715}
9716
9717#[cfg(all(feature = "scanning", feature = "rest-review"))]
9718impl AssessmentRestAuditDocument {
9719    fn from_audit(audit: &crate::web_runtime::WebAssessmentRestAudit) -> Self {
9720        Self {
9721            schema: "security.rest-readonly-review-audit/v1",
9722            capability_id: REST_REVIEW_CAPABILITY_ID,
9723            enabled: true,
9724            method: "get",
9725            outcome: rest_outcome(audit.outcome()),
9726            request_count: audit.request_count(),
9727            active_verification_count: audit.active_verification_count(),
9728            eligible_operation_count: audit.eligible_operation_count(),
9729            selected_operation_identity: audit.selected_operation_identity().map(str::to_owned),
9730            documented_response: audit.documented_response().map(rest_documented_response),
9731            observed_media: rest_observed_media(audit.observed_media()),
9732            status_class: audit.status_class(),
9733            replay_stable: audit.replay_stable(),
9734            item_projected: audit.item_projected(),
9735        }
9736    }
9737
9738    fn validate_for_response_privacy(
9739        &self,
9740        items: &[AssessmentItemDocument<'_>],
9741        response_privacy: bool,
9742    ) -> Result<(), ReportError> {
9743        let projected = items
9744            .iter()
9745            .filter(|item| item.capability_id == REST_REVIEW_CAPABILITY_ID)
9746            .count();
9747        let positive = self.outcome == "surface_observed";
9748        let selected_identity_is_valid = self
9749            .selected_operation_identity
9750            .as_deref()
9751            .is_none_or(|identity| identity.starts_with("openapi-operation-sha256:"));
9752        if self.schema != "security.rest-readonly-review-audit/v1"
9753            || self.capability_id != REST_REVIEW_CAPABILITY_ID
9754            || !self.enabled
9755            || self.method != "get"
9756            || usize::from(self.request_count) > MAX_REST_REVIEW_REQUESTS
9757            || usize::from(self.active_verification_count) > MAX_REST_REVIEW_ACTIVE_VERIFICATIONS
9758            || usize::from(self.active_verification_count)
9759                != usize::from(usize::from(self.request_count) == MAX_REST_REVIEW_REQUESTS)
9760            || projected > 1
9761            || self.item_projected != (projected == 1)
9762            || positive != self.item_projected
9763            || self.replay_stable != positive
9764            || !selected_identity_is_valid
9765            || (response_privacy && self.selected_operation_identity.is_some())
9766            || (positive
9767                && (usize::from(self.request_count) != MAX_REST_REVIEW_REQUESTS
9768                    || usize::from(self.active_verification_count)
9769                        != MAX_REST_REVIEW_ACTIVE_VERIFICATIONS
9770                    || self.eligible_operation_count == 0
9771                    || (self.selected_operation_identity.is_some() == response_privacy)))
9772        {
9773            return Err(ReportError::Serialization);
9774        }
9775        Ok(())
9776    }
9777
9778    fn summary(&self) -> String {
9779        format!(
9780            "enabled={};method={};eligible_operations={};selected_operation={};documented_response={};observed_media={};status_class={};replay_stable={};item_projected={}",
9781            self.enabled,
9782            self.method,
9783            self.eligible_operation_count,
9784            self.selected_operation_identity.as_deref().unwrap_or("none"),
9785            self.documented_response.unwrap_or("none"),
9786            self.observed_media,
9787            self.status_class
9788                .map_or_else(|| "none".to_owned(), |status| status.to_string()),
9789            self.replay_stable,
9790            self.item_projected,
9791        )
9792    }
9793
9794    fn metadata(&self) -> Vec<(&'static str, String)> {
9795        vec![
9796            ("Audit schema", self.schema.into()),
9797            ("Capability", self.capability_id.into()),
9798            ("Enabled", self.enabled.to_string()),
9799            ("Method", self.method.into()),
9800            ("Outcome", self.outcome.into()),
9801            ("Request count", self.request_count.to_string()),
9802            (
9803                "Active verification count",
9804                self.active_verification_count.to_string(),
9805            ),
9806            ("Summary", self.summary()),
9807        ]
9808    }
9809}
9810
9811#[cfg(all(feature = "scanning", feature = "rest-review"))]
9812const fn rest_documented_response(response: RestDocumentedResponseClass) -> &'static str {
9813    match response {
9814        RestDocumentedResponseClass::JsonCompatible => "json_compatible",
9815        RestDocumentedResponseClass::Unknown => "unknown",
9816    }
9817}
9818
9819#[cfg(all(feature = "scanning", feature = "rest-review"))]
9820const fn rest_observed_media(media: RestObservedMediaClass) -> &'static str {
9821    match media {
9822        RestObservedMediaClass::JsonCompatible => "json_compatible",
9823        RestObservedMediaClass::Text => "text",
9824        RestObservedMediaClass::Unsupported => "unsupported",
9825        RestObservedMediaClass::Unknown => "unknown",
9826    }
9827}
9828
9829#[cfg(all(feature = "scanning", feature = "rest-review"))]
9830const fn rest_outcome(outcome: RestRuntimeOutcome) -> &'static str {
9831    match outcome {
9832        RestRuntimeOutcome::NotEligible => "not_eligible",
9833        RestRuntimeOutcome::SurfaceObserved => "surface_observed",
9834        RestRuntimeOutcome::ReplayMismatch => "replay_mismatch",
9835        RestRuntimeOutcome::CompleteNonJson => "complete_non_json",
9836        RestRuntimeOutcome::Redirect => "redirect",
9837        RestRuntimeOutcome::AuthenticationRequired => "authentication_required",
9838        RestRuntimeOutcome::Forbidden => "forbidden",
9839        RestRuntimeOutcome::NotFound => "not_found",
9840        RestRuntimeOutcome::RateLimited => "rate_limited",
9841        RestRuntimeOutcome::DefensiveInterference => "defensive_interference",
9842        RestRuntimeOutcome::ServerError => "server_error",
9843        RestRuntimeOutcome::UnsupportedMedia => "unsupported_media",
9844        RestRuntimeOutcome::Truncated => "truncated",
9845        RestRuntimeOutcome::Incomplete => "incomplete",
9846        RestRuntimeOutcome::Cancelled => "cancelled",
9847        RestRuntimeOutcome::BudgetExhausted => "budget_exhausted",
9848    }
9849}
9850
9851#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
9852#[derive(Serialize)]
9853struct AssessmentSuppliedSessionAuditDocument {
9854    schema: &'static str,
9855    capability_id: &'static str,
9856    policy_reference: String,
9857    application_reference: String,
9858    principal_reference: String,
9859    principal_alias: String,
9860    principal_assurance: &'static str,
9861    credential_mechanism: &'static str,
9862    #[serde(skip_serializing_if = "Option::is_none")]
9863    credential_acquisition: Option<&'static str>,
9864    #[serde(skip_serializing_if = "Option::is_none")]
9865    cookie_policy: Option<AssessmentSuppliedSessionCookiePolicyDocument>,
9866    #[serde(skip_serializing_if = "Option::is_none")]
9867    cookie_lifecycle: Option<AssessmentSuppliedSessionCookieLifecycleDocument>,
9868    #[serde(skip_serializing_if = "Option::is_none")]
9869    login: Option<AssessmentSuppliedSessionLoginDocument>,
9870    #[serde(skip_serializing_if = "Option::is_none")]
9871    renewal: Option<AssessmentSuppliedSessionRenewalDocument>,
9872    #[serde(skip_serializing_if = "Option::is_none")]
9873    oauth_code: Option<crate::web_runtime::SuppliedSessionOAuthCodeAudit>,
9874    #[serde(skip_serializing_if = "Option::is_none")]
9875    oauth_refresh: Option<Box<crate::web_runtime::SuppliedSessionOAuthRefreshAudit>>,
9876    #[serde(skip_serializing_if = "Option::is_none")]
9877    oauth_authorization: Option<Box<crate::web_runtime::SuppliedSessionOAuthAuthorizationAudit>>,
9878    #[serde(skip_serializing_if = "Option::is_none")]
9879    oauth_token_exchange: Option<Box<crate::web_runtime::SuppliedSessionOAuthInitiatedTokenAudit>>,
9880    health_oracle: AssessmentSuppliedSessionHealthOracleDocument,
9881    outcome: &'static str,
9882    coverage: &'static str,
9883    checkpoints: Vec<AssessmentSuppliedSessionCheckpointDocument>,
9884    resources: Vec<AssessmentSuppliedSessionResourceDocument>,
9885    selected_resource_count: u8,
9886    dispatched_resource_count: u8,
9887    committed_resource_count: u8,
9888    dispatched_request_count: u8,
9889    response_bytes: u64,
9890    response_byte_limit: u64,
9891    response_byte_limit_exceeded: bool,
9892    refresh_performed: bool,
9893    anonymous_fallback_performed: bool,
9894    continuous_authentication_established: bool,
9895    exploit_execution: &'static str,
9896    impact_validation: &'static str,
9897}
9898
9899#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
9900#[derive(Serialize)]
9901struct AssessmentSuppliedSessionCookiePolicyDocument {
9902    declared_count: u8,
9903    host_only_count: u8,
9904    domain_count: u8,
9905    secure_count: u8,
9906    http_only_count: u8,
9907    session_count: u8,
9908    persistent_count: u8,
9909    same_site_missing_count: u8,
9910    same_site_strict_count: u8,
9911    same_site_lax_count: u8,
9912    same_site_none_count: u8,
9913    update_policy: &'static str,
9914    browser_semantics: &'static str,
9915}
9916
9917#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
9918#[derive(Serialize)]
9919struct AssessmentSuppliedSessionCookieLifecycleDocument {
9920    initial_epoch: u8,
9921    final_epoch: u8,
9922    selected_update_response_count: u8,
9923    unselected_update_response_count: u8,
9924    update_classification_failure_count: u8,
9925    updates_applied: u8,
9926}
9927
9928#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
9929#[derive(Serialize)]
9930struct AssessmentSuppliedSessionLoginDocument {
9931    login_reference: String,
9932    max_attempts: u8,
9933    attempt_count: u8,
9934    page_dispatched: bool,
9935    page_status: Option<u16>,
9936    page_body_state: &'static str,
9937    form_outcome: &'static str,
9938    #[serde(skip_serializing_if = "Option::is_none")]
9939    form_error_code: Option<&'static str>,
9940    submit_dispatched: bool,
9941    submit_status: Option<u16>,
9942    submit_body_state: &'static str,
9943    submit_outcome: &'static str,
9944    acquired_cookie_count: u8,
9945    response_bytes: u64,
9946    initial_epoch: u8,
9947    final_epoch: u8,
9948    pre_session_cookie_applied: bool,
9949}
9950
9951#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
9952impl AssessmentSuppliedSessionLoginDocument {
9953    fn validate(
9954        &self,
9955        audit_outcome: &str,
9956        startup: Option<&AssessmentSuppliedSessionCheckpointDocument>,
9957    ) -> Result<(), ReportError> {
9958        let page_status_is_valid = self
9959            .page_status
9960            .is_none_or(|status| (100..=599).contains(&status));
9961        let submit_status_is_valid = self
9962            .submit_status
9963            .is_none_or(|status| (100..=599).contains(&status));
9964        let form_error_matches = matches!(
9965            (self.form_outcome, self.form_error_code),
9966            ("matched", None)
9967                | ("not_evaluated", None)
9968                | (
9969                    "ineligible_response",
9970                    Some(
9971                        "ineligible_response"
9972                            | "pre_session_cookie_unsupported"
9973                            | "inexact_final_target"
9974                    )
9975                )
9976                | ("missing", Some("missing_form" | "missing_csrf"))
9977                | ("ambiguous", Some("ambiguous_form" | "ambiguous_csrf"))
9978                | (
9979                    "invalid",
9980                    Some(
9981                        "invalid_form_contract"
9982                            | "invalid_csrf"
9983                            | "invalid_descriptor"
9984                            | "invalid_form_body"
9985                    )
9986                )
9987                | ("transport_failed", Some("transport_failed"))
9988                | ("runtime_limit", Some("runtime_limit"))
9989                | ("cancelled", Some("cancelled"))
9990        );
9991        let page_state_is_valid = match self.form_outcome {
9992            "matched" => {
9993                self.page_dispatched
9994                    && self.page_status == Some(200)
9995                    && self.page_body_state == "complete"
9996            },
9997            "ineligible_response" => {
9998                self.page_dispatched
9999                    && self.page_status.is_some()
10000                    && matches!(self.page_body_state, "complete" | "incomplete")
10001            },
10002            "missing" | "ambiguous" => {
10003                self.page_dispatched
10004                    && self.page_status == Some(200)
10005                    && self.page_body_state == "complete"
10006            },
10007            "invalid" if self.form_error_code == Some("invalid_descriptor") => {
10008                !self.page_dispatched
10009                    && self.page_status.is_none()
10010                    && self.page_body_state == "unavailable"
10011            },
10012            "invalid" => {
10013                self.page_dispatched
10014                    && self.page_status == Some(200)
10015                    && self.page_body_state == "complete"
10016            },
10017            "transport_failed" | "runtime_limit" | "cancelled" => {
10018                self.page_status.is_none() && self.page_body_state == "unavailable"
10019            },
10020            "not_evaluated" => {
10021                !self.page_dispatched
10022                    && self.page_status.is_none()
10023                    && self.page_body_state == "unavailable"
10024            },
10025            _ => false,
10026        };
10027        let submit_state_is_valid = match self.submit_outcome {
10028            "cookie_acquired" => {
10029                self.submit_dispatched
10030                    && self.attempt_count == 1
10031                    && self.submit_status == Some(200)
10032                    && self.submit_body_state == "complete"
10033                    && self.acquired_cookie_count > 0
10034            },
10035            "cookie_unavailable" => {
10036                self.submit_dispatched
10037                    && self.attempt_count == 1
10038                    && self.submit_status == Some(200)
10039                    && self.submit_body_state == "complete"
10040                    && self.acquired_cookie_count == 0
10041            },
10042            "http_error" => {
10043                self.submit_dispatched
10044                    && self.attempt_count == 1
10045                    && self
10046                        .submit_status
10047                        .is_some_and(|status| status != 200 && !(300..400).contains(&status))
10048                    && self.submit_body_state == "complete"
10049                    && self.acquired_cookie_count == 0
10050            },
10051            "redirect_refused" => {
10052                self.submit_dispatched
10053                    && self.attempt_count == 1
10054                    && self
10055                        .submit_status
10056                        .is_some_and(|status| (300..400).contains(&status))
10057                    && matches!(self.submit_body_state, "complete" | "incomplete")
10058                    && self.acquired_cookie_count == 0
10059            },
10060            "incomplete" => {
10061                self.submit_dispatched
10062                    && self.attempt_count == 1
10063                    && self
10064                        .submit_status
10065                        .is_some_and(|status| !(300..400).contains(&status))
10066                    && self.submit_body_state == "incomplete"
10067                    && self.acquired_cookie_count == 0
10068            },
10069            "transport_failed" | "runtime_limit" | "cancelled" => {
10070                self.attempt_count == u8::from(self.submit_dispatched)
10071                    && self.submit_status.is_none()
10072                    && self.submit_body_state == "unavailable"
10073                    && self.acquired_cookie_count == 0
10074            },
10075            "not_dispatched" => {
10076                !self.submit_dispatched
10077                    && self.attempt_count == 0
10078                    && self.submit_status.is_none()
10079                    && self.submit_body_state == "unavailable"
10080                    && self.acquired_cookie_count == 0
10081            },
10082            _ => false,
10083        };
10084        let startup_authenticated = startup.is_some_and(|checkpoint| {
10085            checkpoint.phase == "startup"
10086                && checkpoint.outcome == "healthy"
10087                && checkpoint.predicate == "matched"
10088        });
10089        let epoch_is_valid = self.initial_epoch == 0
10090            && match self.final_epoch {
10091                0 => true,
10092                1 => self.submit_outcome == "cookie_acquired" && startup_authenticated,
10093                _ => false,
10094            };
10095        let outcome_is_valid = match audit_outcome {
10096            "login_form_unavailable" => {
10097                self.final_epoch == 0
10098                    && self.submit_outcome == "not_dispatched"
10099                    && !matches!(self.form_outcome, "matched" | "not_evaluated")
10100            },
10101            "login_submit_unavailable" => {
10102                self.final_epoch == 0
10103                    && self.form_outcome == "matched"
10104                    && !matches!(
10105                        self.submit_outcome,
10106                        "cookie_acquired" | "cookie_unavailable"
10107                    )
10108            },
10109            "login_cookie_unavailable" => {
10110                self.final_epoch == 0
10111                    && self.form_outcome == "matched"
10112                    && self.submit_outcome == "cookie_unavailable"
10113            },
10114            "runtime_limit" => {
10115                self.form_outcome == "runtime_limit"
10116                    || self.submit_outcome == "runtime_limit"
10117                    || self.submit_outcome == "cookie_acquired"
10118            },
10119            "cancelled" => {
10120                self.form_outcome == "cancelled"
10121                    || self.submit_outcome == "cancelled"
10122                    || self.submit_outcome == "cookie_acquired"
10123            },
10124            _ => self.submit_outcome == "cookie_acquired",
10125        };
10126        if !valid_supplied_session_reference(
10127            &self.login_reference,
10128            "supplied-session-login-sha256:",
10129        ) || self.max_attempts != 1
10130            || self.attempt_count > self.max_attempts
10131            || !page_status_is_valid
10132            || !submit_status_is_valid
10133            || !matches!(
10134                self.page_body_state,
10135                "complete" | "incomplete" | "unavailable"
10136            )
10137            || !matches!(
10138                self.submit_body_state,
10139                "complete" | "incomplete" | "unavailable"
10140            )
10141            || !form_error_matches
10142            || !page_state_is_valid
10143            || (self.submit_dispatched && self.form_outcome != "matched")
10144            || !submit_state_is_valid
10145            || !epoch_is_valid
10146            || !outcome_is_valid
10147            || self.pre_session_cookie_applied
10148            || (!self.page_dispatched && !self.submit_dispatched && self.response_bytes != 0)
10149        {
10150            return Err(ReportError::Serialization);
10151        }
10152        Ok(())
10153    }
10154}
10155
10156#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
10157#[derive(Serialize)]
10158struct AssessmentSuppliedSessionHealthOracleDocument {
10159    kind: &'static str,
10160    field_reference: String,
10161}
10162
10163#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
10164#[derive(Serialize)]
10165struct AssessmentSuppliedSessionCheckpointDocument {
10166    sequence: u8,
10167    phase: &'static str,
10168    after_subject_count: u8,
10169    evidence_reference: Option<String>,
10170    outcome: &'static str,
10171    status: Option<u16>,
10172    body_state: &'static str,
10173    predicate: &'static str,
10174    response_bytes: u64,
10175    #[serde(skip_serializing_if = "Option::is_none")]
10176    epoch: Option<u8>,
10177}
10178
10179#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
10180#[derive(Serialize)]
10181struct AssessmentSuppliedSessionRenewalDocument {
10182    renewal_reference: String,
10183    after_resource_count: u8,
10184    attempt_count: u8,
10185    status: Option<u16>,
10186    body_state: &'static str,
10187    outcome: &'static str,
10188    response_bytes: u64,
10189    applied_cookie_count: u8,
10190    initial_epoch: u8,
10191    final_epoch: u8,
10192    #[serde(skip_serializing_if = "Option::is_none")]
10193    health: Option<AssessmentSuppliedSessionCheckpointDocument>,
10194}
10195
10196#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
10197impl AssessmentSuppliedSessionCheckpointDocument {
10198    fn human_summary(&self, include_epoch: bool) -> String {
10199        let status = self
10200            .status
10201            .map_or_else(|| "not_available".to_owned(), |value| value.to_string());
10202        let evidence = self
10203            .evidence_reference
10204            .as_deref()
10205            .unwrap_or("not_available");
10206        let summary = format!(
10207            "sequence={};phase={};after_subject_count={};evidence_reference={};outcome={};status={};body_state={};predicate={};response_bytes={}",
10208            self.sequence, self.phase, self.after_subject_count, evidence, self.outcome,
10209            status, self.body_state, self.predicate, self.response_bytes,
10210        );
10211        if let Some(epoch) = self.epoch.filter(|_| include_epoch) {
10212            format!("{summary};epoch={epoch}")
10213        } else {
10214            summary
10215        }
10216    }
10217
10218    fn from_checkpoint(
10219        checkpoint: &WebAssessmentSuppliedSessionCheckpointAudit,
10220        renewal: bool,
10221    ) -> Self {
10222        Self {
10223            sequence: checkpoint.sequence(),
10224            phase: supplied_session_checkpoint_phase_token(checkpoint.phase()),
10225            after_subject_count: checkpoint.after_subject_count(),
10226            evidence_reference: checkpoint.evidence_reference().map(str::to_owned),
10227            outcome: supplied_session_health_outcome_token(checkpoint.outcome()),
10228            status: checkpoint.status(),
10229            body_state: supplied_session_body_state_token(checkpoint.body_state()),
10230            predicate: supplied_session_predicate_outcome_token(checkpoint.predicate()),
10231            response_bytes: checkpoint.response_bytes(),
10232            epoch: renewal.then_some(checkpoint.epoch()),
10233        }
10234    }
10235}
10236
10237#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
10238#[derive(Serialize)]
10239struct AssessmentSuppliedSessionResourceDocument {
10240    sequence: u8,
10241    resource_reference: String,
10242    evidence_reference: Option<String>,
10243    outcome: &'static str,
10244    status: Option<u16>,
10245    response_bytes: u64,
10246    epoch: u8,
10247}
10248
10249#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
10250impl AssessmentSuppliedSessionAuditDocument {
10251    fn from_audit(audit: &WebAssessmentSuppliedSessionAudit) -> Result<Self, ReportError> {
10252        if audit.exploit_execution_performed() || audit.impact_validation_performed() {
10253            return Err(ReportError::Serialization);
10254        }
10255        if audit
10256            .oauth_refresh()
10257            .is_some_and(|refresh| !refresh.is_consistent())
10258        {
10259            return Err(ReportError::Serialization);
10260        }
10261        if audit
10262            .oauth_authorization()
10263            .is_some_and(|a| !a.is_consistent())
10264            || audit
10265                .oauth_token_exchange()
10266                .is_some_and(|a| !a.is_consistent())
10267        {
10268            return Err(ReportError::Serialization);
10269        }
10270        let document = Self {
10271            schema: audit.schema(),
10272            capability_id: audit.capability_id(),
10273            policy_reference: audit.policy_reference().to_owned(),
10274            application_reference: audit.application_reference().to_owned(),
10275            principal_reference: audit.principal_reference().to_owned(),
10276            principal_alias: audit.principal_alias().to_owned(),
10277            principal_assurance: supplied_session_principal_assurance_token(
10278                audit.principal_assurance(),
10279            ),
10280            credential_mechanism: supplied_session_credential_mechanism_token(
10281                audit.credential_mechanism(),
10282            ),
10283            credential_acquisition: matches!(
10284                audit.schema(),
10285                SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA
10286                    | SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA
10287                    | SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA
10288                    | SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA
10289            )
10290            .then(|| supplied_session_credential_acquisition_token(audit.credential_acquisition())),
10291            oauth_code: audit.oauth_code().cloned(),
10292            oauth_refresh: audit.oauth_refresh().cloned().map(Box::new),
10293            oauth_authorization: audit.oauth_authorization().cloned().map(Box::new),
10294            oauth_token_exchange: audit.oauth_token_exchange().cloned().map(Box::new),
10295            cookie_policy: audit.cookie_policy().map(|policy| {
10296                AssessmentSuppliedSessionCookiePolicyDocument {
10297                    declared_count: policy.declared_count(),
10298                    host_only_count: policy.host_only_count(),
10299                    domain_count: policy.domain_count(),
10300                    secure_count: policy.secure_count(),
10301                    http_only_count: policy.http_only_count(),
10302                    session_count: policy.session_count(),
10303                    persistent_count: policy.persistent_count(),
10304                    same_site_missing_count: policy.same_site_missing_count(),
10305                    same_site_strict_count: policy.same_site_strict_count(),
10306                    same_site_lax_count: policy.same_site_lax_count(),
10307                    same_site_none_count: policy.same_site_none_count(),
10308                    update_policy: policy.update_policy().as_str(),
10309                    browser_semantics: policy.browser_semantics(),
10310                }
10311            }),
10312            cookie_lifecycle: audit.cookie_lifecycle().map(|lifecycle| {
10313                AssessmentSuppliedSessionCookieLifecycleDocument {
10314                    initial_epoch: lifecycle.initial_epoch(),
10315                    final_epoch: lifecycle.final_epoch(),
10316                    selected_update_response_count: lifecycle.selected_update_response_count(),
10317                    unselected_update_response_count: lifecycle.unselected_update_response_count(),
10318                    update_classification_failure_count: lifecycle
10319                        .update_classification_failure_count(),
10320                    updates_applied: lifecycle.updates_applied(),
10321                }
10322            }),
10323            login: audit
10324                .login()
10325                .map(|login| AssessmentSuppliedSessionLoginDocument {
10326                    login_reference: login.login_reference().to_owned(),
10327                    max_attempts: login.max_attempts(),
10328                    attempt_count: login.attempt_count(),
10329                    page_dispatched: login.page_dispatched(),
10330                    page_status: login.page_status(),
10331                    page_body_state: supplied_session_body_state_token(login.page_body_state()),
10332                    form_outcome: supplied_session_login_form_outcome_token(login.form_outcome()),
10333                    form_error_code: login.form_error_code(),
10334                    submit_dispatched: login.submit_dispatched(),
10335                    submit_status: login.submit_status(),
10336                    submit_body_state: supplied_session_body_state_token(login.submit_body_state()),
10337                    submit_outcome: supplied_session_login_submit_outcome_token(
10338                        login.submit_outcome(),
10339                    ),
10340                    acquired_cookie_count: login.acquired_cookie_count(),
10341                    response_bytes: login.response_bytes(),
10342                    initial_epoch: login.initial_epoch(),
10343                    final_epoch: login.final_epoch(),
10344                    pre_session_cookie_applied: login.pre_session_cookie_applied(),
10345                }),
10346            renewal: audit
10347                .renewal()
10348                .map(|renewal| AssessmentSuppliedSessionRenewalDocument {
10349                    renewal_reference: renewal.renewal_reference().to_owned(),
10350                    after_resource_count: renewal.after_resource_count(),
10351                    attempt_count: renewal.attempt_count(),
10352                    status: renewal.status(),
10353                    body_state: supplied_session_body_state_token(renewal.body_state()),
10354                    outcome: supplied_session_login_submit_outcome_token(renewal.outcome()),
10355                    response_bytes: renewal.response_bytes(),
10356                    applied_cookie_count: renewal.applied_cookie_count(),
10357                    initial_epoch: renewal.initial_epoch(),
10358                    final_epoch: renewal.final_epoch(),
10359                    health: renewal.health().map(|health| {
10360                        AssessmentSuppliedSessionCheckpointDocument::from_checkpoint(health, true)
10361                    }),
10362                }),
10363            health_oracle: AssessmentSuppliedSessionHealthOracleDocument {
10364                kind: supplied_session_health_oracle_kind_token(audit.health_oracle().kind()),
10365                field_reference: audit.health_oracle().field_reference().to_owned(),
10366            },
10367            outcome: supplied_session_audit_outcome_token(audit.outcome()),
10368            coverage: supplied_session_coverage_token(audit.coverage()),
10369            checkpoints: audit
10370                .checkpoints()
10371                .iter()
10372                .map(|checkpoint| {
10373                    AssessmentSuppliedSessionCheckpointDocument::from_checkpoint(
10374                        checkpoint,
10375                        audit.renewal().is_some() || audit.oauth_refresh().is_some(),
10376                    )
10377                })
10378                .collect(),
10379            resources: audit
10380                .resources()
10381                .iter()
10382                .map(|resource| AssessmentSuppliedSessionResourceDocument {
10383                    sequence: resource.sequence(),
10384                    resource_reference: resource.resource_reference().to_owned(),
10385                    evidence_reference: resource.evidence_reference().map(str::to_owned),
10386                    outcome: supplied_session_resource_outcome_token(resource.outcome()),
10387                    status: resource.status(),
10388                    response_bytes: resource.response_bytes(),
10389                    epoch: resource.epoch(),
10390                })
10391                .collect(),
10392            selected_resource_count: audit.selected_resource_count(),
10393            dispatched_resource_count: audit.dispatched_resource_count(),
10394            committed_resource_count: audit.committed_resource_count(),
10395            dispatched_request_count: audit.dispatched_request_count(),
10396            response_bytes: audit.response_bytes(),
10397            response_byte_limit: audit.response_byte_limit(),
10398            response_byte_limit_exceeded: audit.response_byte_limit_exceeded(),
10399            refresh_performed: audit.refresh_performed(),
10400            anonymous_fallback_performed: audit.anonymous_fallback_performed(),
10401            continuous_authentication_established: audit.continuous_authentication_established(),
10402            exploit_execution: "not_performed",
10403            impact_validation: "not_performed",
10404        };
10405        document.validate(&[])?;
10406        Ok(document)
10407    }
10408
10409    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
10410        if self.schema == SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA {
10411            if items
10412                .iter()
10413                .any(|item| item.capability_id == SUPPLIED_SESSION_CAPABILITY_ID)
10414            {
10415                return Err(ReportError::Serialization);
10416            }
10417            let value = serde_json::to_value(self).map_err(|_| ReportError::Serialization)?;
10418            return comparison::validate_supplied_session_oauth_authorization(&value)
10419                .map(|_| ())
10420                .map_err(|_| ReportError::Serialization);
10421        }
10422        if self.schema == SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA {
10423            if items
10424                .iter()
10425                .any(|item| item.capability_id == SUPPLIED_SESSION_CAPABILITY_ID)
10426            {
10427                return Err(ReportError::Serialization);
10428            }
10429            let value = serde_json::to_value(self).map_err(|_| ReportError::Serialization)?;
10430            return comparison::validate_supplied_session_oauth_refresh(&value)
10431                .map(|_| ())
10432                .map_err(|_| ReportError::Serialization);
10433        }
10434        if self.schema == SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA {
10435            if items
10436                .iter()
10437                .any(|item| item.capability_id == SUPPLIED_SESSION_CAPABILITY_ID)
10438            {
10439                return Err(ReportError::Serialization);
10440            }
10441            let value = serde_json::to_value(self).map_err(|_| ReportError::Serialization)?;
10442            return comparison::validate_supplied_session_oauth_code(&value)
10443                .map(|_| ())
10444                .map_err(|_| ReportError::Serialization);
10445        }
10446        if self.schema == SUPPLIED_SESSION_RENEWAL_AUDIT_SCHEMA {
10447            if items
10448                .iter()
10449                .any(|item| item.capability_id == SUPPLIED_SESSION_CAPABILITY_ID)
10450            {
10451                return Err(ReportError::Serialization);
10452            }
10453            let value = serde_json::to_value(self).map_err(|_| ReportError::Serialization)?;
10454            return comparison::validate_supplied_session_renewal(&value)
10455                .map(|_| ())
10456                .map_err(|_| ReportError::Serialization);
10457        }
10458        if self.renewal.is_some()
10459            || self.oauth_code.is_some()
10460            || self.oauth_refresh.is_some()
10461            || self.oauth_authorization.is_some()
10462            || self.oauth_token_exchange.is_some()
10463            || self
10464                .checkpoints
10465                .iter()
10466                .any(|checkpoint| checkpoint.epoch.is_some())
10467        {
10468            return Err(ReportError::Serialization);
10469        }
10470        let resource_response_bytes = self
10471            .resources
10472            .iter()
10473            .map(|resource| resource.response_bytes)
10474            .try_fold(0_u64, u64::checked_add);
10475        let checkpoint_response_bytes = self
10476            .checkpoints
10477            .iter()
10478            .map(|checkpoint| checkpoint.response_bytes)
10479            .try_fold(0_u64, u64::checked_add);
10480        let dispatched_resources = self
10481            .resources
10482            .iter()
10483            .filter(|resource| resource.outcome != "not_dispatched")
10484            .count();
10485        let committed_resources = self
10486            .resources
10487            .iter()
10488            .filter(|resource| resource.outcome == "committed")
10489            .count();
10490        let unique_resource_references = self
10491            .resources
10492            .iter()
10493            .map(|resource| resource.resource_reference.as_str())
10494            .collect::<std::collections::BTreeSet<_>>();
10495        let checkpoint_evidence_reference_count = self
10496            .checkpoints
10497            .iter()
10498            .filter(|checkpoint| checkpoint.evidence_reference.is_some())
10499            .count();
10500        let unique_checkpoint_evidence_references = self
10501            .checkpoints
10502            .iter()
10503            .filter_map(|checkpoint| checkpoint.evidence_reference.as_deref())
10504            .collect::<std::collections::BTreeSet<_>>();
10505        let resource_evidence_reference_count = self
10506            .resources
10507            .iter()
10508            .filter(|resource| resource.evidence_reference.is_some())
10509            .count();
10510        let unique_resource_evidence_references = self
10511            .resources
10512            .iter()
10513            .filter_map(|resource| resource.evidence_reference.as_deref())
10514            .collect::<std::collections::BTreeSet<_>>();
10515        let response_bytes = checkpoint_response_bytes
10516            .zip(resource_response_bytes)
10517            .and_then(|(checkpoints, resources)| checkpoints.checked_add(resources))
10518            .and_then(|existing| {
10519                existing.checked_add(self.login.as_ref().map_or(0, |login| login.response_bytes))
10520            });
10521        let login_dispatched_requests = self.login.as_ref().map_or(0_usize, |login| {
10522            usize::from(login.page_dispatched) + usize::from(login.submit_dispatched)
10523        });
10524        let v3_epoch_transition_is_valid = match (self.schema, self.login.as_ref()) {
10525            (SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA, Some(login)) => {
10526                let startup_authenticated = self.checkpoints.first().is_some_and(|checkpoint| {
10527                    checkpoint.phase == "startup"
10528                        && checkpoint.outcome == "healthy"
10529                        && checkpoint.predicate == "matched"
10530                });
10531                login.final_epoch == 1
10532                    || !startup_authenticated
10533                    || (matches!(
10534                        self.outcome,
10535                        "credential_update_required" | "credential_update_unusable"
10536                    ) && self.checkpoints.len() == 1
10537                        && dispatched_resources == 0
10538                        && self
10539                            .resources
10540                            .iter()
10541                            .all(|resource| resource.outcome == "not_dispatched"))
10542            },
10543            (SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA, None) => false,
10544            _ => true,
10545        };
10546        let cookie_contract_is_valid = match (
10547            self.schema,
10548            self.credential_mechanism,
10549            self.credential_acquisition,
10550            self.cookie_policy.as_ref(),
10551            self.cookie_lifecycle.as_ref(),
10552            self.login.as_ref(),
10553        ) {
10554            (SUPPLIED_SESSION_AUDIT_SCHEMA, "authorization_header", None, None, None, None) => {
10555                !matches!(
10556                    self.outcome,
10557                    "credential_update_required"
10558                        | "credential_update_unusable"
10559                        | "login_form_unavailable"
10560                        | "login_submit_unavailable"
10561                        | "login_cookie_unavailable"
10562                )
10563            },
10564            (
10565                SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA,
10566                "cookie_jar",
10567                None,
10568                Some(policy),
10569                Some(lifecycle),
10570                None,
10571            ) => {
10572                let declared = usize::from(policy.declared_count);
10573                let dispatched_requests = usize::from(self.dispatched_request_count);
10574                (1..=MAX_SUPPLIED_SESSION_COOKIES).contains(&declared)
10575                    && usize::from(policy.host_only_count)
10576                        .checked_add(usize::from(policy.domain_count))
10577                        == Some(declared)
10578                    && usize::from(policy.secure_count) <= declared
10579                    && usize::from(policy.same_site_none_count) <= usize::from(policy.secure_count)
10580                    && usize::from(policy.http_only_count) <= declared
10581                    && usize::from(policy.session_count)
10582                        .checked_add(usize::from(policy.persistent_count))
10583                        == Some(declared)
10584                    && usize::from(policy.same_site_missing_count)
10585                        .checked_add(usize::from(policy.same_site_strict_count))
10586                        .and_then(|count| {
10587                            count.checked_add(usize::from(policy.same_site_lax_count))
10588                        })
10589                        .and_then(|count| {
10590                            count.checked_add(usize::from(policy.same_site_none_count))
10591                        })
10592                        == Some(declared)
10593                    && policy.update_policy == "stop_on_selected_cookie"
10594                    && policy.browser_semantics == "attributes_preserved_not_browser_csrf_emulation"
10595                    && lifecycle.initial_epoch == 1
10596                    && lifecycle.final_epoch == 1
10597                    && usize::from(lifecycle.selected_update_response_count) <= dispatched_requests
10598                    && usize::from(lifecycle.unselected_update_response_count)
10599                        .checked_add(usize::from(lifecycle.update_classification_failure_count))
10600                        .is_some_and(|count| count <= dispatched_requests)
10601                    && lifecycle.updates_applied == 0
10602                    && match self.outcome {
10603                        "credential_update_required" => {
10604                            lifecycle.selected_update_response_count == 1
10605                                && lifecycle.update_classification_failure_count == 0
10606                        },
10607                        "credential_update_unusable" => {
10608                            lifecycle.selected_update_response_count == 0
10609                                && lifecycle.update_classification_failure_count == 1
10610                        },
10611                        "login_form_unavailable"
10612                        | "login_submit_unavailable"
10613                        | "login_cookie_unavailable" => false,
10614                        _ => {
10615                            lifecycle.selected_update_response_count == 0
10616                                && lifecycle.update_classification_failure_count == 0
10617                        },
10618                    }
10619            },
10620            (
10621                SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA,
10622                "cookie_jar",
10623                Some("bounded_form_login"),
10624                Some(policy),
10625                Some(lifecycle),
10626                Some(login),
10627            ) => {
10628                let declared = usize::from(policy.declared_count);
10629                let dispatched_requests = usize::from(self.dispatched_request_count);
10630                let update_eligible_requests =
10631                    dispatched_requests.checked_sub(login_dispatched_requests);
10632                (1..=MAX_SUPPLIED_SESSION_COOKIES).contains(&declared)
10633                    && usize::from(policy.host_only_count) == declared
10634                    && policy.domain_count == 0
10635                    && (usize::from(policy.secure_count) == 0
10636                        || usize::from(policy.secure_count) == declared)
10637                    && usize::from(policy.http_only_count) == declared
10638                    && usize::from(policy.session_count) == declared
10639                    && policy.persistent_count == 0
10640                    && policy.same_site_missing_count == 0
10641                    && usize::from(policy.same_site_strict_count)
10642                        .checked_add(usize::from(policy.same_site_lax_count))
10643                        == Some(declared)
10644                    && policy.same_site_none_count == 0
10645                    && policy.update_policy == "stop_on_selected_cookie"
10646                    && policy.browser_semantics == "attributes_preserved_not_browser_csrf_emulation"
10647                    && lifecycle.initial_epoch == 0
10648                    && lifecycle.final_epoch == login.final_epoch
10649                    && update_eligible_requests.is_some_and(|eligible| {
10650                        usize::from(lifecycle.selected_update_response_count) <= eligible
10651                            && usize::from(lifecycle.unselected_update_response_count)
10652                                .checked_add(usize::from(
10653                                    lifecycle.update_classification_failure_count,
10654                                ))
10655                                .is_some_and(|count| count <= eligible)
10656                    })
10657                    && lifecycle.updates_applied == 0
10658                    && login
10659                        .validate(self.outcome, self.checkpoints.first())
10660                        .is_ok()
10661                    && (login.submit_outcome != "cookie_acquired"
10662                        || login.acquired_cookie_count == policy.declared_count)
10663                    && match self.outcome {
10664                        "credential_update_required" => {
10665                            lifecycle.selected_update_response_count == 1
10666                                && lifecycle.update_classification_failure_count == 0
10667                        },
10668                        "credential_update_unusable" => {
10669                            lifecycle.selected_update_response_count == 0
10670                                && lifecycle.update_classification_failure_count == 1
10671                        },
10672                        _ => {
10673                            lifecycle.selected_update_response_count == 0
10674                                && lifecycle.update_classification_failure_count == 0
10675                        },
10676                    }
10677            },
10678            _ => false,
10679        };
10680        if !cookie_contract_is_valid
10681            || !v3_epoch_transition_is_valid
10682            || self.capability_id != SUPPLIED_SESSION_CAPABILITY_ID
10683            || !valid_supplied_session_reference(
10684                &self.policy_reference,
10685                "supplied-session-policy-sha256:",
10686            )
10687            || !valid_supplied_session_reference(
10688                &self.application_reference,
10689                "supplied-session-application-sha256:",
10690            )
10691            || self.principal_reference != "supplied-session-principal-0001"
10692            || !valid_supplied_session_principal_alias(&self.principal_alias)
10693            || self.principal_assurance != "operator_declared"
10694            || self.health_oracle.kind != "json_boolean_true"
10695            || !valid_supplied_session_reference(
10696                &self.health_oracle.field_reference,
10697                "supplied-session-health-field-sha256:",
10698            )
10699            || self.resources.is_empty()
10700            || self.resources.len() > MAX_SUPPLIED_SESSION_RESOURCES
10701            || (self.schema == SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA
10702                && self.resources.len() > MAX_SUPPLIED_SESSION_RESOURCES.saturating_sub(1))
10703            || self.checkpoints.len() > MAX_SUPPLIED_SESSION_CHECKPOINTS
10704            || usize::from(self.selected_resource_count) != self.resources.len()
10705            || usize::from(self.dispatched_resource_count) != dispatched_resources
10706            || usize::from(self.committed_resource_count) != committed_resources
10707            || usize::from(self.dispatched_request_count)
10708                > usize::from(MAX_SUPPLIED_SESSION_REQUESTS)
10709            || self.response_byte_limit == 0
10710            || self.response_byte_limit > MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES
10711            || self.response_byte_limit_exceeded != (self.response_bytes > self.response_byte_limit)
10712            || usize::from(self.dispatched_request_count)
10713                != self
10714                    .checkpoints
10715                    .len()
10716                    .saturating_add(dispatched_resources)
10717                    .saturating_add(login_dispatched_requests)
10718            || response_bytes != Some(self.response_bytes)
10719            || unique_resource_references.len() != self.resources.len()
10720            || unique_checkpoint_evidence_references.len() != checkpoint_evidence_reference_count
10721            || unique_resource_evidence_references.len() != resource_evidence_reference_count
10722            || self.refresh_performed
10723            || self.anonymous_fallback_performed
10724            || self.continuous_authentication_established
10725            || !matches!(
10726                self.outcome,
10727                "complete"
10728                    | "startup_unhealthy"
10729                    | "session_lost"
10730                    | "resource_unavailable"
10731                    | "runtime_limit"
10732                    | "cancelled"
10733                    | "credential_update_required"
10734                    | "credential_update_unusable"
10735                    | "login_form_unavailable"
10736                    | "login_submit_unavailable"
10737                    | "login_cookie_unavailable"
10738            )
10739            || !matches!(self.coverage, "complete" | "partial" | "none")
10740            || (self.coverage == "complete" && self.outcome != "complete")
10741            || (matches!(
10742                self.outcome,
10743                "credential_update_required"
10744                    | "credential_update_unusable"
10745                    | "login_form_unavailable"
10746                    | "login_submit_unavailable"
10747                    | "login_cookie_unavailable"
10748            ) && self.coverage == "complete")
10749            || self.exploit_execution != "not_performed"
10750            || self.impact_validation != "not_performed"
10751            || items
10752                .iter()
10753                .any(|item| item.capability_id == SUPPLIED_SESSION_CAPABILITY_ID)
10754        {
10755            return Err(ReportError::Serialization);
10756        }
10757        for (index, checkpoint) in self.checkpoints.iter().enumerate() {
10758            let expected_phase = if index == 0 {
10759                "startup"
10760            } else if index == usize::from(self.selected_resource_count) {
10761                "terminal"
10762            } else {
10763                "subject_boundary"
10764            };
10765            if usize::from(checkpoint.sequence) != index
10766                || usize::from(checkpoint.after_subject_count) != index
10767                || checkpoint.phase != expected_phase
10768                || checkpoint
10769                    .evidence_reference
10770                    .as_deref()
10771                    .is_some_and(|reference| {
10772                        !valid_supplied_session_reference(
10773                            reference,
10774                            "supplied-session-checkpoint-evidence-sha256:",
10775                        )
10776                    })
10777                || (checkpoint.outcome != "indeterminate"
10778                    && checkpoint.evidence_reference.is_none())
10779            {
10780                return Err(ReportError::Serialization);
10781            }
10782        }
10783        for (index, resource) in self.resources.iter().enumerate() {
10784            if usize::from(resource.sequence) != index
10785                || resource.epoch != 1
10786                || !valid_supplied_session_reference(
10787                    &resource.resource_reference,
10788                    "supplied-session-resource-sha256:",
10789                )
10790                || (matches!(resource.outcome, "committed" | "health_unqualified")
10791                    != resource
10792                        .evidence_reference
10793                        .as_deref()
10794                        .is_some_and(|reference| {
10795                            valid_supplied_session_reference(
10796                                reference,
10797                                "supplied-session-resource-evidence-sha256:",
10798                            )
10799                        }))
10800            {
10801                return Err(ReportError::Serialization);
10802            }
10803        }
10804        Ok(())
10805    }
10806
10807    fn metadata(&self) -> Vec<(&'static str, String)> {
10808        let mut metadata = vec![
10809            ("Audit schema", self.schema.to_owned()),
10810            ("Capability", self.capability_id.to_owned()),
10811            ("Policy reference", self.policy_reference.clone()),
10812            ("Application reference", self.application_reference.clone()),
10813            ("Principal reference", self.principal_reference.clone()),
10814            ("Principal alias", self.principal_alias.clone()),
10815            ("Principal assurance", self.principal_assurance.to_owned()),
10816            ("Credential mechanism", self.credential_mechanism.to_owned()),
10817            ("Health oracle", self.health_oracle.kind.to_owned()),
10818            (
10819                "Health field reference",
10820                self.health_oracle.field_reference.clone(),
10821            ),
10822            ("Outcome", self.outcome.to_owned()),
10823            ("Coverage", self.coverage.to_owned()),
10824            (
10825                "Selected resources",
10826                self.selected_resource_count.to_string(),
10827            ),
10828            (
10829                "Dispatched resources",
10830                self.dispatched_resource_count.to_string(),
10831            ),
10832            (
10833                "Committed resources",
10834                self.committed_resource_count.to_string(),
10835            ),
10836            (
10837                "Dispatched requests",
10838                self.dispatched_request_count.to_string(),
10839            ),
10840            ("Response bytes", self.response_bytes.to_string()),
10841            ("Response byte limit", self.response_byte_limit.to_string()),
10842            (
10843                "Response byte limit exceeded",
10844                self.response_byte_limit_exceeded.to_string(),
10845            ),
10846            ("Refresh performed", self.refresh_performed.to_string()),
10847            (
10848                "Anonymous fallback performed",
10849                self.anonymous_fallback_performed.to_string(),
10850            ),
10851            (
10852                "Continuous authentication established",
10853                self.continuous_authentication_established.to_string(),
10854            ),
10855            ("Exploit execution", self.exploit_execution.to_owned()),
10856            ("Impact validation", self.impact_validation.to_owned()),
10857        ];
10858        if let Some(acquisition) = self.credential_acquisition {
10859            metadata.push(("Credential acquisition", acquisition.to_owned()));
10860        }
10861        if let Some(authorization) = self.oauth_authorization.as_ref() {
10862            metadata.extend([
10863                ("OAuth authorization profile", "loopback-form-post-state/v1".to_owned()),
10864                ("OAuth authorization policy reference", authorization.authorization_policy_reference().to_owned()),
10865                ("OAuth callback outcome", format!("{:?}", authorization.outcome())),
10866                ("OAuth callback reception reservations (not outgoing HTTP)", authorization.reservation_count().to_string()),
10867                ("OAuth accepted loopback connections", authorization.accepted_connection_count().to_string()),
10868                ("OAuth committed state/issuer-qualified callbacks", authorization.callback_commit_count().to_string()),
10869                ("OAuth recorded state validation", authorization.state_validated().to_string()),
10870                ("OAuth recorded issuer validation", authorization.issuer_validated().to_string()),
10871                ("OAuth inbound framing and form bytes (charged to parent)", authorization.ingress_bytes().to_string()),
10872                ("OAuth authorization assurance", "Selected policy requires client-generated S256/state and exact issuer/single-use callback binding; only recorded validation and commit counts establish completed checks. These facts are not browser/sender authentication or remote PKCE enforcement. Authorization wait is bounded separately under the same immutable parent deadline. No OIDC nonce, identity or token signature validation.".to_owned()),
10873            ]);
10874        }
10875        if let Some(exchange) = self.oauth_token_exchange.as_ref() {
10876            metadata.extend([
10877                ("OAuth token exchange profile", "client-initiated-public-code-s256-bearer/v1".to_owned()),
10878                ("OAuth IdP reference", exchange.idp_reference().to_owned()),
10879                ("OAuth token exchange outcome", exchange.outcome().as_str().to_owned()),
10880                ("OAuth outgoing token attempts", exchange.request_attempt_count().to_string()),
10881                ("OAuth admitted token requests", exchange.request_admitted_count().to_string()),
10882                ("OAuth committed credentials", exchange.credential_commit_count().to_string()),
10883                ("OAuth token response bytes", exchange.response_bytes().to_string()),
10884                ("OAuth token meaning", "Opaque Bearer credential; protected health and resource coverage are qualified separately, not OIDC identity.".to_owned()),
10885            ]);
10886        }
10887        if let Some(code) = self.oauth_code.as_ref() {
10888            metadata.extend([
10889                ("OAuth code profile", "external-public-code-s256-bearer/v1".to_owned()),
10890                ("OAuth IdP reference", code.idp_reference().to_owned()),
10891                ("OAuth acquisition outcome", code.outcome().as_str().to_owned()),
10892                ("OAuth attempted dispatches", code.request_attempt_count().to_string()),
10893                ("OAuth admitted requests", code.request_admitted_count().to_string()),
10894                ("OAuth complete responses", code.response_completed_count().to_string()),
10895                ("OAuth committed credentials", code.credential_commit_count().to_string()),
10896                ("OAuth response bytes", code.response_bytes().to_string()),
10897                ("OAuth declared token lifetime seconds", code.declared_token_lifetime_seconds()
10898                    .map_or_else(|| "not_established".to_owned(), |seconds| seconds.to_string())),
10899                ("OAuth transaction assurance", "operator_declared_external_transaction; original S256 enforcement and front-channel CSRF not established".to_owned()),
10900                ("OAuth token meaning", "opaque Bearer credential, not OIDC identity; target health and coverage qualified separately".to_owned()),
10901                ("OAuth initiation / callback / state-nonce / OIDC / signature verification", "not_performed".to_owned()),
10902            ]);
10903        }
10904        if let Some(refresh) = self.oauth_refresh.as_ref() {
10905            metadata.extend([
10906                ("OAuth renewal profile", "scheduled-public-bearer-refresh/v1".to_owned()),
10907                ("OAuth renewal policy reference", refresh.refresh_policy_reference().to_owned()),
10908                ("OAuth renewal outcome", refresh.outcome().as_str().to_owned()),
10909                ("OAuth renewal attempted dispatches", refresh.request_attempt_count().to_string()),
10910                ("OAuth renewal admitted requests", refresh.request_admitted_count().to_string()),
10911                ("OAuth renewal complete responses", refresh.response_completed_count().to_string()),
10912                ("OAuth renewal committed credentials", refresh.credential_commit_count().to_string()),
10913                ("OAuth renewal response bytes", refresh.response_bytes().to_string()),
10914                ("OAuth renewal initial epoch", refresh.initial_epoch().to_string()),
10915                ("OAuth renewal final epoch", refresh.final_epoch().to_string()),
10916                ("OAuth replacement refresh token returned", refresh.replacement_refresh_token_received()
10917                    .map_or_else(|| "not_established".to_owned(), |value| value.to_string())),
10918                ("OAuth replacement value differs", refresh.replacement_value_differs()
10919                    .map_or_else(|| "not_established".to_owned(), |value| value.to_string())),
10920                ("Health observations (common plus renewal)",
10921                    (self.checkpoints.len() + usize::from(refresh.health().is_some())).to_string()),
10922                ("OAuth renewal assurance", "Returned value inequality is a local observation, not server revocation or replay-detection proof; no OIDC identity, signature, or front-channel verification is established.".to_owned()),
10923            ]);
10924        }
10925        if let Some(policy) = self.cookie_policy.as_ref() {
10926            metadata.extend([
10927                ("Declared cookies", policy.declared_count.to_string()),
10928                ("Host-only cookies", policy.host_only_count.to_string()),
10929                ("Domain cookies", policy.domain_count.to_string()),
10930                ("Secure cookies", policy.secure_count.to_string()),
10931                ("HttpOnly cookies", policy.http_only_count.to_string()),
10932                ("Session cookies", policy.session_count.to_string()),
10933                ("Persistent cookies", policy.persistent_count.to_string()),
10934                (
10935                    "SameSite missing cookies",
10936                    policy.same_site_missing_count.to_string(),
10937                ),
10938                (
10939                    "SameSite strict cookies",
10940                    policy.same_site_strict_count.to_string(),
10941                ),
10942                (
10943                    "SameSite lax cookies",
10944                    policy.same_site_lax_count.to_string(),
10945                ),
10946                (
10947                    "SameSite none cookies",
10948                    policy.same_site_none_count.to_string(),
10949                ),
10950                ("Cookie update policy", policy.update_policy.to_owned()),
10951                ("Browser semantics", policy.browser_semantics.to_owned()),
10952            ]);
10953        }
10954        if let Some(lifecycle) = self.cookie_lifecycle.as_ref() {
10955            metadata.extend([
10956                ("Initial session epoch", lifecycle.initial_epoch.to_string()),
10957                ("Final session epoch", lifecycle.final_epoch.to_string()),
10958                (
10959                    "Selected cookie-update responses",
10960                    lifecycle.selected_update_response_count.to_string(),
10961                ),
10962                (
10963                    "Unselected cookie-update responses",
10964                    lifecycle.unselected_update_response_count.to_string(),
10965                ),
10966                (
10967                    "Cookie-update classification failures",
10968                    lifecycle.update_classification_failure_count.to_string(),
10969                ),
10970                (
10971                    "Cookie updates applied",
10972                    lifecycle.updates_applied.to_string(),
10973                ),
10974            ]);
10975        }
10976        if let Some(renewal) = self.renewal.as_ref() {
10977            metadata.extend([
10978                ("Renewal reference", renewal.renewal_reference.clone()),
10979                (
10980                    "Renewal after resource count",
10981                    renewal.after_resource_count.to_string(),
10982                ),
10983                ("Renewal attempt count", renewal.attempt_count.to_string()),
10984                ("Renewal response bytes", renewal.response_bytes.to_string()),
10985                (
10986                    "Renewal applied cookie count",
10987                    renewal.applied_cookie_count.to_string(),
10988                ),
10989                ("Renewal initial epoch", renewal.initial_epoch.to_string()),
10990                ("Renewal final epoch", renewal.final_epoch.to_string()),
10991                (
10992                    "Renewal health",
10993                    renewal
10994                        .health
10995                        .as_ref()
10996                        .map_or("not_dispatched", |health| health.outcome)
10997                        .to_owned(),
10998                ),
10999            ]);
11000        }
11001        if let Some(login) = self.login.as_ref() {
11002            metadata.extend([
11003                ("Login reference", login.login_reference.clone()),
11004                ("Login attempt limit", login.max_attempts.to_string()),
11005                ("Login attempt count", login.attempt_count.to_string()),
11006                ("Login page dispatched", login.page_dispatched.to_string()),
11007                (
11008                    "Login page status",
11009                    login
11010                        .page_status
11011                        .map_or_else(|| "none".to_owned(), |value| value.to_string()),
11012                ),
11013                ("Login page body state", login.page_body_state.to_owned()),
11014                ("Login form outcome", login.form_outcome.to_owned()),
11015                (
11016                    "Login form error code",
11017                    login.form_error_code.unwrap_or("none").to_owned(),
11018                ),
11019                (
11020                    "Login submit dispatched",
11021                    login.submit_dispatched.to_string(),
11022                ),
11023                (
11024                    "Login submit status",
11025                    login
11026                        .submit_status
11027                        .map_or_else(|| "none".to_owned(), |value| value.to_string()),
11028                ),
11029                (
11030                    "Login submit body state",
11031                    login.submit_body_state.to_owned(),
11032                ),
11033                ("Login submit outcome", login.submit_outcome.to_owned()),
11034                (
11035                    "Acquired cookie count",
11036                    login.acquired_cookie_count.to_string(),
11037                ),
11038                ("Login response bytes", login.response_bytes.to_string()),
11039                ("Login initial epoch", login.initial_epoch.to_string()),
11040                ("Login final epoch", login.final_epoch.to_string()),
11041                (
11042                    "Pre-session cookie applied",
11043                    login.pre_session_cookie_applied.to_string(),
11044                ),
11045            ]);
11046        }
11047        metadata
11048    }
11049
11050    fn wire_json(&self) -> Result<String, ReportError> {
11051        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
11052    }
11053}
11054
11055#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11056fn valid_supplied_session_reference(value: &str, prefix: &str) -> bool {
11057    value.len() == prefix.len() + 64
11058        && value.starts_with(prefix)
11059        && value[prefix.len()..]
11060            .bytes()
11061            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
11062}
11063
11064#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11065fn valid_supplied_session_principal_alias(value: &str) -> bool {
11066    !value.is_empty()
11067        && value.len() <= 128
11068        && value.bytes().all(|byte| {
11069            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'@' | b'-')
11070        })
11071}
11072
11073#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11074const fn supplied_session_principal_assurance_token(
11075    value: SuppliedSessionPrincipalAssurance,
11076) -> &'static str {
11077    match value {
11078        SuppliedSessionPrincipalAssurance::OperatorDeclared => "operator_declared",
11079    }
11080}
11081
11082#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11083const fn supplied_session_credential_mechanism_token(
11084    value: SuppliedSessionCredentialMechanism,
11085) -> &'static str {
11086    match value {
11087        SuppliedSessionCredentialMechanism::AuthorizationHeader => "authorization_header",
11088        SuppliedSessionCredentialMechanism::CookieJar => "cookie_jar",
11089    }
11090}
11091
11092#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11093const fn supplied_session_credential_acquisition_token(
11094    value: SuppliedSessionCredentialAcquisition,
11095) -> &'static str {
11096    value.as_str()
11097}
11098
11099#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11100const fn supplied_session_health_oracle_kind_token(
11101    value: SuppliedSessionHealthOracleKind,
11102) -> &'static str {
11103    match value {
11104        SuppliedSessionHealthOracleKind::JsonBooleanTrue => "json_boolean_true",
11105    }
11106}
11107
11108#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11109const fn supplied_session_audit_outcome_token(value: SuppliedSessionAuditOutcome) -> &'static str {
11110    match value {
11111        SuppliedSessionAuditOutcome::Complete => "complete",
11112        SuppliedSessionAuditOutcome::StartupUnhealthy => "startup_unhealthy",
11113        SuppliedSessionAuditOutcome::SessionLost => "session_lost",
11114        SuppliedSessionAuditOutcome::ResourceUnavailable => "resource_unavailable",
11115        SuppliedSessionAuditOutcome::RuntimeLimit => "runtime_limit",
11116        SuppliedSessionAuditOutcome::Cancelled => "cancelled",
11117        SuppliedSessionAuditOutcome::CredentialUpdateRequired => "credential_update_required",
11118        SuppliedSessionAuditOutcome::CredentialUpdateUnusable => "credential_update_unusable",
11119        SuppliedSessionAuditOutcome::LoginFormUnavailable => "login_form_unavailable",
11120        SuppliedSessionAuditOutcome::LoginSubmitUnavailable => "login_submit_unavailable",
11121        SuppliedSessionAuditOutcome::LoginCookieUnavailable => "login_cookie_unavailable",
11122        SuppliedSessionAuditOutcome::RenewalUnavailable => "renewal_unavailable",
11123        SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable => {
11124            "oauth_code_exchange_unavailable"
11125        },
11126        SuppliedSessionAuditOutcome::OAuthRefreshUnavailable => "oauth_refresh_unavailable",
11127    }
11128}
11129
11130#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11131const fn supplied_session_login_form_outcome_token(
11132    value: SuppliedSessionLoginFormOutcome,
11133) -> &'static str {
11134    match value {
11135        SuppliedSessionLoginFormOutcome::Matched => "matched",
11136        SuppliedSessionLoginFormOutcome::IneligibleResponse => "ineligible_response",
11137        SuppliedSessionLoginFormOutcome::Missing => "missing",
11138        SuppliedSessionLoginFormOutcome::Ambiguous => "ambiguous",
11139        SuppliedSessionLoginFormOutcome::Invalid => "invalid",
11140        SuppliedSessionLoginFormOutcome::TransportFailed => "transport_failed",
11141        SuppliedSessionLoginFormOutcome::RuntimeLimit => "runtime_limit",
11142        SuppliedSessionLoginFormOutcome::Cancelled => "cancelled",
11143        SuppliedSessionLoginFormOutcome::NotEvaluated => "not_evaluated",
11144    }
11145}
11146
11147#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11148const fn supplied_session_login_submit_outcome_token(
11149    value: SuppliedSessionLoginSubmitOutcome,
11150) -> &'static str {
11151    match value {
11152        SuppliedSessionLoginSubmitOutcome::CookieAcquired => "cookie_acquired",
11153        SuppliedSessionLoginSubmitOutcome::CookieUnavailable => "cookie_unavailable",
11154        SuppliedSessionLoginSubmitOutcome::HttpError => "http_error",
11155        SuppliedSessionLoginSubmitOutcome::RedirectRefused => "redirect_refused",
11156        SuppliedSessionLoginSubmitOutcome::Incomplete => "incomplete",
11157        SuppliedSessionLoginSubmitOutcome::TransportFailed => "transport_failed",
11158        SuppliedSessionLoginSubmitOutcome::RuntimeLimit => "runtime_limit",
11159        SuppliedSessionLoginSubmitOutcome::Cancelled => "cancelled",
11160        SuppliedSessionLoginSubmitOutcome::NotDispatched => "not_dispatched",
11161    }
11162}
11163
11164#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11165const fn supplied_session_coverage_token(value: SuppliedSessionCoverage) -> &'static str {
11166    match value {
11167        SuppliedSessionCoverage::Complete => "complete",
11168        SuppliedSessionCoverage::Partial => "partial",
11169        SuppliedSessionCoverage::None => "none",
11170    }
11171}
11172
11173#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11174const fn supplied_session_checkpoint_phase_token(
11175    value: SuppliedSessionHealthCheckpointPhase,
11176) -> &'static str {
11177    match value {
11178        SuppliedSessionHealthCheckpointPhase::Startup => "startup",
11179        SuppliedSessionHealthCheckpointPhase::SubjectBoundary => "subject_boundary",
11180        SuppliedSessionHealthCheckpointPhase::Terminal => "terminal",
11181        SuppliedSessionHealthCheckpointPhase::Renewal => "renewal",
11182    }
11183}
11184
11185#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11186const fn supplied_session_health_outcome_token(
11187    value: SuppliedSessionHealthOutcome,
11188) -> &'static str {
11189    match value {
11190        SuppliedSessionHealthOutcome::Healthy => "healthy",
11191        SuppliedSessionHealthOutcome::Unhealthy => "unhealthy",
11192        SuppliedSessionHealthOutcome::Indeterminate => "indeterminate",
11193    }
11194}
11195
11196#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11197const fn supplied_session_body_state_token(value: SuppliedSessionBodyState) -> &'static str {
11198    match value {
11199        SuppliedSessionBodyState::Complete => "complete",
11200        SuppliedSessionBodyState::Incomplete => "incomplete",
11201        SuppliedSessionBodyState::Unavailable => "unavailable",
11202    }
11203}
11204
11205#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11206const fn supplied_session_predicate_outcome_token(
11207    value: SuppliedSessionPredicateOutcome,
11208) -> &'static str {
11209    match value {
11210        SuppliedSessionPredicateOutcome::Matched => "matched",
11211        SuppliedSessionPredicateOutcome::NotMatched => "not_matched",
11212        SuppliedSessionPredicateOutcome::NotEvaluated => "not_evaluated",
11213    }
11214}
11215
11216#[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
11217const fn supplied_session_resource_outcome_token(
11218    value: SuppliedSessionResourceOutcome,
11219) -> &'static str {
11220    match value {
11221        SuppliedSessionResourceOutcome::Committed => "committed",
11222        SuppliedSessionResourceOutcome::HealthUnqualified => "health_unqualified",
11223        SuppliedSessionResourceOutcome::HttpError => "http_error",
11224        SuppliedSessionResourceOutcome::RedirectRefused => "redirect_refused",
11225        SuppliedSessionResourceOutcome::Incomplete => "incomplete",
11226        SuppliedSessionResourceOutcome::TransportFailed => "transport_failed",
11227        SuppliedSessionResourceOutcome::NotDispatched => "not_dispatched",
11228    }
11229}
11230
11231#[cfg(all(feature = "scanning", feature = "authorization-review"))]
11232#[derive(Serialize)]
11233struct AssessmentAuthorizationAuditDocument {
11234    schema: &'static str,
11235    capability_id: &'static str,
11236    policy_id: String,
11237    selected_path_count: u8,
11238    ignored_path_count: u8,
11239    request_count: u8,
11240    outcome: &'static str,
11241    primary_stable: Option<bool>,
11242    peer_stable: Option<bool>,
11243    cross_resources_equivalent: Option<bool>,
11244    item_projected: bool,
11245}
11246
11247#[cfg(all(feature = "scanning", feature = "authorization-review"))]
11248impl AssessmentAuthorizationAuditDocument {
11249    fn from_audit(audit: &crate::web_runtime::WebAssessmentAuthorizationAudit) -> Self {
11250        Self {
11251            schema: "security.authorization-review-audit/v1",
11252            capability_id: RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID,
11253            policy_id: audit.policy_id().to_string(),
11254            selected_path_count: audit.selected_path_count(),
11255            ignored_path_count: audit.ignored_path_count(),
11256            request_count: audit.request_count(),
11257            outcome: authorization_review_outcome_token(audit.outcome()),
11258            primary_stable: audit.primary_stable(),
11259            peer_stable: audit.peer_stable(),
11260            cross_resources_equivalent: audit.cross_resources_equivalent(),
11261            item_projected: audit.item_projected(),
11262        }
11263    }
11264
11265    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
11266        let projected_count = items
11267            .iter()
11268            .filter(|item| item.capability_id == RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID)
11269            .count();
11270        let positive = self.outcome == "stable_cross_principal_equivalence";
11271        if self.schema != "security.authorization-review-audit/v1"
11272            || self.capability_id != RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID
11273            || !self.policy_id.starts_with("authorization-policy-sha256:")
11274            || self.selected_path_count == 0
11275            || usize::from(self.selected_path_count) > HARD_MAX_AUTHORIZATION_REVIEW_SELECTED_PATHS
11276            || usize::from(self.ignored_path_count) > HARD_MAX_AUTHORIZATION_REVIEW_IGNORED_PATHS
11277            || usize::from(self.request_count) > MAX_AUTHORIZATION_REVIEW_REQUESTS
11278            || projected_count > 1
11279            || self.item_projected != (projected_count == 1)
11280            || positive != self.item_projected
11281            || (positive && usize::from(self.request_count) != MAX_AUTHORIZATION_REVIEW_REQUESTS)
11282            || (positive
11283                && (self.primary_stable != Some(true)
11284                    || self.peer_stable != Some(true)
11285                    || self.cross_resources_equivalent != Some(true)))
11286        {
11287            return Err(ReportError::Serialization);
11288        }
11289        Ok(())
11290    }
11291
11292    fn metadata(&self) -> [(&'static str, String); 11] {
11293        [
11294            ("Audit schema", self.schema.to_owned()),
11295            ("Capability", self.capability_id.to_owned()),
11296            ("Policy ID", self.policy_id.clone()),
11297            ("Selected path count", self.selected_path_count.to_string()),
11298            ("Ignored path count", self.ignored_path_count.to_string()),
11299            ("Request count", self.request_count.to_string()),
11300            ("Outcome", self.outcome.to_owned()),
11301            ("Primary stable", optional_bool_token(self.primary_stable)),
11302            ("Peer stable", optional_bool_token(self.peer_stable)),
11303            (
11304                "Cross resources equivalent",
11305                optional_bool_token(self.cross_resources_equivalent),
11306            ),
11307            ("Item projected", self.item_projected.to_string()),
11308        ]
11309    }
11310}
11311
11312#[cfg(all(feature = "scanning", feature = "websocket-review"))]
11313const WEBSOCKET_REVIEW_PHYSICAL_FRAME_COUNT: &str = "unavailable";
11314#[cfg(all(feature = "scanning", feature = "websocket-review"))]
11315const WEBSOCKET_REVIEW_MAX_FRAME_SIZE: &str = "bounded_by_max_inbound_message_bytes";
11316#[cfg(all(feature = "scanning", feature = "websocket-review"))]
11317const WEBSOCKET_REVIEW_TRANSPORT_BYTE_SCOPE: &str = "socket_wire_bytes_tls_ciphertext_when_wss";
11318#[cfg(all(feature = "scanning", feature = "websocket-review"))]
11319const WEBSOCKET_REVIEW_CLAIM_LIMITS: [&str; 8] = [
11320    "browser_origin_security_not_established",
11321    "authentication_not_established",
11322    "authorization_not_established",
11323    "availability_not_established",
11324    "vulnerability_not_established",
11325    "exploitability_not_established",
11326    "impact_not_established",
11327    "source_authenticity_not_established",
11328];
11329#[cfg(all(feature = "scanning", feature = "websocket-review"))]
11330const WEBSOCKET_SUPPLIED_SESSION_CLAIM_LIMITS: [&str; 9] = [
11331    "browser_origin_security_not_established",
11332    "authentication_not_established",
11333    "authorization_not_established",
11334    "availability_not_established",
11335    "vulnerability_not_established",
11336    "exploitability_not_established",
11337    "impact_not_established",
11338    "source_authenticity_not_established",
11339    "continuous_authentication_not_established",
11340];
11341
11342#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11343const XML_EXTERNAL_ENTITY_REVIEW_CLAIM_LIMITS: [&str; 7] = [
11344    "not_confirmed_xxe",
11345    "file_read_not_performed",
11346    "data_exfiltration_not_performed",
11347    "internal_network_access_not_performed",
11348    "parser_identity_not_established",
11349    "impact_validation_not_performed",
11350    "source_authentication_not_established",
11351];
11352#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11353const XML_EXTERNAL_ENTITY_REVIEW_TITLE: &str =
11354    "Repeated correlated outbound interactions after XML inputs";
11355#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11356const XML_EXTERNAL_ENTITY_REVIEW_CATEGORY: &str = "XML external resource resolution";
11357#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11358const XML_EXTERNAL_ENTITY_REVIEW_SUMMARY: &str = "Two independently allocated callback targets received distinct correlated HTTP interactions after fixed candidate and replay XML documents; parser identity, semantic effect, exploitability, and impact remain unestablished.";
11359#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11360const XML_EXTERNAL_ENTITY_REVIEW_REMEDIATION_ID: &str =
11361    "web.remediation.xml-external-entity-policy@1";
11362#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11363const XML_EXTERNAL_ENTITY_REVIEW_REMEDIATION_SUMMARY: &str = "Disable unneeded external entity resolution and independently review the exact server-side XML parser policy before treating this observation as a vulnerability.";
11364#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11365const XML_EXTERNAL_ENTITY_REVIEW_CONTROL_EVIDENCE_COUNT: usize = 3;
11366#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11367const XML_EXTERNAL_ENTITY_REVIEW_CANDIDATE_EVIDENCE_COUNT: usize = 5;
11368#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11369const MAX_XML_EXTERNAL_ENTITY_COMPLETE_TARGET_RESPONSE_BYTES: u64 =
11370    XML_EXTERNAL_ENTITY_TARGET_REQUESTS as u64 * MAX_XML_EXTERNAL_ENTITY_TARGET_RESPONSE_BYTES;
11371
11372#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11373#[derive(Serialize)]
11374struct AssessmentXmlExternalEntityReviewAuditDocument {
11375    schema: &'static str,
11376    algorithm: &'static str,
11377    policy: AssessmentXmlExternalEntityPolicyDocument,
11378    outcome: &'static str,
11379    target: AssessmentXmlExternalEntityTargetDocument,
11380    provider: AssessmentXmlExternalEntityProviderDocument,
11381    item_projected: bool,
11382    semantic_effect: &'static str,
11383    impact_validation: &'static str,
11384    claim_limits: Vec<&'static str>,
11385}
11386
11387#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11388#[derive(Serialize)]
11389struct AssessmentXmlExternalEntityPolicyDocument {
11390    id: String,
11391    method: &'static str,
11392    media_type: &'static str,
11393    control_model: &'static str,
11394    execution_mode: &'static str,
11395}
11396
11397#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11398#[derive(Serialize)]
11399struct AssessmentXmlExternalEntityTargetDocument {
11400    request_count: u8,
11401    request_body_bytes: u64,
11402    response_bytes: u64,
11403    complete: bool,
11404    accounting_complete: bool,
11405}
11406
11407#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11408#[derive(Serialize)]
11409struct AssessmentXmlExternalEntityProviderDocument {
11410    request_count: u8,
11411    active_verification_count: u8,
11412    preflight_clean: bool,
11413    control_callback_observed: bool,
11414    candidate_callback_observed: bool,
11415    replay_callback_observed: bool,
11416    callback_targets_distinct: bool,
11417    event_identities_distinct: bool,
11418    cleanup_verified: bool,
11419    complete: bool,
11420}
11421
11422#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11423impl AssessmentXmlExternalEntityReviewAuditDocument {
11424    fn from_audit(audit: &XmlExternalEntityReviewAudit) -> Result<Self, ReportError> {
11425        let document = Self {
11426            schema: audit.schema(),
11427            algorithm: audit.algorithm(),
11428            policy: AssessmentXmlExternalEntityPolicyDocument {
11429                id: audit.policy_id().to_owned(),
11430                method: "post",
11431                media_type: audit.media_type(),
11432                control_model: audit.control_model(),
11433                execution_mode: audit.execution_mode().as_str(),
11434            },
11435            outcome: xml_external_entity_outcome_token(audit.outcome()),
11436            target: AssessmentXmlExternalEntityTargetDocument {
11437                request_count: audit.target_request_count(),
11438                request_body_bytes: audit.target_request_body_bytes(),
11439                response_bytes: audit.target_response_bytes(),
11440                complete: audit.target_complete(),
11441                accounting_complete: audit.target_accounting_complete(),
11442            },
11443            provider: AssessmentXmlExternalEntityProviderDocument {
11444                request_count: audit.provider_request_count(),
11445                active_verification_count: audit.active_verification_count(),
11446                preflight_clean: audit.preflight_clean(),
11447                control_callback_observed: audit.control_callback_observed(),
11448                candidate_callback_observed: audit.candidate_callback_observed(),
11449                replay_callback_observed: audit.replay_callback_observed(),
11450                callback_targets_distinct: audit.callback_targets_distinct(),
11451                event_identities_distinct: audit.event_identities_distinct(),
11452                cleanup_verified: audit.cleanup_verified(),
11453                complete: audit.provider_accounting_complete(),
11454            },
11455            item_projected: audit.item_projected(),
11456            semantic_effect: xml_external_entity_operation_token(audit.semantic_effect()),
11457            impact_validation: xml_external_entity_operation_token(audit.impact_validation()),
11458            claim_limits: {
11459                let limits = audit.claim_limits();
11460                if limits.maximum_disposition() != XmlExternalEntityMaximumDisposition::NeedsReview
11461                    || limits.semantic_effect() != XmlExternalEntityOperationStatus::NotPerformed
11462                    || limits.impact_validation() != XmlExternalEntityOperationStatus::NotPerformed
11463                    || audit.method() != XML_EXTERNAL_ENTITY_REVIEW_METHOD
11464                {
11465                    return Err(ReportError::Serialization);
11466                }
11467                XML_EXTERNAL_ENTITY_REVIEW_CLAIM_LIMITS.to_vec()
11468            },
11469        };
11470        document.validate_wire()?;
11471        Ok(document)
11472    }
11473
11474    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
11475        self.validate_wire()?;
11476        let matching_items = items
11477            .iter()
11478            .filter(|item| item.capability_id == XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID)
11479            .collect::<Vec<_>>();
11480        if matching_items.len() > 1
11481            || self.item_projected != (matching_items.len() == 1)
11482            || matching_items.first().is_some_and(|item| {
11483                item.subject_reference != "subject-0000"
11484                    || item.presentation_target != "assessment_subject"
11485                    || item.title != XML_EXTERNAL_ENTITY_REVIEW_TITLE
11486                    || item.disposition != "needs_review"
11487                    || item.claim_basis != "differential"
11488                    || item.severity.is_some()
11489                    || item.confidence_ppm != 900_000
11490                    || item.redacted_summary != XML_EXTERNAL_ENTITY_REVIEW_SUMMARY
11491                    || item.category != XML_EXTERNAL_ENTITY_REVIEW_CATEGORY
11492                    || item.cwe != Some("CWE-611")
11493                    || item.remediation.id != XML_EXTERNAL_ENTITY_REVIEW_REMEDIATION_ID
11494                    || item.remediation.summary != XML_EXTERNAL_ENTITY_REVIEW_REMEDIATION_SUMMARY
11495                    || !xml_external_entity_evidence_linkage_is_exact(item)
11496            })
11497        {
11498            return Err(ReportError::Serialization);
11499        }
11500        Ok(())
11501    }
11502
11503    fn validate_wire(&self) -> Result<(), ReportError> {
11504        const OUTCOMES: [&str; 13] = [
11505            "not_eligible",
11506            "control_incomplete",
11507            "preflight_contaminated",
11508            "control_callback_observed",
11509            "no_callback",
11510            "candidate_only",
11511            "replay_only",
11512            "correlation_mismatch",
11513            "cleanup_incomplete",
11514            "cancelled",
11515            "budget_exhausted",
11516            "incomplete",
11517            "repeated_external_entity_resolution_observed",
11518        ];
11519        let positive = self.outcome == "repeated_external_entity_resolution_observed";
11520        let no_callbacks = !self.provider.control_callback_observed
11521            && !self.provider.candidate_callback_observed
11522            && !self.provider.replay_callback_observed;
11523        let provider_empty_consistent = self.provider.request_count != 0
11524            || (self.provider.active_verification_count == 0
11525                && !self.provider.preflight_clean
11526                && !self.provider.control_callback_observed
11527                && !self.provider.candidate_callback_observed
11528                && !self.provider.replay_callback_observed
11529                && !self.provider.callback_targets_distinct
11530                && !self.provider.event_identities_distinct
11531                && !self.provider.cleanup_verified
11532                && !self.provider.complete);
11533        let callback_facts_consistent = no_callbacks || self.provider.callback_targets_distinct;
11534        let event_facts_consistent = !self.provider.event_identities_distinct
11535            || (self.provider.callback_targets_distinct
11536                && self.provider.candidate_callback_observed
11537                && self.provider.replay_callback_observed);
11538        let provider_completion_consistent = !self.provider.complete
11539            || (usize::from(self.provider.request_count) >= 9
11540                && self.provider.preflight_clean
11541                && self.provider.callback_targets_distinct);
11542        let target_started_consistent = self.target.request_count == 0
11543            || (self.provider.request_count > 0
11544                && self.provider.preflight_clean
11545                && self.provider.callback_targets_distinct);
11546        let complete_lifecycle = usize::from(self.target.request_count)
11547            == XML_EXTERNAL_ENTITY_TARGET_REQUESTS
11548            && self.target.request_body_bytes > 0
11549            && self.provider.request_count > 0
11550            && self.target.complete
11551            && self.target.accounting_complete
11552            && usize::from(self.provider.active_verification_count)
11553                == XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS
11554            && self.provider.preflight_clean
11555            && !self.provider.control_callback_observed
11556            && self.provider.callback_targets_distinct
11557            && self.provider.cleanup_verified
11558            && self.provider.complete;
11559        let complete_positive = complete_lifecycle
11560            && self.provider.candidate_callback_observed
11561            && self.provider.replay_callback_observed
11562            && self.provider.event_identities_distinct;
11563        let empty_lifecycle = self.target.request_count == 0
11564            && self.provider.request_count == 0
11565            && no_callbacks
11566            && !self.target.complete
11567            && !self.provider.preflight_clean
11568            && !self.provider.callback_targets_distinct
11569            && !self.provider.event_identities_distinct
11570            && !self.provider.cleanup_verified
11571            && !self.target.accounting_complete
11572            && !self.provider.complete;
11573        let stopped_lifecycle = !complete_lifecycle
11574            && !self.provider.control_callback_observed
11575            && (!self.target.accounting_complete || !self.provider.complete);
11576        let outcome_facts_match = match self.outcome {
11577            "not_eligible" => empty_lifecycle,
11578            "control_incomplete" => {
11579                self.target.request_count == 0
11580                    && !self.target.complete
11581                    && !self.provider.preflight_clean
11582                    && no_callbacks
11583                    && !self.target.accounting_complete
11584                    && !self.provider.complete
11585            },
11586            "preflight_contaminated" => {
11587                self.target.request_count == 0
11588                    && self.provider.request_count > 0
11589                    && !self.provider.preflight_clean
11590                    && !no_callbacks
11591                    && self.provider.callback_targets_distinct
11592                    && !self.target.complete
11593                    && !self.target.accounting_complete
11594                    && !self.provider.complete
11595            },
11596            "control_callback_observed" => {
11597                self.provider.preflight_clean
11598                    && self.provider.control_callback_observed
11599                    && self.target.request_count > 0
11600                    && !self.target.complete
11601                    && !self.target.accounting_complete
11602                    && !self.provider.complete
11603            },
11604            "no_callback" => complete_lifecycle && no_callbacks,
11605            "candidate_only" => {
11606                complete_lifecycle
11607                    && self.provider.candidate_callback_observed
11608                    && !self.provider.replay_callback_observed
11609                    && !self.provider.event_identities_distinct
11610            },
11611            "replay_only" => {
11612                complete_lifecycle
11613                    && !self.provider.candidate_callback_observed
11614                    && self.provider.replay_callback_observed
11615                    && !self.provider.event_identities_distinct
11616            },
11617            "correlation_mismatch" => {
11618                self.provider.preflight_clean
11619                    && !self.provider.control_callback_observed
11620                    && (self.provider.candidate_callback_observed
11621                        || self.provider.replay_callback_observed)
11622                    && !self.provider.event_identities_distinct
11623            },
11624            "cleanup_incomplete" => {
11625                self.provider.preflight_clean
11626                    && !self.provider.control_callback_observed
11627                    && !self.provider.cleanup_verified
11628            },
11629            "cancelled" | "budget_exhausted" | "incomplete" => stopped_lifecycle,
11630            "repeated_external_entity_resolution_observed" => complete_positive,
11631            _ => false,
11632        };
11633        if self.schema != XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA
11634            || self.algorithm != XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM
11635            || !valid_xml_external_entity_policy_id(&self.policy.id)
11636            || self.policy.method != "post"
11637            || self.policy.media_type != XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE
11638            || self.policy.control_model != XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL
11639            || !matches!(
11640                self.policy.execution_mode,
11641                "production" | "owned_numeric_loopback_test"
11642            )
11643            || !OUTCOMES.contains(&self.outcome)
11644            || usize::from(self.target.request_count) > XML_EXTERNAL_ENTITY_TARGET_REQUESTS
11645            || self.target.request_body_bytes
11646                > u64::try_from(XML_EXTERNAL_ENTITY_TARGET_REQUESTS)
11647                    .map_err(|_| ReportError::Serialization)?
11648                    .checked_mul(
11649                        u64::try_from(MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES)
11650                            .map_err(|_| ReportError::Serialization)?,
11651                    )
11652                    .ok_or(ReportError::Serialization)?
11653            || self.target.response_bytes > 256 * 1_024 * 1_024
11654            || (self.target.complete
11655                && self.target.response_bytes
11656                    > MAX_XML_EXTERNAL_ENTITY_COMPLETE_TARGET_RESPONSE_BYTES)
11657            || (self.target.request_count == 0) != (self.target.request_body_bytes == 0)
11658            || (self.target.complete
11659                && usize::from(self.target.request_count) != XML_EXTERNAL_ENTITY_TARGET_REQUESTS)
11660            || (self.target.accounting_complete && !self.target.complete)
11661            || usize::from(self.provider.request_count) > MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS
11662            || usize::from(self.provider.active_verification_count)
11663                > XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS
11664            || self.provider.active_verification_count != u8::from(self.target.request_count >= 2)
11665            || !callback_facts_consistent
11666            || !event_facts_consistent
11667            || !provider_empty_consistent
11668            || !provider_completion_consistent
11669            || !target_started_consistent
11670            || !outcome_facts_match
11671            || self.item_projected != positive
11672            || self.semantic_effect != "not_performed"
11673            || self.impact_validation != "not_performed"
11674            || self.claim_limits.as_slice() != XML_EXTERNAL_ENTITY_REVIEW_CLAIM_LIMITS
11675            || positive != complete_positive
11676        {
11677            return Err(ReportError::Serialization);
11678        }
11679        Ok(())
11680    }
11681
11682    fn wire_json(&self) -> Result<String, ReportError> {
11683        render_serializable_json(self, MAX_RENDERED_REPORT_BYTES)
11684    }
11685
11686    fn metadata(&self) -> Vec<(&'static str, String)> {
11687        vec![
11688            ("Schema", self.schema.to_owned()),
11689            ("Algorithm", self.algorithm.to_owned()),
11690            ("Outcome", self.outcome.to_owned()),
11691            ("Pseudonymous policy digest", self.policy.id.clone()),
11692            ("Method", self.policy.method.to_owned()),
11693            ("Media type", self.policy.media_type.to_owned()),
11694            ("Control model", self.policy.control_model.to_owned()),
11695            ("Execution mode", self.policy.execution_mode.to_owned()),
11696            (
11697                "Target request count",
11698                self.target.request_count.to_string(),
11699            ),
11700            (
11701                "Target request body bytes",
11702                self.target.request_body_bytes.to_string(),
11703            ),
11704            (
11705                "Target response bytes",
11706                self.target.response_bytes.to_string(),
11707            ),
11708            ("Target complete", self.target.complete.to_string()),
11709            (
11710                "Target accounting complete",
11711                self.target.accounting_complete.to_string(),
11712            ),
11713            (
11714                "Provider request count",
11715                self.provider.request_count.to_string(),
11716            ),
11717            (
11718                "Active verification count",
11719                self.provider.active_verification_count.to_string(),
11720            ),
11721            ("Preflight clean", self.provider.preflight_clean.to_string()),
11722            (
11723                "Control callback observed",
11724                self.provider.control_callback_observed.to_string(),
11725            ),
11726            (
11727                "Candidate callback observed",
11728                self.provider.candidate_callback_observed.to_string(),
11729            ),
11730            (
11731                "Replay callback observed",
11732                self.provider.replay_callback_observed.to_string(),
11733            ),
11734            (
11735                "Callback targets distinct",
11736                self.provider.callback_targets_distinct.to_string(),
11737            ),
11738            (
11739                "Event identities distinct",
11740                self.provider.event_identities_distinct.to_string(),
11741            ),
11742            (
11743                "Cleanup verified",
11744                self.provider.cleanup_verified.to_string(),
11745            ),
11746            (
11747                "Provider accounting complete",
11748                self.provider.complete.to_string(),
11749            ),
11750            ("Item projected", self.item_projected.to_string()),
11751            ("Semantic effect", self.semantic_effect.to_owned()),
11752            ("Impact validation", self.impact_validation.to_owned()),
11753            ("Claim limits", self.claim_limits.join(",")),
11754        ]
11755    }
11756}
11757
11758#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
11759#[derive(Serialize)]
11760struct AssessmentTemplateEvaluationReviewAuditDocument {
11761    schema: &'static str,
11762    policy_id: &'static str,
11763    family_id: &'static str,
11764    outcome: &'static str,
11765    engine_identity: &'static str,
11766    coverage: AssessmentTemplateEvaluationCoverageDocument,
11767    operations: AssessmentTemplateEvaluationOperationsDocument,
11768    claim_limits: Vec<&'static str>,
11769}
11770
11771#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
11772#[derive(Serialize)]
11773struct AssessmentTemplateEvaluationCoverageDocument {
11774    selected_case_count: u8,
11775    attempted_request_count: u8,
11776    active_request_count: u8,
11777    completed_response_count: u8,
11778    committed_response_count: u8,
11779    projected_item_count: u8,
11780}
11781
11782#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
11783#[derive(Serialize)]
11784struct AssessmentTemplateEvaluationOperationsDocument {
11785    outbound_interaction: &'static str,
11786    os_execution: &'static str,
11787    file_access: &'static str,
11788    impact_validation: &'static str,
11789}
11790
11791#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
11792impl AssessmentTemplateEvaluationReviewAuditDocument {
11793    fn from_audit(audit: &TemplateEvaluationReviewAudit) -> Result<Self, ReportError> {
11794        let document = Self {
11795            schema: audit.schema(),
11796            policy_id: audit.policy_id(),
11797            family_id: audit.family_id(),
11798            outcome: template_evaluation_outcome_token(audit.outcome()),
11799            engine_identity: audit.engine_identity(),
11800            coverage: AssessmentTemplateEvaluationCoverageDocument {
11801                selected_case_count: audit.selected_case_count(),
11802                attempted_request_count: audit.attempted_request_count(),
11803                active_request_count: audit.active_request_count(),
11804                completed_response_count: audit.completed_response_count(),
11805                committed_response_count: audit.committed_response_count(),
11806                projected_item_count: audit.projected_item_count(),
11807            },
11808            operations: AssessmentTemplateEvaluationOperationsDocument {
11809                outbound_interaction: audit.outbound_interaction(),
11810                os_execution: audit.os_execution(),
11811                file_access: audit.file_access(),
11812                impact_validation: audit.impact_validation(),
11813            },
11814            claim_limits: audit.claim_limits().to_vec(),
11815        };
11816        document.validate_wire()?;
11817        Ok(document)
11818    }
11819
11820    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
11821        self.validate_wire()?;
11822        let matching_items = items
11823            .iter()
11824            .filter(|item| item.capability_id == TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID)
11825            .collect::<Vec<_>>();
11826        if matching_items.len() != usize::from(self.coverage.projected_item_count)
11827            || matching_items.len() > 1
11828            || matching_items.first().is_some_and(|item| {
11829                item.subject_reference != "subject-0000"
11830                    || item.presentation_target != "query_parameter"
11831                    || item.title != TEMPLATE_EVALUATION_REVIEW_TITLE
11832                    || item.disposition != "needs_review"
11833                    || item.claim_basis != "differential"
11834                    || item.severity.is_some()
11835                    || item.confidence_ppm != TEMPLATE_EVALUATION_REVIEW_CONFIDENCE_PPM
11836                    || item.redacted_summary != TEMPLATE_EVALUATION_REVIEW_SUMMARY
11837                    || item.category != TEMPLATE_EVALUATION_REVIEW_CATEGORY
11838                    || item.cwe != Some("CWE-1336")
11839                    || item.remediation.id != TEMPLATE_EVALUATION_REVIEW_REMEDIATION_ID
11840                    || item.remediation.summary != TEMPLATE_EVALUATION_REVIEW_REMEDIATION_SUMMARY
11841                    || !template_evaluation_evidence_linkage_is_exact(item)
11842            })
11843        {
11844            return Err(ReportError::Serialization);
11845        }
11846        Ok(())
11847    }
11848
11849    fn validate_wire(&self) -> Result<(), ReportError> {
11850        let positive = self.outcome == "candidate_specific_evaluation";
11851        let parent_absent = self.outcome == "parent_not_observed";
11852        let outcome_is_known = matches!(
11853            self.outcome,
11854            "parent_not_observed"
11855                | "literal_or_escaped"
11856                | "unsupported_representation"
11857                | "replay_mismatch"
11858                | "candidate_specific_evaluation"
11859                | "incomplete"
11860        );
11861        let counts_are_bounded = matches!(self.coverage.selected_case_count, 0 | 2)
11862            && self.coverage.attempted_request_count <= 4
11863            && self.coverage.active_request_count <= 2
11864            && self.coverage.completed_response_count <= 4
11865            && self.coverage.committed_response_count <= 4
11866            && self.coverage.projected_item_count <= 1;
11867        let counts_are_ordered = self.coverage.completed_response_count
11868            <= self.coverage.attempted_request_count
11869            && self.coverage.committed_response_count <= self.coverage.completed_response_count
11870            && self.coverage.active_request_count <= self.coverage.attempted_request_count;
11871        let outcome_matches = if parent_absent {
11872            self.coverage.selected_case_count == 0
11873                && self.coverage.attempted_request_count == 0
11874                && self.coverage.active_request_count == 0
11875                && self.coverage.completed_response_count == 0
11876                && self.coverage.committed_response_count == 0
11877                && self.coverage.projected_item_count == 0
11878        } else if positive {
11879            self.coverage.selected_case_count == 2
11880                && self.coverage.attempted_request_count == 4
11881                && self.coverage.active_request_count == 2
11882                && self.coverage.completed_response_count == 4
11883                && self.coverage.committed_response_count == 4
11884                && self.coverage.projected_item_count == 1
11885        } else if matches!(
11886            self.outcome,
11887            "literal_or_escaped" | "unsupported_representation" | "replay_mismatch"
11888        ) {
11889            self.coverage.selected_case_count == 2
11890                && self.coverage.attempted_request_count == 4
11891                && self.coverage.active_request_count == 2
11892                && self.coverage.completed_response_count == 4
11893                && self.coverage.committed_response_count == 4
11894                && self.coverage.projected_item_count == 0
11895        } else {
11896            self.coverage.selected_case_count == 2 && self.coverage.projected_item_count == 0
11897        };
11898        if self.schema != TEMPLATE_EVALUATION_REVIEW_AUDIT_SCHEMA
11899            || self.policy_id != TEMPLATE_EVALUATION_REVIEW_POLICY_ID
11900            || self.family_id != TEMPLATE_EVALUATION_REVIEW_FAMILY_ID
11901            || !outcome_is_known
11902            || self.engine_identity != TEMPLATE_EVALUATION_ENGINE_IDENTITY
11903            || self.operations.outbound_interaction != TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
11904            || self.operations.os_execution != TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
11905            || self.operations.file_access != TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
11906            || self.operations.impact_validation != TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
11907            || self.claim_limits.as_slice() != TEMPLATE_EVALUATION_REVIEW_CLAIM_LIMITS
11908            || !counts_are_bounded
11909            || !counts_are_ordered
11910            || !outcome_matches
11911        {
11912            return Err(ReportError::Serialization);
11913        }
11914        Ok(())
11915    }
11916
11917    fn wire_json(&self) -> Result<String, ReportError> {
11918        render_serializable_json(self, MAX_RENDERED_REPORT_BYTES)
11919    }
11920
11921    fn metadata(&self) -> Vec<(&'static str, String)> {
11922        vec![
11923            ("Schema", self.schema.to_owned()),
11924            ("Policy", self.policy_id.to_owned()),
11925            ("Expression family", self.family_id.to_owned()),
11926            ("Outcome", self.outcome.to_owned()),
11927            ("Engine identity", self.engine_identity.to_owned()),
11928            (
11929                "Selected case count",
11930                self.coverage.selected_case_count.to_string(),
11931            ),
11932            (
11933                "Attempted request count",
11934                self.coverage.attempted_request_count.to_string(),
11935            ),
11936            (
11937                "Active request count",
11938                self.coverage.active_request_count.to_string(),
11939            ),
11940            (
11941                "Completed response count",
11942                self.coverage.completed_response_count.to_string(),
11943            ),
11944            (
11945                "Committed response count",
11946                self.coverage.committed_response_count.to_string(),
11947            ),
11948            (
11949                "Projected item count",
11950                self.coverage.projected_item_count.to_string(),
11951            ),
11952            (
11953                "Outbound interaction",
11954                self.operations.outbound_interaction.to_owned(),
11955            ),
11956            ("OS execution", self.operations.os_execution.to_owned()),
11957            ("File access", self.operations.file_access.to_owned()),
11958            (
11959                "Impact validation",
11960                self.operations.impact_validation.to_owned(),
11961            ),
11962            ("Claim limits", self.claim_limits.join(",")),
11963        ]
11964    }
11965}
11966
11967#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
11968const fn template_evaluation_outcome_token(
11969    outcome: TemplateEvaluationReviewOutcome,
11970) -> &'static str {
11971    outcome.as_str()
11972}
11973
11974#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11975fn valid_xml_external_entity_policy_id(value: &str) -> bool {
11976    value
11977        .strip_prefix("xml-external-entity-policy-sha256:")
11978        .is_some_and(|digest| {
11979            digest.len() == 64
11980                && digest
11981                    .bytes()
11982                    .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
11983        })
11984}
11985
11986#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
11987fn xml_external_entity_evidence_linkage_is_exact(item: &AssessmentItemDocument<'_>) -> bool {
11988    if !item.evidence_references.is_empty()
11989        || item.control_evidence_references.len()
11990            != XML_EXTERNAL_ENTITY_REVIEW_CONTROL_EVIDENCE_COUNT
11991        || item.candidate_evidence_references.len()
11992            != XML_EXTERNAL_ENTITY_REVIEW_CANDIDATE_EVIDENCE_COUNT
11993        || item.evidence_count
11994            != u64::try_from(
11995                XML_EXTERNAL_ENTITY_REVIEW_CONTROL_EVIDENCE_COUNT
11996                    + XML_EXTERNAL_ENTITY_REVIEW_CANDIDATE_EVIDENCE_COUNT,
11997            )
11998            .unwrap_or(u64::MAX)
11999    {
12000        return false;
12001    }
12002    let ordinals = item
12003        .control_evidence_references
12004        .iter()
12005        .chain(&item.candidate_evidence_references)
12006        .map(|reference| assessment_reference_ordinal(reference, "evidence"))
12007        .collect::<Option<Vec<_>>>();
12008    ordinals.is_some_and(|ordinals| {
12009        ordinals
12010            .windows(2)
12011            .all(|pair| pair[0].checked_add(1) == Some(pair[1]))
12012    })
12013}
12014
12015#[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
12016fn template_evaluation_evidence_linkage_is_exact(item: &AssessmentItemDocument<'_>) -> bool {
12017    item.evidence_references.is_empty()
12018        && item.control_evidence_references.len()
12019            == TEMPLATE_EVALUATION_REVIEW_CONTROL_EVIDENCE_COUNT
12020        && item.candidate_evidence_references.len()
12021            == TEMPLATE_EVALUATION_REVIEW_CANDIDATE_EVIDENCE_COUNT
12022        && item.evidence_count
12023            == u64::try_from(
12024                TEMPLATE_EVALUATION_REVIEW_CONTROL_EVIDENCE_COUNT
12025                    + TEMPLATE_EVALUATION_REVIEW_CANDIDATE_EVIDENCE_COUNT,
12026            )
12027            .unwrap_or(u64::MAX)
12028}
12029
12030#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
12031fn assessment_reference_ordinal(value: &str, kind: &str) -> Option<u64> {
12032    let suffix = value
12033        .strip_prefix(kind)
12034        .and_then(|value| value.strip_prefix('-'))?;
12035    ((4..=10).contains(&suffix.len())
12036        && suffix.bytes().all(|byte| byte.is_ascii_digit())
12037        && (suffix.len() == 4 || !suffix.starts_with('0')))
12038    .then(|| suffix.parse().ok())
12039    .flatten()
12040}
12041
12042#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
12043const fn xml_external_entity_outcome_token(
12044    outcome: XmlExternalEntityReviewOutcome,
12045) -> &'static str {
12046    match outcome {
12047        XmlExternalEntityReviewOutcome::NotEligible => "not_eligible",
12048        XmlExternalEntityReviewOutcome::ControlIncomplete => "control_incomplete",
12049        XmlExternalEntityReviewOutcome::PreflightContaminated => "preflight_contaminated",
12050        XmlExternalEntityReviewOutcome::ControlCallbackObserved => "control_callback_observed",
12051        XmlExternalEntityReviewOutcome::NoCallback => "no_callback",
12052        XmlExternalEntityReviewOutcome::CandidateOnly => "candidate_only",
12053        XmlExternalEntityReviewOutcome::ReplayOnly => "replay_only",
12054        XmlExternalEntityReviewOutcome::CorrelationMismatch => "correlation_mismatch",
12055        XmlExternalEntityReviewOutcome::CleanupIncomplete => "cleanup_incomplete",
12056        XmlExternalEntityReviewOutcome::Cancelled => "cancelled",
12057        XmlExternalEntityReviewOutcome::BudgetExhausted => "budget_exhausted",
12058        XmlExternalEntityReviewOutcome::Incomplete => "incomplete",
12059        XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved => {
12060            "repeated_external_entity_resolution_observed"
12061        },
12062    }
12063}
12064
12065#[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
12066const fn xml_external_entity_operation_token(
12067    status: XmlExternalEntityOperationStatus,
12068) -> &'static str {
12069    match status {
12070        XmlExternalEntityOperationStatus::NotPerformed => "not_performed",
12071    }
12072}
12073
12074#[cfg(all(feature = "scanning", feature = "websocket-review"))]
12075#[derive(Serialize)]
12076struct AssessmentWebSocketReviewAuditDocument {
12077    schema: &'static str,
12078    capability_id: &'static str,
12079    selected: bool,
12080    policy_reference: String,
12081    endpoint_reference: String,
12082    context: &'static str,
12083    #[cfg(feature = "supplied-session-review")]
12084    #[serde(skip_serializing_if = "Option::is_none")]
12085    supplied_session_context: Option<AssessmentWebSocketSuppliedSessionContextDocument>,
12086    methodology: AssessmentWebSocketReviewMethodologyDocument,
12087    coverage: AssessmentWebSocketReviewCoverageDocument,
12088    messages: Vec<AssessmentWebSocketReviewMessageDocument>,
12089    claim_limits: Vec<&'static str>,
12090}
12091
12092#[cfg(all(
12093    feature = "scanning",
12094    feature = "websocket-review",
12095    feature = "supplied-session-review"
12096))]
12097#[derive(Serialize)]
12098struct AssessmentWebSocketSuppliedSessionContextDocument {
12099    mode: &'static str,
12100    policy_reference: String,
12101    application_reference: String,
12102    principal_reference: String,
12103    principal_alias: String,
12104    principal_assurance: &'static str,
12105    credential_mechanism: &'static str,
12106    session_epoch: u8,
12107    startup_health: Option<AssessmentWebSocketSuppliedSessionHealthDocument>,
12108    terminal_health: Option<AssessmentWebSocketSuppliedSessionHealthDocument>,
12109    qualification: &'static str,
12110    continuous_authentication_established: bool,
12111}
12112
12113#[cfg(all(
12114    feature = "scanning",
12115    feature = "websocket-review",
12116    feature = "supplied-session-review"
12117))]
12118#[derive(Serialize)]
12119struct AssessmentWebSocketSuppliedSessionHealthDocument {
12120    outcome: &'static str,
12121    evidence_reference: Option<String>,
12122}
12123
12124#[cfg(all(
12125    feature = "scanning",
12126    feature = "websocket-review",
12127    feature = "supplied-session-review"
12128))]
12129impl AssessmentWebSocketSuppliedSessionContextDocument {
12130    fn validate_internal(
12131        &self,
12132        request_attempt_count: u8,
12133        request_admitted_count: u8,
12134    ) -> Result<(), ReportError> {
12135        let health_is_valid = |health: &AssessmentWebSocketSuppliedSessionHealthDocument| {
12136            matches!(health.outcome, "healthy" | "unhealthy" | "indeterminate")
12137                && health
12138                    .evidence_reference
12139                    .as_deref()
12140                    .is_none_or(|reference| {
12141                        valid_supplied_session_reference(
12142                            reference,
12143                            "supplied-session-checkpoint-evidence-sha256:",
12144                        )
12145                    })
12146        };
12147        if self.mode != "health_qualified_authorization_header"
12148            || !valid_supplied_session_reference(
12149                &self.policy_reference,
12150                "supplied-session-policy-sha256:",
12151            )
12152            || !valid_supplied_session_reference(
12153                &self.application_reference,
12154                "supplied-session-application-sha256:",
12155            )
12156            || self.principal_reference != "supplied-session-principal-0001"
12157            || !valid_supplied_session_principal_alias(&self.principal_alias)
12158            || self.principal_assurance != "operator_declared"
12159            || self.credential_mechanism != "authorization_header"
12160            || self.session_epoch != 1
12161            || self.continuous_authentication_established
12162            || self
12163                .startup_health
12164                .as_ref()
12165                .is_some_and(|health| !health_is_valid(health))
12166            || self
12167                .terminal_health
12168                .as_ref()
12169                .is_some_and(|health| !health_is_valid(health))
12170        {
12171            return Err(ReportError::Serialization);
12172        }
12173        let startup_healthy = self.startup_health.as_ref().is_some_and(|health| {
12174            health.outcome == "healthy" && health.evidence_reference.is_some()
12175        });
12176        let terminal_healthy = self.terminal_health.as_ref().is_some_and(|health| {
12177            health.outcome == "healthy" && health.evidence_reference.is_some()
12178        });
12179        let qualification_is_valid = match self.qualification {
12180            "context_qualified" => {
12181                request_admitted_count == 1 && startup_healthy && terminal_healthy
12182            },
12183            "context_unqualified" => {
12184                request_admitted_count == 1 && startup_healthy && !terminal_healthy
12185            },
12186            "not_established" => request_admitted_count == 0 && request_attempt_count <= 1,
12187            _ => false,
12188        };
12189        if qualification_is_valid {
12190            Ok(())
12191        } else {
12192            Err(ReportError::Serialization)
12193        }
12194    }
12195
12196    #[cfg(feature = "supplied-session-review")]
12197    fn validate_against_supplied_session(
12198        &self,
12199        session: &AssessmentSuppliedSessionAuditDocument,
12200    ) -> Result<(), ReportError> {
12201        if session.schema != SUPPLIED_SESSION_AUDIT_SCHEMA
12202            || session.policy_reference != self.policy_reference
12203            || session.application_reference != self.application_reference
12204            || session.principal_reference != self.principal_reference
12205            || session.principal_alias != self.principal_alias
12206            || session.principal_assurance != self.principal_assurance
12207            || session.credential_mechanism != self.credential_mechanism
12208            || session.selected_resource_count != 1
12209            || session.resources.len() != 1
12210            || session.continuous_authentication_established
12211        {
12212            return Err(ReportError::Serialization);
12213        }
12214        let startup = session
12215            .checkpoints
12216            .iter()
12217            .find(|checkpoint| checkpoint.phase == "startup");
12218        let terminal = session
12219            .checkpoints
12220            .iter()
12221            .find(|checkpoint| checkpoint.phase == "terminal");
12222        if session
12223            .checkpoints
12224            .iter()
12225            .filter(|checkpoint| checkpoint.phase == "startup")
12226            .count()
12227            > 1
12228            || session
12229                .checkpoints
12230                .iter()
12231                .filter(|checkpoint| checkpoint.phase == "terminal")
12232                .count()
12233                > 1
12234        {
12235            return Err(ReportError::Serialization);
12236        }
12237        let health_matches =
12238            |projected: Option<&AssessmentWebSocketSuppliedSessionHealthDocument>,
12239             checkpoint: Option<&AssessmentSuppliedSessionCheckpointDocument>| {
12240                projected.is_some() == checkpoint.is_some()
12241                    && projected
12242                        .zip(checkpoint)
12243                        .is_none_or(|(projected, checkpoint)| {
12244                            projected.outcome == checkpoint.outcome
12245                                && projected.evidence_reference == checkpoint.evidence_reference
12246                        })
12247            };
12248        if health_matches(self.startup_health.as_ref(), startup)
12249            && health_matches(self.terminal_health.as_ref(), terminal)
12250        {
12251            Ok(())
12252        } else {
12253            Err(ReportError::Serialization)
12254        }
12255    }
12256}
12257
12258#[cfg(all(feature = "scanning", feature = "websocket-review"))]
12259#[derive(Serialize)]
12260struct AssessmentWebSocketReviewMethodologyDocument {
12261    origin_mode: &'static str,
12262    compression: &'static str,
12263    reconnect: &'static str,
12264    http2_extended_connect: &'static str,
12265    subprotocol: &'static str,
12266    physical_frame_count: &'static str,
12267    max_frame_size: &'static str,
12268    limits: AssessmentWebSocketReviewLimitsDocument,
12269}
12270
12271#[cfg(all(feature = "scanning", feature = "websocket-review"))]
12272#[derive(Serialize)]
12273struct AssessmentWebSocketReviewLimitsDocument {
12274    max_connections: u8,
12275    max_outbound_application_bytes: u64,
12276    max_inbound_application_bytes: u64,
12277    max_outbound_message_bytes: u64,
12278    max_inbound_message_bytes: u64,
12279    max_messages: u64,
12280    max_control_frames: u64,
12281    max_wall_time_ms: u64,
12282}
12283
12284#[cfg(all(feature = "scanning", feature = "websocket-review"))]
12285#[derive(Serialize)]
12286struct AssessmentWebSocketReviewCoverageDocument {
12287    terminal: &'static str,
12288    completeness: &'static str,
12289    first_failure: Option<&'static str>,
12290    request_attempt_count: u8,
12291    request_admitted_count: u8,
12292    connection_attempt_count: u8,
12293    handshake_completed_count: u8,
12294    outbound_message_count: u64,
12295    inbound_message_count: u64,
12296    outbound_control_frame_count: u64,
12297    inbound_control_frame_count: u64,
12298    outbound_application_bytes: u64,
12299    inbound_application_bytes: u64,
12300    transport_read_bytes: u64,
12301    transport_write_bytes: u64,
12302    accounted_request_body_bytes: u64,
12303    accounted_transport_response_bytes: u64,
12304    transport_byte_scope: &'static str,
12305    expected_response_count: u64,
12306    matched_response_count: u64,
12307    mismatched_response_count: u64,
12308}
12309
12310#[cfg(all(feature = "scanning", feature = "websocket-review"))]
12311#[derive(Serialize)]
12312struct AssessmentWebSocketReviewMessageDocument {
12313    message_reference: String,
12314    expected_response_reference: String,
12315    outbound_length: u64,
12316    inbound_length: Option<u64>,
12317    status: &'static str,
12318}
12319
12320#[cfg(all(feature = "scanning", feature = "websocket-review"))]
12321impl AssessmentWebSocketReviewAuditDocument {
12322    fn has_supplied_session_context(&self) -> bool {
12323        #[cfg(feature = "supplied-session-review")]
12324        {
12325            self.supplied_session_context.is_some()
12326        }
12327        #[cfg(not(feature = "supplied-session-review"))]
12328        {
12329            false
12330        }
12331    }
12332
12333    fn from_audit(audit: &WebAssessmentWebSocketReviewAudit) -> Result<Self, ReportError> {
12334        let limits = audit.limits();
12335        let claim_limits = audit
12336            .claim_limits()
12337            .iter()
12338            .map(|limit| limit.as_str())
12339            .collect::<Vec<_>>();
12340        #[cfg(feature = "supplied-session-review")]
12341        let claim_limits = {
12342            let mut claim_limits = claim_limits;
12343            if audit.schema() == WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA {
12344                claim_limits.push("continuous_authentication_not_established");
12345            }
12346            claim_limits
12347        };
12348        let document = Self {
12349            schema: audit.schema(),
12350            capability_id: audit.capability_id(),
12351            selected: audit.selected(),
12352            policy_reference: audit.policy_reference().to_owned(),
12353            endpoint_reference: audit.endpoint_reference().to_owned(),
12354            context: audit.context(),
12355            #[cfg(feature = "supplied-session-review")]
12356            supplied_session_context: audit.supplied_session_context().map(|context| {
12357                AssessmentWebSocketSuppliedSessionContextDocument {
12358                    mode: "health_qualified_authorization_header",
12359                    policy_reference: context.policy_reference().to_owned(),
12360                    application_reference: context.application_reference().to_owned(),
12361                    principal_reference: context.principal_reference().to_owned(),
12362                    principal_alias: context.principal_alias().to_owned(),
12363                    principal_assurance: context.principal_assurance(),
12364                    credential_mechanism: context.credential_mechanism(),
12365                    session_epoch: context.epoch(),
12366                    startup_health: context.startup_health().map(|health| {
12367                        AssessmentWebSocketSuppliedSessionHealthDocument {
12368                            outcome: health.outcome(),
12369                            evidence_reference: health.evidence_reference().map(str::to_owned),
12370                        }
12371                    }),
12372                    terminal_health: context.terminal_health().map(|health| {
12373                        AssessmentWebSocketSuppliedSessionHealthDocument {
12374                            outcome: health.outcome(),
12375                            evidence_reference: health.evidence_reference().map(str::to_owned),
12376                        }
12377                    }),
12378                    qualification: context.qualification().as_str(),
12379                    continuous_authentication_established: context
12380                        .continuous_authentication_established(),
12381                }
12382            }),
12383            methodology: AssessmentWebSocketReviewMethodologyDocument {
12384                origin_mode: audit.origin_mode().as_str(),
12385                compression: if audit.compression_enabled() {
12386                    "enabled"
12387                } else {
12388                    "disabled"
12389                },
12390                reconnect: if audit.reconnect_enabled() {
12391                    "enabled"
12392                } else {
12393                    "disabled"
12394                },
12395                http2_extended_connect: audit.http2_extended_connect(),
12396                subprotocol: if audit.subprotocol_requested() {
12397                    "present"
12398                } else {
12399                    "absent"
12400                },
12401                physical_frame_count: WEBSOCKET_REVIEW_PHYSICAL_FRAME_COUNT,
12402                max_frame_size: WEBSOCKET_REVIEW_MAX_FRAME_SIZE,
12403                limits: AssessmentWebSocketReviewLimitsDocument {
12404                    max_connections: limits.max_connections(),
12405                    max_outbound_application_bytes: limits.max_outbound_application_bytes(),
12406                    max_inbound_application_bytes: limits.max_inbound_application_bytes(),
12407                    max_outbound_message_bytes: limits.max_outbound_message_bytes(),
12408                    max_inbound_message_bytes: limits.max_inbound_message_bytes(),
12409                    max_messages: limits.max_messages(),
12410                    max_control_frames: limits.max_control_frames(),
12411                    max_wall_time_ms: limits.max_wall_time_ms(),
12412                },
12413            },
12414            coverage: AssessmentWebSocketReviewCoverageDocument {
12415                terminal: audit.terminal().as_str(),
12416                completeness: audit.completeness().as_str(),
12417                first_failure: audit.first_failure().map(|failure| failure.as_str()),
12418                request_attempt_count: audit.request_attempt_count(),
12419                request_admitted_count: audit.request_admitted_count(),
12420                connection_attempt_count: audit.connection_attempt_count(),
12421                handshake_completed_count: audit.handshake_completed_count(),
12422                outbound_message_count: audit.outbound_message_count(),
12423                inbound_message_count: audit.inbound_message_count(),
12424                outbound_control_frame_count: audit.outbound_control_frame_count(),
12425                inbound_control_frame_count: audit.inbound_control_frame_count(),
12426                outbound_application_bytes: audit.outbound_application_bytes(),
12427                inbound_application_bytes: audit.inbound_application_bytes(),
12428                transport_read_bytes: audit.transport_read_bytes(),
12429                transport_write_bytes: audit.transport_write_bytes(),
12430                accounted_request_body_bytes: audit.accounted_request_body_bytes(),
12431                accounted_transport_response_bytes: audit.accounted_transport_response_bytes(),
12432                transport_byte_scope: audit.transport_byte_scope(),
12433                expected_response_count: audit.expected_response_count(),
12434                matched_response_count: audit.matched_response_count(),
12435                mismatched_response_count: audit.mismatched_response_count(),
12436            },
12437            messages: audit
12438                .messages()
12439                .iter()
12440                .map(|message| AssessmentWebSocketReviewMessageDocument {
12441                    message_reference: message.message_reference().to_owned(),
12442                    expected_response_reference: message.expected_response_reference().to_owned(),
12443                    outbound_length: message.outbound_length(),
12444                    inbound_length: message.inbound_length(),
12445                    status: message.status().as_str(),
12446                })
12447                .collect(),
12448            claim_limits,
12449        };
12450        document.validate_wire()?;
12451        Ok(document)
12452    }
12453
12454    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
12455        if items
12456            .iter()
12457            .any(|item| item.capability_id == WEBSOCKET_REVIEW_CAPABILITY_ID)
12458        {
12459            return Err(ReportError::Serialization);
12460        }
12461        self.validate_wire()
12462    }
12463
12464    fn validate_wire(&self) -> Result<(), ReportError> {
12465        let limits = &self.methodology.limits;
12466        let coverage = &self.coverage;
12467        let mut message_references = std::collections::BTreeSet::new();
12468        let mut outbound_messages = 0_u64;
12469        let mut inbound_messages = 0_u64;
12470        let mut outbound_bytes = 0_u64;
12471        let mut inbound_bytes = 0_u64;
12472        let mut matched = 0_u64;
12473        let mut mismatched = 0_u64;
12474        let mut phase = 0_u8;
12475        for message in &self.messages {
12476            if !valid_websocket_review_reference(
12477                &message.message_reference,
12478                "websocket-message-revision-sha256:",
12479            ) || !message_references.insert(message.message_reference.as_str())
12480                || !valid_websocket_review_reference(
12481                    &message.expected_response_reference,
12482                    "websocket-expected-response-revision-sha256:",
12483                )
12484                || message.outbound_length > limits.max_outbound_message_bytes
12485                || message
12486                    .inbound_length
12487                    .is_some_and(|length| length > limits.max_inbound_message_bytes)
12488            {
12489                return Err(ReportError::Serialization);
12490            }
12491            match message.status {
12492                "response_matched" | "response_mismatched"
12493                    if phase == 0 && message.inbound_length.is_some() =>
12494                {
12495                    outbound_messages = outbound_messages
12496                        .checked_add(1)
12497                        .ok_or(ReportError::Serialization)?;
12498                    inbound_messages = inbound_messages
12499                        .checked_add(1)
12500                        .ok_or(ReportError::Serialization)?;
12501                    outbound_bytes = outbound_bytes
12502                        .checked_add(message.outbound_length)
12503                        .ok_or(ReportError::Serialization)?;
12504                    inbound_bytes = inbound_bytes
12505                        .checked_add(message.inbound_length.unwrap_or_default())
12506                        .ok_or(ReportError::Serialization)?;
12507                    if message.status == "response_matched" {
12508                        matched += 1;
12509                    } else {
12510                        mismatched += 1;
12511                    }
12512                },
12513                "sent_no_response" if phase == 0 && message.inbound_length.is_none() => {
12514                    phase = 1;
12515                    outbound_messages = outbound_messages
12516                        .checked_add(1)
12517                        .ok_or(ReportError::Serialization)?;
12518                    outbound_bytes = outbound_bytes
12519                        .checked_add(message.outbound_length)
12520                        .ok_or(ReportError::Serialization)?;
12521                },
12522                "not_sent" if message.inbound_length.is_none() => phase = 2,
12523                _ => return Err(ReportError::Serialization),
12524            }
12525        }
12526        let failure_shape = match coverage.first_failure {
12527            None => coverage.terminal == "completed",
12528            Some("authority_already_minted" | "endpoint_outside_authority") => {
12529                coverage.terminal == "authority_refused"
12530            },
12531            Some(
12532                "message_too_large"
12533                | "message_limit_reached"
12534                | "control_frame_limit_reached"
12535                | "application_byte_limit_reached"
12536                | "parent_response_budget_reached",
12537            ) => coverage.terminal == "limit_reached",
12538            Some("cancelled") => coverage.terminal == "cancelled",
12539            Some("deadline_reached") => coverage.terminal == "deadline_reached",
12540            Some(
12541                "parent_authority_unavailable"
12542                | "request_construction"
12543                | "connection_failed"
12544                | "handshake_rejected"
12545                | "extension_negotiation_refused"
12546                | "subprotocol_negotiation_refused"
12547                | "send_failed"
12548                | "read_failed"
12549                | "binary_message_unsupported"
12550                | "close_failed"
12551                | "peer_closed"
12552                | "internal_invariant",
12553            ) => coverage.terminal == "incomplete",
12554            Some(_) => false,
12555        };
12556        let complete = coverage.terminal == "completed"
12557            && coverage.completeness == "configured_exchange_complete"
12558            && coverage.first_failure.is_none();
12559        let incomplete = coverage.terminal != "completed"
12560            && coverage.completeness == "incomplete"
12561            && coverage.first_failure.is_some();
12562        let no_admitted_activity = coverage.request_admitted_count != 0
12563            || (coverage.connection_attempt_count == 0
12564                && coverage.handshake_completed_count == 0
12565                && coverage.outbound_message_count == 0
12566                && coverage.inbound_message_count == 0
12567                && coverage.outbound_control_frame_count == 0
12568                && coverage.inbound_control_frame_count == 0
12569                && coverage.outbound_application_bytes == 0
12570                && coverage.inbound_application_bytes == 0
12571                && coverage.transport_read_bytes == 0
12572                && coverage.transport_write_bytes == 0);
12573        let completed_exchange = coverage.terminal != "completed"
12574            || (coverage.request_attempt_count == 1
12575                && coverage.request_admitted_count == 1
12576                && coverage.connection_attempt_count == 1
12577                && coverage.handshake_completed_count == 1
12578                && coverage.outbound_message_count == coverage.expected_response_count
12579                && coverage.inbound_message_count == coverage.expected_response_count);
12580        let context_is_valid = match self.schema {
12581            WEBSOCKET_REVIEW_AUDIT_SCHEMA => {
12582                self.context == "anonymous" && {
12583                    #[cfg(feature = "supplied-session-review")]
12584                    {
12585                        self.supplied_session_context.is_none()
12586                    }
12587                    #[cfg(not(feature = "supplied-session-review"))]
12588                    {
12589                        true
12590                    }
12591                }
12592            },
12593            #[cfg(feature = "supplied-session-review")]
12594            WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA => {
12595                self.context == "supplied_session"
12596                    && self
12597                        .supplied_session_context
12598                        .as_ref()
12599                        .is_some_and(|context| {
12600                            context
12601                                .validate_internal(
12602                                    coverage.request_attempt_count,
12603                                    coverage.request_admitted_count,
12604                                )
12605                                .is_ok()
12606                        })
12607            },
12608            _ => false,
12609        };
12610        let expected_claim_limits: &[&str] = if self.schema == WEBSOCKET_REVIEW_AUDIT_SCHEMA {
12611            &WEBSOCKET_REVIEW_CLAIM_LIMITS
12612        } else {
12613            &WEBSOCKET_SUPPLIED_SESSION_CLAIM_LIMITS
12614        };
12615        if !context_is_valid
12616            || self.capability_id != WEBSOCKET_REVIEW_CAPABILITY_ID
12617            || !self.selected
12618            || !valid_websocket_review_reference(&self.policy_reference, "websocket-policy-sha256:")
12619            || !valid_websocket_review_reference(
12620                &self.endpoint_reference,
12621                "websocket-endpoint-sha256:",
12622            )
12623            || !matches!(self.methodology.origin_mode, "application_origin" | "omit")
12624            || self.methodology.compression != "disabled"
12625            || self.methodology.reconnect != "disabled"
12626            || self.methodology.http2_extended_connect != "unsupported"
12627            || !matches!(self.methodology.subprotocol, "present" | "absent")
12628            || self.methodology.physical_frame_count != WEBSOCKET_REVIEW_PHYSICAL_FRAME_COUNT
12629            || self.methodology.max_frame_size != WEBSOCKET_REVIEW_MAX_FRAME_SIZE
12630            || limits.max_connections != MAX_WEBSOCKET_REVIEW_CONNECTIONS
12631            || limits.max_outbound_application_bytes
12632                != MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION
12633            || limits.max_inbound_application_bytes
12634                != MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION
12635            || !(1..=MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION)
12636                .contains(&limits.max_outbound_message_bytes)
12637            || !(1..=MAX_WEBSOCKET_REVIEW_APPLICATION_BYTES_PER_DIRECTION)
12638                .contains(&limits.max_inbound_message_bytes)
12639            || !(1..=u64::try_from(MAX_WEBSOCKET_REVIEW_MESSAGES)
12640                .map_err(|_| ReportError::Serialization)?)
12641                .contains(&limits.max_messages)
12642            || !(1..=u64::from(MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES))
12643                .contains(&limits.max_control_frames)
12644            || !(1..=MAX_WEBSOCKET_REVIEW_WALL_TIME_MS).contains(&limits.max_wall_time_ms)
12645            || self.messages.is_empty()
12646            || u64::try_from(self.messages.len()).map_err(|_| ReportError::Serialization)?
12647                != coverage.expected_response_count
12648            || coverage.expected_response_count > limits.max_messages
12649            || coverage.request_attempt_count > 1
12650            || coverage.request_admitted_count > coverage.request_attempt_count
12651            || coverage.connection_attempt_count > coverage.request_admitted_count
12652            || coverage.handshake_completed_count > coverage.connection_attempt_count
12653            || coverage.inbound_message_count > coverage.outbound_message_count
12654            || coverage
12655                .outbound_control_frame_count
12656                .checked_add(coverage.inbound_control_frame_count)
12657                .is_none_or(|count| count > limits.max_control_frames)
12658            || coverage.outbound_application_bytes > limits.max_outbound_application_bytes
12659            || coverage.inbound_application_bytes > limits.max_inbound_application_bytes
12660            || coverage.transport_read_bytes > HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES
12661            || coverage.transport_read_bytes < coverage.inbound_application_bytes
12662            || coverage.transport_write_bytes < coverage.outbound_application_bytes
12663            || coverage.accounted_request_body_bytes != 0
12664            || coverage.accounted_transport_response_bytes != coverage.transport_read_bytes
12665            || coverage.transport_byte_scope != WEBSOCKET_REVIEW_TRANSPORT_BYTE_SCOPE
12666            || coverage.outbound_message_count != outbound_messages
12667            || coverage.inbound_message_count != inbound_messages
12668            || coverage.outbound_application_bytes != outbound_bytes
12669            || coverage.inbound_application_bytes != inbound_bytes
12670            || coverage.matched_response_count != matched
12671            || coverage.mismatched_response_count != mismatched
12672            || coverage
12673                .matched_response_count
12674                .checked_add(coverage.mismatched_response_count)
12675                != Some(coverage.inbound_message_count)
12676            || !(complete || incomplete)
12677            || !failure_shape
12678            || !no_admitted_activity
12679            || !completed_exchange
12680            || self.claim_limits.as_slice() != expected_claim_limits
12681        {
12682            return Err(ReportError::Serialization);
12683        }
12684        Ok(())
12685    }
12686
12687    fn metadata(&self) -> Vec<(&'static str, String)> {
12688        let mut metadata = Vec::with_capacity(29);
12689        metadata.push(("Audit schema", self.schema.to_owned()));
12690        metadata.push(("Capability", self.capability_id.to_owned()));
12691        metadata.push(("Selected", self.selected.to_string()));
12692        metadata.push(("Policy reference", self.policy_reference.clone()));
12693        metadata.push(("Endpoint reference", self.endpoint_reference.clone()));
12694        metadata.push(("Context", self.context.to_owned()));
12695        #[cfg(feature = "supplied-session-review")]
12696        if let Some(context) = &self.supplied_session_context {
12697            metadata.push(("Session principal alias", context.principal_alias.clone()));
12698            metadata.push(("Session epoch", context.session_epoch.to_string()));
12699            metadata.push((
12700                "Session startup health",
12701                context
12702                    .startup_health
12703                    .as_ref()
12704                    .map_or("not_observed", |health| health.outcome)
12705                    .to_owned(),
12706            ));
12707            metadata.push((
12708                "Session terminal health",
12709                context
12710                    .terminal_health
12711                    .as_ref()
12712                    .map_or("not_observed", |health| health.outcome)
12713                    .to_owned(),
12714            ));
12715            metadata.push(("Session qualification", context.qualification.to_owned()));
12716            metadata.push((
12717                "Continuous authentication established",
12718                context.continuous_authentication_established.to_string(),
12719            ));
12720        }
12721        metadata.push(("Origin mode", self.methodology.origin_mode.to_owned()));
12722        metadata.push(("Compression", self.methodology.compression.to_owned()));
12723        metadata.push(("Reconnect", self.methodology.reconnect.to_owned()));
12724        metadata.push((
12725            "HTTP/2 extended CONNECT",
12726            self.methodology.http2_extended_connect.to_owned(),
12727        ));
12728        metadata.push((
12729            "Subprotocol declaration",
12730            self.methodology.subprotocol.to_owned(),
12731        ));
12732        metadata.push((
12733            "Physical frame count",
12734            self.methodology.physical_frame_count.to_owned(),
12735        ));
12736        metadata.push(("Terminal", self.coverage.terminal.to_owned()));
12737        metadata.push(("Completeness", self.coverage.completeness.to_owned()));
12738        metadata.push((
12739            "First failure",
12740            self.coverage.first_failure.unwrap_or("none").to_owned(),
12741        ));
12742        metadata.push((
12743            "Request attempts/admitted",
12744            format!(
12745                "{}/{}",
12746                self.coverage.request_attempt_count, self.coverage.request_admitted_count
12747            ),
12748        ));
12749        metadata.push((
12750            "Connection attempts/handshakes",
12751            format!(
12752                "{}/{}",
12753                self.coverage.connection_attempt_count, self.coverage.handshake_completed_count
12754            ),
12755        ));
12756        metadata.push((
12757            "Application messages outbound/inbound",
12758            format!(
12759                "{}/{}",
12760                self.coverage.outbound_message_count, self.coverage.inbound_message_count
12761            ),
12762        ));
12763        metadata.push((
12764            "Observable control events outbound/inbound",
12765            format!(
12766                "{}/{}",
12767                self.coverage.outbound_control_frame_count,
12768                self.coverage.inbound_control_frame_count
12769            ),
12770        ));
12771        metadata.push((
12772            "Application bytes outbound/inbound",
12773            format!(
12774                "{}/{}",
12775                self.coverage.outbound_application_bytes, self.coverage.inbound_application_bytes
12776            ),
12777        ));
12778        metadata.push((
12779            "Transport bytes written/read",
12780            format!(
12781                "{}/{}",
12782                self.coverage.transport_write_bytes, self.coverage.transport_read_bytes
12783            ),
12784        ));
12785        metadata.push((
12786            "Accounted request/response bytes",
12787            format!(
12788                "{}/{}",
12789                self.coverage.accounted_request_body_bytes,
12790                self.coverage.accounted_transport_response_bytes
12791            ),
12792        ));
12793        metadata.push((
12794            "Responses expected/matched/mismatched",
12795            format!(
12796                "{}/{}/{}",
12797                self.coverage.expected_response_count,
12798                self.coverage.matched_response_count,
12799                self.coverage.mismatched_response_count
12800            ),
12801        ));
12802        metadata
12803    }
12804
12805    fn wire_json(&self) -> Result<String, ReportError> {
12806        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
12807    }
12808}
12809
12810#[cfg(all(feature = "scanning", feature = "websocket-review"))]
12811fn valid_websocket_review_reference(value: &str, prefix: &str) -> bool {
12812    value.len() == prefix.len() + 64
12813        && value.starts_with(prefix)
12814        && value[prefix.len()..]
12815            .bytes()
12816            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
12817}
12818
12819#[cfg(all(feature = "scanning", feature = "authorization-review"))]
12820fn optional_bool_token(value: Option<bool>) -> String {
12821    match value {
12822        Some(true) => "true".to_owned(),
12823        Some(false) => "false".to_owned(),
12824        None => "not available".to_owned(),
12825    }
12826}
12827
12828#[cfg(all(feature = "scanning", feature = "authorization-review"))]
12829const fn authorization_review_outcome_token(outcome: AuthorizationReviewOutcome) -> &'static str {
12830    match outcome {
12831        AuthorizationReviewOutcome::NotEligible => "not_eligible",
12832        AuthorizationReviewOutcome::PrimaryBaselineInvalid => "primary_baseline_invalid",
12833        AuthorizationReviewOutcome::PrimaryUnstable => "primary_unstable",
12834        AuthorizationReviewOutcome::PeerDenied => "peer_denied",
12835        AuthorizationReviewOutcome::PeerUnstable => "peer_unstable",
12836        AuthorizationReviewOutcome::CrossStatusDifferent => "cross_status_different",
12837        AuthorizationReviewOutcome::CrossFieldsEquivalentOnly => "cross_fields_equivalent_only",
12838        AuthorizationReviewOutcome::CrossResourcesDifferent => "cross_resources_different",
12839        AuthorizationReviewOutcome::StableCrossPrincipalEquivalence => {
12840            "stable_cross_principal_equivalence"
12841        },
12842        AuthorizationReviewOutcome::DefensiveInterference => "defensive_interference",
12843        AuthorizationReviewOutcome::RateLimited => "rate_limited",
12844        AuthorizationReviewOutcome::RedirectObserved => "redirect_observed",
12845        AuthorizationReviewOutcome::UnsupportedMedia => "unsupported_media",
12846        AuthorizationReviewOutcome::MalformedJson => "malformed_json",
12847        AuthorizationReviewOutcome::GenericJsonErrorEnvelope => "generic_json_error_envelope",
12848        AuthorizationReviewOutcome::SelectedPathMissing => "selected_path_missing",
12849        AuthorizationReviewOutcome::Truncated => "truncated",
12850        AuthorizationReviewOutcome::Incomplete => "incomplete",
12851        AuthorizationReviewOutcome::BudgetExhausted => "budget_exhausted",
12852        AuthorizationReviewOutcome::Cancelled => "cancelled",
12853        AuthorizationReviewOutcome::ContractMismatch => "contract_mismatch",
12854    }
12855}
12856
12857#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12858#[derive(Serialize)]
12859struct AssessmentWordPressAuditDocument {
12860    schema: &'static str,
12861    #[serde(skip_serializing_if = "Option::is_none")]
12862    review_basis_schema: Option<&'static str>,
12863    capability_id: &'static str,
12864    catalog_status: &'static str,
12865    #[serde(skip_serializing_if = "Option::is_none")]
12866    catalog_schema: Option<&'static str>,
12867    #[serde(skip_serializing_if = "Option::is_none")]
12868    catalog: Option<WordPressCatalogDocument>,
12869    #[serde(skip_serializing_if = "Option::is_none")]
12870    inventory_import: Option<WordPressInventoryImportDocument>,
12871    #[serde(skip_serializing_if = "Option::is_none")]
12872    external_review: Option<WordPressExternalReviewDocument>,
12873    signal_count: u16,
12874    evidence_reference_count: u16,
12875    additional_request_count: u8,
12876    item_projected: bool,
12877    component_count: usize,
12878    advisory_count: usize,
12879    components: Vec<WordPressComponentDocument>,
12880    advisories: Vec<WordPressAdvisoryDocument>,
12881}
12882
12883#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12884const WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V2: &str = "security.wordpress-discovery-audit/v2";
12885#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12886const WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V3: &str = "security.wordpress-discovery-audit/v3";
12887#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12888const WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V4: &str = "security.wordpress-discovery-audit/v4";
12889#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12890const WORDPRESS_DISCOVERY_POLICY_ID: &str =
12891    "termivar.wordpress-deployment-aware-metadata-discovery/v1";
12892#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12893const WORDPRESS_PAGE_SCOPED_DISCOVERY_POLICY_ID: &str =
12894    "termivar.wordpress-page-scoped-metadata-discovery/v1";
12895#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12896const WORDPRESS_SUPPLIED_SESSION_DISCOVERY_POLICY_ID: &str =
12897    "termivar.wordpress-supplied-session-metadata-discovery/v1";
12898#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12899const WORDPRESS_FORM_LOGIN_DISCOVERY_POLICY_ID: &str =
12900    "termivar.wordpress-supplied-session-metadata-discovery/v2";
12901#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12902const WORDPRESS_DISCOVERY_LAYOUT_SCHEMA: &str = "security.wordpress-layout/v1";
12903#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12904const MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES: u64 = 16 * 1024;
12905#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12906const WORDPRESS_DISCOVERY_CAPABILITY_ID: &str = "technology.wordpress-metadata-discovery@1";
12907#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12908const MAX_WORDPRESS_DISCOVERY_AUDIT_SOURCES: usize = 32;
12909#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12910const MAX_WORDPRESS_DISCOVERY_AUDIT_REQUESTS: u8 = 12;
12911#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12912const MAX_WORDPRESS_PAGE_DISCOVERY_AUDIT_REQUESTS: u8 = 3;
12913#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12914const MAX_WORDPRESS_PAGE_DISCOVERY_AUDIT_CANDIDATES: u16 = 4_096;
12915#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12916const MAX_WORDPRESS_PAGE_DISCOVERY_AUDIT_INTERPRETED_BYTES: u64 = 768 * 1024;
12917#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12918const MAX_WORDPRESS_SUPPLIED_SESSION_PAGE_AUDIT_COUNT: usize = 4;
12919#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12920const MAX_WORDPRESS_SUPPLIED_SESSION_PAGE_AUDIT_INTERPRETED_BYTES: u64 = 256 * 1024;
12921#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12922const MAX_WORDPRESS_DISCOVERY_AUDIT_RESPONSE_BYTES: u64 = 2 * 1024 * 1024;
12923#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12924const MAX_WORDPRESS_DISCOVERY_AUDIT_REST_REQUESTS: usize = 1;
12925#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12926const MAX_WORDPRESS_DISCOVERY_AUDIT_THEME_REQUESTS: usize = 3;
12927#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12928const MAX_WORDPRESS_DISCOVERY_AUDIT_PLUGIN_REQUESTS: usize = 8;
12929#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12930const MAX_WORDPRESS_DISCOVERY_AUDIT_OMITTED_CANDIDATES: u64 = 4_064;
12931#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12932const MAX_WORDPRESS_DISCOVERY_AUDIT_METADATA_BYTES: usize = 256;
12933#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12934const MAX_WORDPRESS_DISCOVERY_AUDIT_VERSION_BYTES: usize = 64;
12935#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12936const MAX_WORDPRESS_DISCOVERY_AUDIT_NAMESPACE_BYTES: usize = 128;
12937#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12938const MAX_WORDPRESS_DISCOVERY_AUDIT_NAMESPACES: usize = 64;
12939/// Separate optional wire audit for explicitly selected metadata collection.
12940///
12941/// Collection details remain separate from the versioned WordPress review
12942/// result. A discovery-influenced review uses audit v7 so the vocabulary and
12943/// request accounting of previously published v1-v6 documents stay unchanged.
12944#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12945#[derive(Serialize)]
12946struct AssessmentWordPressDiscoveryAuditDocument {
12947    schema: &'static str,
12948    capability_id: &'static str,
12949    policy_id: &'static str,
12950    selected: bool,
12951    method: &'static str,
12952    credential_mode: &'static str,
12953    seed_count: u8,
12954    candidate_count: u8,
12955    candidate_limit_reached: bool,
12956    omitted_candidate_count: u64,
12957    attempted_request_count: u8,
12958    completed_response_count: u8,
12959    committed_response_count: u8,
12960    response_bytes: u64,
12961    source_count: usize,
12962    layout: WordPressDiscoveryLayoutDocument,
12963    #[serde(skip_serializing_if = "Option::is_none")]
12964    page_collection: Option<WordPressPageCollectionDocument>,
12965    #[serde(skip_serializing_if = "Option::is_none")]
12966    supplied_session_pages: Option<WordPressSuppliedSessionPageCollectionDocument>,
12967    sources: Vec<WordPressDiscoverySourceDocument>,
12968}
12969
12970#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12971#[derive(Serialize)]
12972struct WordPressSuppliedSessionPageCollectionDocument {
12973    mode: &'static str,
12974    policy_reference: String,
12975    application_reference: String,
12976    principal_reference: String,
12977    credential_mechanism: &'static str,
12978    session_epoch: u8,
12979    selected_count: u8,
12980    committed_count: u8,
12981    accepted_association_count: u8,
12982    rejected_association_count: u8,
12983    not_established_association_count: u8,
12984    not_evaluated_count: u8,
12985    interpreted_response_bytes: u64,
12986    pages: Vec<WordPressSuppliedSessionPageDocument>,
12987}
12988
12989#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
12990#[derive(Serialize)]
12991struct WordPressSuppliedSessionPageDocument {
12992    page_reference: String,
12993    resource_reference: String,
12994    resource_evidence_reference: Option<String>,
12995    acquisition: &'static str,
12996    association: &'static str,
12997    outcome: &'static str,
12998    interpreted_response_bytes: u64,
12999    evidence_reference_count: usize,
13000    evidence_references: Vec<String>,
13001    fingerprint_evaluation: &'static str,
13002}
13003
13004#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13005const WORDPRESS_ASSET_FINGERPRINT_AUDIT_SCHEMA: &str =
13006    "security.wordpress-asset-fingerprint-audit/v1";
13007#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13008const WORDPRESS_ASSET_FINGERPRINT_POLICY_ID: &str =
13009    "termivar.wordpress-observed-asset-fingerprint/v1";
13010#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13011const MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES: usize = 256;
13012#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13013const WORDPRESS_ASSET_FINGERPRINT_CAPABILITY_ID: &str =
13014    "technology.wordpress-asset-fingerprint-candidate@1";
13015
13016/// Optional conditional byte-comparison audit. It never becomes installed-
13017/// version evidence and remains separate from the discovery transport audit.
13018#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13019#[derive(Serialize)]
13020struct AssessmentWordPressAssetFingerprintAuditDocument {
13021    schema: &'static str,
13022    capability_id: &'static str,
13023    policy_id: &'static str,
13024    selected: bool,
13025    representation_profile: &'static str,
13026    finite_reference_scope: &'static str,
13027    same_release_assumption: &'static str,
13028    installed_version_assurance: &'static str,
13029    source_authenticity: &'static str,
13030    catalogue: WordPressAssetFingerprintCatalogueDocument,
13031    candidate_count: u64,
13032    selected_resource_count: usize,
13033    omitted_resource_count: usize,
13034    attempted_request_count: u8,
13035    reused_response_count: usize,
13036    fetched_response_count: usize,
13037    response_bytes: u64,
13038    stop: &'static str,
13039    resource_count: usize,
13040    resources: Vec<WordPressAssetFingerprintResourceDocument>,
13041    component_count: usize,
13042    components: Vec<WordPressAssetFingerprintComponentDocument>,
13043}
13044
13045#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13046#[derive(Serialize)]
13047struct WordPressAssetFingerprintCatalogueDocument {
13048    schema: &'static str,
13049    id: String,
13050    revision: String,
13051    source_namespace: String,
13052    byte_length: u64,
13053    sha256: String,
13054    semantic_sha256: String,
13055    retained_bytes: usize,
13056    component_count: usize,
13057    release_count: usize,
13058    file_count: usize,
13059    provenance: WordPressAssetFingerprintProvenanceDocument,
13060}
13061
13062#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13063#[derive(Serialize)]
13064struct WordPressAssetFingerprintProvenanceDocument {
13065    reference: String,
13066    revision: String,
13067    notices: Vec<WordPressAssetFingerprintNoticeDocument>,
13068}
13069
13070#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13071#[derive(Serialize)]
13072struct WordPressAssetFingerprintNoticeDocument {
13073    id: String,
13074    party: String,
13075    notice: String,
13076    license: String,
13077    license_reference: String,
13078}
13079
13080#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13081#[derive(Serialize)]
13082struct WordPressAssetFingerprintResourceDocument {
13083    component: WordPressComponentIdentityDocument,
13084    relative_path: String,
13085    resource_reference: String,
13086    source_page_references: Vec<String>,
13087    observed_variant_count: usize,
13088    acquisition: &'static str,
13089    outcome: &'static str,
13090    request_attempted: bool,
13091    interpreted_response_bytes: u64,
13092    response_bytes: u64,
13093    evidence_reference_count: usize,
13094    evidence_references: Vec<String>,
13095    #[serde(skip_serializing_if = "Option::is_none")]
13096    observation: Option<WordPressAssetFingerprintObservationDocument>,
13097}
13098
13099#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13100#[derive(Serialize)]
13101struct WordPressAssetFingerprintObservationDocument {
13102    byte_length: u64,
13103    sha256: String,
13104}
13105
13106#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13107#[derive(Serialize)]
13108struct WordPressAssetFingerprintComponentDocument {
13109    identity: WordPressComponentIdentityDocument,
13110    catalogue_component_listed: bool,
13111    state: &'static str,
13112    candidate_resource_count: usize,
13113    selected_resource_count: usize,
13114    completely_interpreted_resource_count: usize,
13115    omitted_resource_count: usize,
13116    informative_resource_count: usize,
13117    listed_matrix_complete: bool,
13118    compatible_release_ids: Vec<String>,
13119    undetermined_release_ids: Vec<String>,
13120    inconsistent_release_ids: Vec<String>,
13121    resource_count: usize,
13122    resources: Vec<WordPressAssetFingerprintResourceMatchDocument>,
13123    release_count: usize,
13124    releases: Vec<WordPressAssetFingerprintReleaseMatchDocument>,
13125}
13126
13127#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13128#[derive(Serialize)]
13129struct WordPressAssetFingerprintResourceMatchDocument {
13130    relative_path: String,
13131    distinct_observation_count: usize,
13132    informative: bool,
13133    release_relation_count: usize,
13134    release_relations: Vec<WordPressAssetFingerprintReleaseRelationDocument>,
13135}
13136
13137#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13138#[derive(Serialize)]
13139struct WordPressAssetFingerprintReleaseRelationDocument {
13140    release_id: String,
13141    relation: &'static str,
13142    #[serde(skip_serializing_if = "Option::is_none")]
13143    unknown_reason: Option<&'static str>,
13144}
13145
13146#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13147#[derive(Serialize)]
13148struct WordPressAssetFingerprintReleaseMatchDocument {
13149    release_id: String,
13150    version: String,
13151    build_variant: Option<String>,
13152    state: &'static str,
13153    source: WordPressAssetFingerprintReleaseSourceDocument,
13154}
13155
13156#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13157#[derive(Serialize)]
13158struct WordPressAssetFingerprintReleaseSourceDocument {
13159    reference: String,
13160    revision: String,
13161    notice_ids: Vec<String>,
13162}
13163
13164#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13165#[derive(Serialize)]
13166struct WordPressPageCollectionDocument {
13167    mode: &'static str,
13168    entry_page_reference: String,
13169    candidate_count: u16,
13170    selected_count: u8,
13171    omitted_candidate_count: u16,
13172    reused_response_count: u8,
13173    fetched_response_count: u8,
13174    not_observed_count: u8,
13175    rejected_response_count: u8,
13176    accepted_association_count: u8,
13177    rejected_association_count: u8,
13178    attempted_request_count: u8,
13179    completed_response_count: u8,
13180    committed_response_count: u8,
13181    interpreted_response_bytes: u64,
13182    response_bytes: u64,
13183    pages: Vec<WordPressPageSourceDocument>,
13184}
13185
13186#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13187#[derive(Serialize)]
13188struct WordPressPageSourceDocument {
13189    page_reference: String,
13190    acquisition: &'static str,
13191    association: &'static str,
13192    outcome: &'static str,
13193    request_attempted: bool,
13194    interpreted_response_bytes: u64,
13195    response_bytes: u64,
13196    evidence_reference_count: usize,
13197    evidence_references: Vec<String>,
13198}
13199
13200#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13201#[derive(Serialize)]
13202struct WordPressDiscoveryLayoutDocument {
13203    application_reference: String,
13204    #[serde(skip_serializing_if = "Option::is_none")]
13205    declaration: Option<WordPressDiscoveryLayoutDeclarationDocument>,
13206    roles: Vec<WordPressDiscoveryLayoutRoleDocument>,
13207    skipped_foreign_origin_count: u16,
13208    skipped_sibling_application_count: u16,
13209    conflicting_association_count: u16,
13210}
13211
13212#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13213#[derive(Serialize)]
13214struct WordPressDiscoveryLayoutDeclarationDocument {
13215    schema: &'static str,
13216    byte_length: u64,
13217    sha256: String,
13218}
13219
13220#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13221#[derive(Serialize)]
13222struct WordPressDiscoveryLayoutRoleDocument {
13223    role: &'static str,
13224    status: &'static str,
13225    basis: &'static str,
13226    #[serde(skip_serializing_if = "Option::is_none")]
13227    reference: Option<String>,
13228    candidate_count: u16,
13229}
13230
13231#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13232#[derive(Serialize)]
13233struct WordPressDiscoverySourceDocument {
13234    kind: &'static str,
13235    association: &'static str,
13236    resource_reference: String,
13237    #[serde(skip_serializing_if = "Option::is_none")]
13238    role_reference: Option<String>,
13239    #[serde(skip_serializing_if = "Option::is_none")]
13240    component: Option<WordPressComponentIdentityDocument>,
13241    parent_depth: u8,
13242    outcome: &'static str,
13243    request_attempted: bool,
13244    response_bytes: u64,
13245    evidence_reference_count: usize,
13246    evidence_references: Vec<String>,
13247    #[serde(skip_serializing_if = "Vec::is_empty")]
13248    source_page_references: Vec<String>,
13249    #[serde(skip_serializing_if = "Option::is_none")]
13250    source_supplied_session_page_references: Option<Vec<String>>,
13251    #[serde(skip_serializing_if = "Vec::is_empty")]
13252    namespaces: Vec<String>,
13253    #[serde(skip_serializing_if = "Option::is_none")]
13254    theme: Option<WordPressThemeDiscoveryDocument>,
13255    #[serde(skip_serializing_if = "Option::is_none")]
13256    plugin: Option<WordPressPluginDiscoveryDocument>,
13257}
13258
13259#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13260#[derive(Serialize)]
13261struct WordPressThemeDiscoveryDocument {
13262    #[serde(skip_serializing_if = "Option::is_none")]
13263    name: Option<String>,
13264    #[serde(skip_serializing_if = "Option::is_none")]
13265    version: Option<String>,
13266    #[serde(skip_serializing_if = "Option::is_none")]
13267    template: Option<String>,
13268    #[serde(skip_serializing_if = "Option::is_none")]
13269    requires_wordpress: Option<String>,
13270    #[serde(skip_serializing_if = "Option::is_none")]
13271    requires_php: Option<String>,
13272    #[serde(skip_serializing_if = "Option::is_none")]
13273    tested_up_to: Option<String>,
13274}
13275
13276#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13277#[derive(Serialize)]
13278struct WordPressPluginDiscoveryDocument {
13279    #[serde(skip_serializing_if = "Option::is_none")]
13280    name: Option<String>,
13281    #[serde(skip_serializing_if = "Option::is_none")]
13282    stable_tag: Option<String>,
13283    #[serde(skip_serializing_if = "Option::is_none")]
13284    requires_wordpress: Option<String>,
13285    #[serde(skip_serializing_if = "Option::is_none")]
13286    requires_php: Option<String>,
13287    #[serde(skip_serializing_if = "Option::is_none")]
13288    tested_up_to: Option<String>,
13289}
13290
13291#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13292#[derive(Serialize)]
13293struct WordPressCatalogDocument {
13294    id: String,
13295    revision: String,
13296    retrieved_on: String,
13297}
13298
13299#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13300#[derive(Serialize)]
13301struct WordPressComponentIdentityDocument {
13302    kind: &'static str,
13303    slug: String,
13304}
13305
13306#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13307#[derive(Serialize)]
13308struct WordPressComponentDocument {
13309    identity: WordPressComponentIdentityDocument,
13310    evidence_class: &'static str,
13311    identity_sources: Vec<&'static str>,
13312    confidence_classes: Vec<&'static str>,
13313    versions: Vec<WordPressVersionEvidenceDocument>,
13314    activation: Option<&'static str>,
13315    #[serde(skip_serializing_if = "Option::is_none")]
13316    inventory_status: Option<&'static str>,
13317}
13318
13319#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13320#[derive(Serialize)]
13321struct WordPressInventoryImportDocument {
13322    coverage: WordPressInventoryCoverageDocument,
13323    component_count: usize,
13324    limitations: Vec<WordPressInventoryLimitationDocument>,
13325    inputs: Vec<WordPressInventoryInputDocument>,
13326}
13327
13328#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13329#[derive(Serialize)]
13330struct WordPressInventoryCoverageDocument {
13331    core: &'static str,
13332    plugins: &'static str,
13333    themes: &'static str,
13334}
13335
13336#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13337#[derive(Serialize)]
13338struct WordPressInventoryLimitationDocument {
13339    declared_name: String,
13340    #[serde(skip_serializing_if = "Option::is_none")]
13341    version: Option<String>,
13342    status: &'static str,
13343    reason: &'static str,
13344}
13345
13346#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13347#[derive(Serialize)]
13348struct WordPressInventoryInputDocument {
13349    class: &'static str,
13350    byte_length: usize,
13351    sha256: String,
13352}
13353
13354#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13355#[derive(Serialize)]
13356struct WordPressExternalReviewDocument {
13357    source_namespace: &'static str,
13358    source_format: &'static str,
13359    mapping_revision: &'static str,
13360    #[serde(skip_serializing_if = "Option::is_none")]
13361    identity_mapping_policy: Option<&'static str>,
13362    #[serde(skip_serializing_if = "Option::is_none")]
13363    identity_source_assurance: Option<&'static str>,
13364    #[serde(skip_serializing_if = "Option::is_none")]
13365    resource_policy: Option<&'static str>,
13366    comparison_policy: &'static str,
13367    #[serde(skip_serializing_if = "Option::is_none")]
13368    comparison_profile: Option<&'static str>,
13369    #[serde(skip_serializing_if = "Option::is_none")]
13370    policy_selection: Option<&'static str>,
13371    #[serde(skip_serializing_if = "Option::is_none")]
13372    source_semantics_assurance: Option<&'static str>,
13373    input: WordPressExternalInputDocument,
13374    counts: WordPressExternalCountsDocument,
13375    notices: Vec<WordPressExternalNoticeDocument>,
13376    evaluations: Vec<WordPressExternalEvaluationDocument>,
13377    #[serde(skip_serializing_if = "Option::is_none")]
13378    identity_limitations: Option<Vec<WordPressExternalIdentityLimitationDocument>>,
13379}
13380
13381#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13382#[derive(Serialize)]
13383struct WordPressExternalInputDocument {
13384    byte_length: u64,
13385    sha256: String,
13386    semantic_sha256: String,
13387    #[serde(skip_serializing_if = "Option::is_none")]
13388    accounted_retained_bytes: Option<usize>,
13389}
13390
13391#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13392#[derive(Serialize)]
13393struct WordPressExternalCountsDocument {
13394    parsed_records: usize,
13395    software_associations: usize,
13396    #[serde(skip_serializing_if = "Option::is_none")]
13397    exact_identity_associations: Option<usize>,
13398    #[serde(skip_serializing_if = "Option::is_none")]
13399    candidate_identity_associations: Option<usize>,
13400    #[serde(skip_serializing_if = "Option::is_none")]
13401    ambiguous_identity_associations: Option<usize>,
13402    #[serde(skip_serializing_if = "Option::is_none")]
13403    unresolved_identity_associations: Option<usize>,
13404    #[serde(skip_serializing_if = "Option::is_none")]
13405    projected_identity_limitations: Option<usize>,
13406    #[serde(skip_serializing_if = "Option::is_none")]
13407    unprojected_identity_limitations: Option<usize>,
13408    selected_associations: usize,
13409    evaluable_associations: usize,
13410    unsupported_associations: usize,
13411    excluded_associations: usize,
13412    #[serde(skip_serializing_if = "Option::is_none")]
13413    mapped_unselected_associations: Option<usize>,
13414    #[serde(skip_serializing_if = "Option::is_none")]
13415    within_associations: Option<usize>,
13416    #[serde(skip_serializing_if = "Option::is_none")]
13417    outside_associations: Option<usize>,
13418    #[serde(skip_serializing_if = "Option::is_none")]
13419    indeterminate_associations: Option<usize>,
13420    #[serde(skip_serializing_if = "Option::is_none")]
13421    selected_ranges: Option<usize>,
13422    #[serde(skip_serializing_if = "Option::is_none")]
13423    evaluated_ranges: Option<usize>,
13424    #[serde(skip_serializing_if = "Option::is_none")]
13425    containing_ranges: Option<usize>,
13426    #[serde(skip_serializing_if = "Option::is_none")]
13427    noncontaining_ranges: Option<usize>,
13428    #[serde(skip_serializing_if = "Option::is_none")]
13429    unsupported_ranges: Option<usize>,
13430    #[serde(skip_serializing_if = "Option::is_none")]
13431    invalid_ranges: Option<usize>,
13432    #[serde(skip_serializing_if = "Option::is_none")]
13433    not_evaluated_ranges: Option<usize>,
13434    #[serde(skip_serializing_if = "Option::is_none")]
13435    partial_range_coverage_associations: Option<usize>,
13436}
13437
13438#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13439#[derive(Serialize)]
13440struct WordPressExternalNoticeDocument {
13441    id: String,
13442    message: String,
13443    party: String,
13444    notice: String,
13445    license: String,
13446    license_url: String,
13447}
13448
13449#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13450#[derive(Serialize)]
13451struct WordPressExternalEvaluationDocument {
13452    key: WordPressExternalEvaluationKeyDocument,
13453    #[serde(skip_serializing_if = "Option::is_none")]
13454    identity_mapping: Option<&'static str>,
13455    #[serde(skip_serializing_if = "Option::is_none")]
13456    identity_collision_raw_count: Option<usize>,
13457    title: String,
13458    display_name: String,
13459    informational: bool,
13460    description: String,
13461    references: Vec<String>,
13462    record_reference: Option<String>,
13463    cwe: Option<WordPressExternalCweDocument>,
13464    cvss: Option<WordPressExternalCvssDocument>,
13465    cve: Option<String>,
13466    cve_link: Option<String>,
13467    researchers: Vec<String>,
13468    source_dates: WordPressExternalSourceDatesDocument,
13469    affected_ranges: Vec<WordPressExternalRangeDocument>,
13470    #[serde(skip_serializing_if = "Option::is_none")]
13471    range_evaluations: Option<Vec<WordPressExternalRangeEvaluationDocument>>,
13472    source_patched: bool,
13473    source_patched_versions: Vec<String>,
13474    source_remediation: String,
13475    notice_ids: Vec<String>,
13476    component_evidence: &'static str,
13477    version_evidence_resolution: WordPressExternalVersionEvidenceResolutionDocument,
13478    version_relation: &'static str,
13479    #[serde(skip_serializing_if = "Option::is_none")]
13480    version_relation_reason: Option<&'static str>,
13481    applicability: &'static str,
13482    execution: WordPressExternalExecutionDocument,
13483}
13484
13485#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13486#[derive(Serialize)]
13487struct WordPressExternalIdentityLimitationDocument {
13488    key: WordPressExternalIdentityLimitationKeyDocument,
13489    identity_resolution: &'static str,
13490    title: String,
13491    display_name: String,
13492    references: Vec<String>,
13493    record_reference: Option<String>,
13494    affected_ranges: Vec<WordPressExternalRangeDocument>,
13495    range_evaluations: Vec<WordPressExternalRangeEvaluationDocument>,
13496    source_patched: bool,
13497    source_patched_versions: Vec<String>,
13498    source_remediation: String,
13499    notice_ids: Vec<String>,
13500    version_relation: &'static str,
13501    version_relation_reason: &'static str,
13502    applicability: &'static str,
13503    execution: WordPressExternalExecutionDocument,
13504}
13505
13506#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13507#[derive(Serialize)]
13508struct WordPressExternalIdentityLimitationKeyDocument {
13509    source_namespace: &'static str,
13510    upstream_id: String,
13511    source_component: WordPressExternalSourceComponentIdentityDocument,
13512    source_association_fingerprint: String,
13513}
13514
13515#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13516#[derive(Serialize)]
13517struct WordPressExternalVersionEvidenceResolutionDocument {
13518    status: &'static str,
13519    evidence_row_count: usize,
13520    distinct_spelling_count: usize,
13521    #[serde(skip_serializing_if = "Option::is_none")]
13522    semantic_status: Option<&'static str>,
13523    #[serde(skip_serializing_if = "Option::is_none")]
13524    semantic_reason: Option<&'static str>,
13525}
13526
13527#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13528#[derive(Serialize)]
13529struct WordPressExternalRangeEvaluationDocument {
13530    relation: &'static str,
13531    reason: &'static str,
13532}
13533
13534#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13535#[derive(Serialize)]
13536struct WordPressExternalEvaluationKeyDocument {
13537    source_namespace: String,
13538    upstream_id: String,
13539    component: WordPressComponentIdentityDocument,
13540    #[serde(skip_serializing_if = "Option::is_none")]
13541    source_component: Option<WordPressExternalSourceComponentIdentityDocument>,
13542    #[serde(skip_serializing_if = "Option::is_none")]
13543    source_association_fingerprint: Option<String>,
13544}
13545
13546#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13547#[derive(Serialize)]
13548struct WordPressExternalSourceComponentIdentityDocument {
13549    kind: &'static str,
13550    slug: String,
13551}
13552
13553#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13554#[derive(Serialize)]
13555struct WordPressExternalCweDocument {
13556    id: u32,
13557    name: String,
13558    description: String,
13559}
13560
13561#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13562#[derive(Serialize)]
13563struct WordPressExternalCvssDocument {
13564    vector: String,
13565    score: String,
13566    rating: &'static str,
13567}
13568
13569#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13570#[derive(Serialize)]
13571struct WordPressExternalSourceDatesDocument {
13572    published: Option<String>,
13573    updated: Option<String>,
13574}
13575
13576#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13577#[derive(Serialize)]
13578struct WordPressExternalRangeDocument {
13579    label: String,
13580    from_kind: &'static str,
13581    from_version: String,
13582    from_inclusive: bool,
13583    to_kind: &'static str,
13584    to_version: String,
13585    to_inclusive: bool,
13586}
13587
13588#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13589#[derive(Serialize)]
13590struct WordPressExternalExecutionDocument {
13591    exploit_execution: &'static str,
13592    impact_validation: &'static str,
13593}
13594
13595#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13596#[derive(Serialize)]
13597struct WordPressVersionEvidenceDocument {
13598    value: String,
13599    source: &'static str,
13600    confidence: &'static str,
13601}
13602
13603#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13604#[derive(Serialize)]
13605struct WordPressAdvisoryDocument {
13606    id: String,
13607    component: WordPressComponentIdentityDocument,
13608    source: WordPressAdvisorySourceDocument,
13609    cve: Option<String>,
13610    summary: String,
13611    affected_ranges: Vec<WordPressAffectedRangeDocument>,
13612    fixed_versions: Vec<String>,
13613    prerequisites: Vec<WordPressPrerequisiteDocument>,
13614    remediation: Option<String>,
13615    #[serde(skip_serializing_if = "Option::is_none")]
13616    comparison_profile: Option<&'static str>,
13617    #[serde(skip_serializing_if = "Option::is_none")]
13618    version_resolution: Option<&'static str>,
13619    #[serde(skip_serializing_if = "Option::is_none")]
13620    version_resolution_reason: Option<&'static str>,
13621    component_evidence: &'static str,
13622    version_relation: &'static str,
13623    applicability: &'static str,
13624    exploit_execution: &'static str,
13625    impact_validation: &'static str,
13626}
13627
13628#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13629#[derive(Serialize)]
13630struct WordPressAdvisorySourceDocument {
13631    reference: String,
13632    revision: String,
13633    retrieved_on: String,
13634    usage_basis: String,
13635}
13636
13637#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13638#[derive(Serialize)]
13639struct WordPressAffectedRangeDocument {
13640    lower: Option<WordPressVersionEndpointDocument>,
13641    upper: Option<WordPressVersionEndpointDocument>,
13642}
13643
13644#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13645#[derive(Serialize)]
13646struct WordPressVersionEndpointDocument {
13647    declared: String,
13648    inclusive: bool,
13649}
13650
13651#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13652#[derive(Serialize)]
13653struct WordPressPrerequisiteDocument {
13654    kind: &'static str,
13655    expected: String,
13656    #[serde(skip_serializing_if = "Option::is_none")]
13657    patch_id: Option<String>,
13658    outcome: &'static str,
13659}
13660
13661#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13662#[derive(Clone, Copy, PartialEq, Eq)]
13663enum WordPressPresentationGroup {
13664    ReviewCandidate,
13665    Contradicted,
13666    Limitation,
13667}
13668
13669#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13670struct WordPressPresentationCounts {
13671    review_candidates: usize,
13672    contradicted: usize,
13673    limitations: usize,
13674    source_guidance: usize,
13675}
13676
13677#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
13678impl AssessmentWordPressAuditDocument {
13679    fn from_audit(audit: &WebAssessmentWordPressAudit) -> Self {
13680        let result = audit.result();
13681        let profiled = result.catalog_schema() == Some(WordPressAdvisoryCatalogSchema::V2);
13682        let external_schema = result.external_review().is_some();
13683        let explicit_external_schema = result.external_review().is_some_and(|review| {
13684            review.comparison_policy()
13685                == WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1
13686        });
13687        let source_identity_external_schema = result
13688            .external_review()
13689            .is_some_and(crate::wordpress_review::WordPressExternalReview::requires_audit_v6);
13690        let inventory_import = result.inventory_summary().map(|summary| {
13691            let coverage = summary.coverage();
13692            WordPressInventoryImportDocument {
13693                coverage: WordPressInventoryCoverageDocument {
13694                    core: wordpress_inventory_category_status(coverage.core()),
13695                    plugins: wordpress_inventory_category_status(coverage.plugins()),
13696                    themes: wordpress_inventory_category_status(coverage.themes()),
13697                },
13698                component_count: summary.component_count(),
13699                limitations: summary
13700                    .limitations()
13701                    .iter()
13702                    .map(|limitation| WordPressInventoryLimitationDocument {
13703                        declared_name: limitation.declared_name().to_owned(),
13704                        version: limitation.version().map(str::to_owned),
13705                        status: wordpress_inventory_entry_status(limitation.status()),
13706                        reason: wordpress_inventory_limitation_reason(limitation.reason()),
13707                    })
13708                    .collect(),
13709                inputs: result
13710                    .local_input_provenance()
13711                    .iter()
13712                    .map(|input| WordPressInventoryInputDocument {
13713                        class: wordpress_local_input_class(input.class()),
13714                        byte_length: input.byte_length(),
13715                        sha256: lowercase_hex(input.sha256()),
13716                    })
13717                    .collect(),
13718            }
13719        });
13720        let inventory_schema = inventory_import.is_some();
13721        let catalog = result
13722            .catalog_metadata()
13723            .map(|metadata| WordPressCatalogDocument {
13724                id: metadata.id().to_owned(),
13725                revision: metadata.revision().to_owned(),
13726                retrieved_on: metadata.retrieved_on().to_owned(),
13727            });
13728        let components = result
13729            .components()
13730            .iter()
13731            .map(|component| WordPressComponentDocument {
13732                identity: wordpress_component_identity(component.identity()),
13733                evidence_class: wordpress_evidence_class(component.evidence_class()),
13734                identity_sources: component
13735                    .identity_sources()
13736                    .iter()
13737                    .copied()
13738                    .map(wordpress_evidence_source)
13739                    .collect(),
13740                confidence_classes: component
13741                    .confidence_classes()
13742                    .iter()
13743                    .copied()
13744                    .map(wordpress_confidence)
13745                    .collect(),
13746                versions: component
13747                    .versions()
13748                    .iter()
13749                    .map(|version| WordPressVersionEvidenceDocument {
13750                        value: version.value().to_owned(),
13751                        source: wordpress_evidence_source(version.source()),
13752                        confidence: wordpress_confidence(version.confidence()),
13753                    })
13754                    .collect(),
13755                activation: component.activation().map(wordpress_activation),
13756                inventory_status: component
13757                    .inventory_status()
13758                    .map(wordpress_inventory_entry_status),
13759            })
13760            .collect::<Vec<_>>();
13761        let external_review = result
13762            .external_review()
13763            .map(wordpress_external_review_document);
13764        let advisories = result
13765            .advisories()
13766            .iter()
13767            .map(|evaluation| {
13768                let record = evaluation.record();
13769                WordPressAdvisoryDocument {
13770                    id: record.id().to_owned(),
13771                    component: wordpress_component_identity(record.component()),
13772                    source: WordPressAdvisorySourceDocument {
13773                        reference: record.source().reference().to_owned(),
13774                        revision: record.source().revision().to_owned(),
13775                        retrieved_on: record.source().retrieved_on().to_owned(),
13776                        usage_basis: record.source().usage_basis().to_owned(),
13777                    },
13778                    cve: record.cve().map(str::to_owned),
13779                    summary: record.summary().to_owned(),
13780                    affected_ranges: if profiled {
13781                        record
13782                            .profiled_affected_ranges()
13783                            .iter()
13784                            .map(|range| WordPressAffectedRangeDocument {
13785                                lower: range.lower().map(|endpoint| {
13786                                    WordPressVersionEndpointDocument {
13787                                        declared: endpoint.declared().to_owned(),
13788                                        inclusive: endpoint.inclusive(),
13789                                    }
13790                                }),
13791                                upper: range.upper().map(|endpoint| {
13792                                    WordPressVersionEndpointDocument {
13793                                        declared: endpoint.declared().to_owned(),
13794                                        inclusive: endpoint.inclusive(),
13795                                    }
13796                                }),
13797                            })
13798                            .collect()
13799                    } else {
13800                        record
13801                            .affected_ranges()
13802                            .iter()
13803                            .map(|range| WordPressAffectedRangeDocument {
13804                                lower: range.lower().map(|endpoint| {
13805                                    WordPressVersionEndpointDocument {
13806                                        declared: endpoint.declared().to_owned(),
13807                                        inclusive: endpoint.inclusive(),
13808                                    }
13809                                }),
13810                                upper: range.upper().map(|endpoint| {
13811                                    WordPressVersionEndpointDocument {
13812                                        declared: endpoint.declared().to_owned(),
13813                                        inclusive: endpoint.inclusive(),
13814                                    }
13815                                }),
13816                            })
13817                            .collect()
13818                    },
13819                    fixed_versions: record.fixed_versions().to_vec(),
13820                    prerequisites: evaluation
13821                        .prerequisites()
13822                        .iter()
13823                        .map(|prerequisite| {
13824                            wordpress_prerequisite(
13825                                prerequisite.prerequisite(),
13826                                prerequisite.outcome(),
13827                            )
13828                        })
13829                        .collect(),
13830                    remediation: record.remediation().map(str::to_owned),
13831                    comparison_profile: profiled
13832                        .then_some(wordpress_comparison_profile(record.comparison_profile())),
13833                    version_resolution: evaluation
13834                        .version_resolution()
13835                        .map(wordpress_version_resolution),
13836                    version_resolution_reason: evaluation
13837                        .version_resolution_reason()
13838                        .map(wordpress_version_resolution_reason),
13839                    component_evidence: wordpress_evidence_class(evaluation.component_evidence()),
13840                    version_relation: wordpress_version_relation(evaluation.version_relation()),
13841                    applicability: wordpress_applicability(evaluation.applicability()),
13842                    exploit_execution: wordpress_execution(evaluation.exploit_execution()),
13843                    impact_validation: wordpress_execution(evaluation.impact_validation()),
13844                }
13845            })
13846            .collect::<Vec<_>>();
13847        let review_basis_schema = if source_identity_external_schema {
13848            "security.wordpress-review-audit/v6"
13849        } else if explicit_external_schema {
13850            "security.wordpress-review-audit/v5"
13851        } else if external_schema {
13852            "security.wordpress-review-audit/v4"
13853        } else if inventory_schema {
13854            "security.wordpress-review-audit/v3"
13855        } else if profiled {
13856            "security.wordpress-review-audit/v2"
13857        } else {
13858            "security.wordpress-review-audit/v1"
13859        };
13860        let discovery_influenced = audit.discovery().is_some();
13861        let fingerprint_influenced = audit.asset_fingerprints().is_some();
13862        Self {
13863            schema: if fingerprint_influenced {
13864                "security.wordpress-review-audit/v8"
13865            } else if discovery_influenced {
13866                "security.wordpress-review-audit/v7"
13867            } else {
13868                review_basis_schema
13869            },
13870            review_basis_schema: (discovery_influenced || fingerprint_influenced)
13871                .then_some(review_basis_schema),
13872            capability_id: WORDPRESS_REVIEW_CAPABILITY_ID,
13873            catalog_status: wordpress_catalog_status(result.catalog_status()),
13874            catalog_schema: if inventory_schema && !external_schema {
13875                result
13876                    .catalog_schema()
13877                    .map(WordPressAdvisoryCatalogSchema::id)
13878            } else {
13879                None
13880            },
13881            catalog,
13882            inventory_import,
13883            external_review,
13884            signal_count: audit.signal_count(),
13885            evidence_reference_count: audit.evidence_reference_count(),
13886            additional_request_count: audit.additional_request_count(),
13887            item_projected: audit.item_projected(),
13888            component_count: components.len(),
13889            advisory_count: advisories.len(),
13890            components,
13891            advisories,
13892        }
13893    }
13894
13895    fn validate(&self, items: &[AssessmentItemDocument<'_>]) -> Result<(), ReportError> {
13896        let projected = items
13897            .iter()
13898            .filter(|item| item.capability_id == WORDPRESS_REVIEW_CAPABILITY_ID)
13899            .count();
13900        let discovery_influenced = matches!(
13901            self.schema,
13902            "security.wordpress-review-audit/v7" | "security.wordpress-review-audit/v8"
13903        );
13904        let review_basis_schema = if discovery_influenced {
13905            self.review_basis_schema.unwrap_or("")
13906        } else {
13907            self.schema
13908        };
13909        let external = matches!(
13910            review_basis_schema,
13911            "security.wordpress-review-audit/v4"
13912                | "security.wordpress-review-audit/v5"
13913                | "security.wordpress-review-audit/v6"
13914        );
13915        let source_identity_external = review_basis_schema == "security.wordpress-review-audit/v6";
13916        let explicit_external = match review_basis_schema {
13917            "security.wordpress-review-audit/v5" => true,
13918            "security.wordpress-review-audit/v6" => self
13919                .external_review
13920                .as_ref()
13921                .is_some_and(|review| review.comparison_profile.is_some()),
13922            _ => false,
13923        };
13924        let catalog_consistent = if external {
13925            self.catalog_status == "evaluated" && self.catalog.is_none()
13926        } else {
13927            matches!(
13928                (self.catalog_status, self.catalog.as_ref()),
13929                ("catalogue_not_supplied", None) | ("evaluated", Some(_))
13930            )
13931        };
13932        let version_evidence_count = self
13933            .components
13934            .iter()
13935            .try_fold(0_usize, |count, component| {
13936                count.checked_add(component.versions.len())
13937            });
13938        let discovery_evidence_present = self.components.iter().any(|component| {
13939            component
13940                .identity_sources
13941                .contains(&"theme_stylesheet_declaration")
13942                || component
13943                    .versions
13944                    .iter()
13945                    .any(|version| version.source == "theme_stylesheet_declaration")
13946        });
13947        let inventory = review_basis_schema == "security.wordpress-review-audit/v3";
13948        let profiled = review_basis_schema == "security.wordpress-review-audit/v2"
13949            || (inventory && self.catalog_schema == Some(WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2));
13950        let basis_schema_valid = matches!(
13951            review_basis_schema,
13952            "security.wordpress-review-audit/v1"
13953                | "security.wordpress-review-audit/v2"
13954                | "security.wordpress-review-audit/v3"
13955                | "security.wordpress-review-audit/v4"
13956                | "security.wordpress-review-audit/v5"
13957                | "security.wordpress-review-audit/v6"
13958        );
13959        let schema_valid = basis_schema_valid
13960            && if discovery_influenced {
13961                self.review_basis_schema == Some(review_basis_schema)
13962            } else {
13963                self.review_basis_schema.is_none()
13964            };
13965        let catalogue_schema_valid = if external {
13966            self.catalog_schema.is_none()
13967        } else if inventory {
13968            matches!(
13969                (self.catalog_status, self.catalog_schema),
13970                ("catalogue_not_supplied", None)
13971                    | (
13972                        "evaluated",
13973                        Some(
13974                            WORDPRESS_ADVISORY_CATALOG_SCHEMA
13975                                | WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2
13976                        )
13977                    )
13978            )
13979        } else {
13980            self.catalog_schema.is_none()
13981        };
13982        let inventory_valid = match (&self.inventory_import, inventory, external) {
13983            (Some(import), true, false) | (Some(import), false, true) => {
13984                import.is_valid(&self.components)
13985            },
13986            (None, false, _) => self
13987                .components
13988                .iter()
13989                .all(|component| component.inventory_status.is_none()),
13990            _ => false,
13991        };
13992        let external_valid = match (&self.external_review, external) {
13993            (Some(review), true) => review.is_valid(
13994                &self.components,
13995                explicit_external,
13996                source_identity_external,
13997            ),
13998            (None, false) => true,
13999            _ => false,
14000        };
14001        if !schema_valid
14002            || self.capability_id != WORDPRESS_REVIEW_CAPABILITY_ID
14003            || !catalog_consistent
14004            || !catalogue_schema_valid
14005            || !inventory_valid
14006            || !external_valid
14007            || (!discovery_influenced && discovery_evidence_present)
14008            || if discovery_influenced {
14009                self.additional_request_count > MAX_WORDPRESS_DISCOVERY_AUDIT_REQUESTS
14010            } else {
14011                self.additional_request_count != 0
14012            }
14013            || usize::from(self.signal_count) > MAX_WORDPRESS_SIGNALS
14014            || usize::from(self.evidence_reference_count) > MAX_WORDPRESS_SIGNALS
14015            || self.component_count != self.components.len()
14016            || self.component_count > MAX_WORDPRESS_RESULT_COMPONENTS
14017            || version_evidence_count
14018                .is_none_or(|count| count > MAX_WORDPRESS_RESULT_VERSION_EVIDENCE)
14019            || self.advisory_count != self.advisories.len()
14020            || self.advisory_count > MAX_WORDPRESS_ADVISORY_RECORDS
14021            || (external && self.advisory_count != 0)
14022            || projected > 1
14023            || self.item_projected != (projected == 1)
14024            || self.item_projected != (self.signal_count > 0)
14025            || self.item_projected != (self.evidence_reference_count > 0)
14026            || ((review_basis_schema == "security.wordpress-review-audit/v2" || external)
14027                && self.catalog_status != "evaluated")
14028            || self
14029                .advisories
14030                .iter()
14031                .any(|advisory| !advisory.profile_fields_are_valid(profiled))
14032        {
14033            return Err(ReportError::Serialization);
14034        }
14035        Ok(())
14036    }
14037
14038    fn wire_json(&self) -> Result<String, ReportError> {
14039        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
14040    }
14041
14042    fn metadata(&self) -> Vec<(&'static str, String)> {
14043        let mut metadata = vec![
14044            ("Audit schema", self.schema.to_owned()),
14045            ("Capability", self.capability_id.to_owned()),
14046            ("Catalog status", self.catalog_status.to_owned()),
14047            ("Signal count", self.signal_count.to_string()),
14048            (
14049                "Evidence reference count",
14050                self.evidence_reference_count.to_string(),
14051            ),
14052            (
14053                "Additional request count",
14054                self.additional_request_count.to_string(),
14055            ),
14056            ("Item projected", self.item_projected.to_string()),
14057            ("Component count", self.component_count.to_string()),
14058            ("Advisory count", self.advisory_count.to_string()),
14059        ];
14060        if let Some(schema) = self.review_basis_schema {
14061            metadata.insert(1, ("Review basis schema", schema.to_owned()));
14062        }
14063        metadata
14064    }
14065
14066    fn presentation_counts(&self) -> WordPressPresentationCounts {
14067        let mut counts = WordPressPresentationCounts {
14068            review_candidates: 0,
14069            contradicted: 0,
14070            limitations: 0,
14071            source_guidance: self.external_review.as_ref().map_or(0, |external| {
14072                let evaluated = external
14073                    .evaluations
14074                    .iter()
14075                    .filter(|evaluation| external_evaluation_has_source_guidance(evaluation))
14076                    .count();
14077                let limitations = external
14078                    .identity_limitations
14079                    .as_ref()
14080                    .map_or(0, |limitations| {
14081                        limitations
14082                            .iter()
14083                            .filter(|limitation| {
14084                                limitation.source_patched
14085                                    || !limitation.source_patched_versions.is_empty()
14086                                    || !limitation.source_remediation.is_empty()
14087                            })
14088                            .count()
14089                    });
14090                evaluated + limitations
14091            }),
14092        };
14093        if let Some(external) = &self.external_review {
14094            for evaluation in &external.evaluations {
14095                match wordpress_external_advisory_group(evaluation) {
14096                    WordPressPresentationGroup::ReviewCandidate => counts.review_candidates += 1,
14097                    WordPressPresentationGroup::Contradicted => counts.contradicted += 1,
14098                    WordPressPresentationGroup::Limitation => counts.limitations += 1,
14099                }
14100            }
14101            counts.limitations = counts
14102                .limitations
14103                .saturating_add(external.identity_limitations.as_ref().map_or(0, Vec::len))
14104                .saturating_add(
14105                    external
14106                        .counts
14107                        .unprojected_identity_limitations
14108                        .unwrap_or(0),
14109                );
14110        }
14111        for advisory in &self.advisories {
14112            match wordpress_advisory_group(advisory) {
14113                WordPressPresentationGroup::ReviewCandidate => counts.review_candidates += 1,
14114                WordPressPresentationGroup::Contradicted => counts.contradicted += 1,
14115                WordPressPresentationGroup::Limitation => counts.limitations += 1,
14116            }
14117            if advisory_has_source_guidance(advisory) {
14118                counts.source_guidance += 1;
14119            }
14120        }
14121        counts
14122    }
14123}
14124
14125#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
14126impl AssessmentWordPressDiscoveryAuditDocument {
14127    fn from_wordpress_audit(
14128        audit: &WebAssessmentWordPressAudit,
14129        evidence_references: &[String],
14130    ) -> Result<Option<Self>, ReportError> {
14131        let Some(discovery) = audit.discovery() else {
14132            if evidence_references.is_empty() {
14133                return Ok(None);
14134            }
14135            return Err(ReportError::Serialization);
14136        };
14137        let mut evidence_references = evidence_references.iter();
14138        let page_collection = discovery
14139            .page_scope()
14140            .map(|page_scope| {
14141                let pages = page_scope
14142                    .pages()
14143                    .iter()
14144                    .map(|page| {
14145                        let evidence_reference_count = page.evidence_ids().len();
14146                        let page_evidence_references = evidence_references
14147                            .by_ref()
14148                            .take(evidence_reference_count)
14149                            .cloned()
14150                            .collect::<Vec<_>>();
14151                        if page_evidence_references.len() != evidence_reference_count {
14152                            return Err(ReportError::Serialization);
14153                        }
14154                        Ok(WordPressPageSourceDocument {
14155                            page_reference: page.page_reference().to_owned(),
14156                            acquisition: page.acquisition().as_str(),
14157                            association: page.association().as_str(),
14158                            outcome: page.outcome().as_str(),
14159                            request_attempted: page.request_attempted(),
14160                            interpreted_response_bytes: page.interpreted_response_bytes(),
14161                            response_bytes: page.response_bytes(),
14162                            evidence_reference_count,
14163                            evidence_references: page_evidence_references,
14164                        })
14165                    })
14166                    .collect::<Result<Vec<_>, ReportError>>()?;
14167                Ok(WordPressPageCollectionDocument {
14168                    mode: page_scope.mode().as_str(),
14169                    entry_page_reference: page_scope.entry_page_reference().to_owned(),
14170                    candidate_count: page_scope.candidate_count(),
14171                    selected_count: page_scope.selected_count(),
14172                    omitted_candidate_count: page_scope.omitted_candidate_count(),
14173                    reused_response_count: page_scope.reused_response_count(),
14174                    fetched_response_count: page_scope.fetched_response_count(),
14175                    not_observed_count: page_scope.not_observed_count(),
14176                    rejected_response_count: page_scope.rejected_response_count(),
14177                    accepted_association_count: page_scope.accepted_association_count(),
14178                    rejected_association_count: page_scope.rejected_association_count(),
14179                    attempted_request_count: page_scope.attempted_request_count(),
14180                    completed_response_count: page_scope.completed_response_count(),
14181                    committed_response_count: page_scope.committed_response_count(),
14182                    interpreted_response_bytes: page_scope.interpreted_response_bytes(),
14183                    response_bytes: page_scope.response_bytes(),
14184                    pages,
14185                })
14186            })
14187            .transpose()?;
14188        let supplied_session_pages = discovery
14189            .supplied_session_pages()
14190            .map(|scope| {
14191                let pages = scope
14192                    .pages()
14193                    .iter()
14194                    .map(|page| {
14195                        let evidence_reference_count = page.evidence_ids().len();
14196                        let page_evidence_references = evidence_references
14197                            .by_ref()
14198                            .take(evidence_reference_count)
14199                            .cloned()
14200                            .collect::<Vec<_>>();
14201                        if page_evidence_references.len() != evidence_reference_count {
14202                            return Err(ReportError::Serialization);
14203                        }
14204                        Ok(WordPressSuppliedSessionPageDocument {
14205                            page_reference: page.context_page_reference().to_owned(),
14206                            resource_reference: page.resource_reference().to_owned(),
14207                            resource_evidence_reference: page
14208                                .resource_evidence_reference()
14209                                .map(str::to_owned),
14210                            acquisition: "reused_supplied_session_response",
14211                            association: page.association().as_str(),
14212                            outcome: page.outcome().as_str(),
14213                            interpreted_response_bytes: page.interpreted_response_bytes(),
14214                            evidence_reference_count,
14215                            evidence_references: page_evidence_references,
14216                            fingerprint_evaluation: page.fingerprint_evaluation(),
14217                        })
14218                    })
14219                    .collect::<Result<Vec<_>, ReportError>>()?;
14220                Ok(WordPressSuppliedSessionPageCollectionDocument {
14221                    mode: scope.mode(),
14222                    policy_reference: scope.policy_reference().to_owned(),
14223                    application_reference: scope.application_reference().to_owned(),
14224                    principal_reference: scope.principal_reference().to_owned(),
14225                    credential_mechanism: scope.credential_mechanism().as_str(),
14226                    session_epoch: scope.epoch(),
14227                    selected_count: scope.selected_count(),
14228                    committed_count: scope.committed_count(),
14229                    accepted_association_count: scope.accepted_count(),
14230                    rejected_association_count: scope.rejected_count(),
14231                    not_established_association_count: scope.not_established_count(),
14232                    not_evaluated_count: scope.not_evaluated_count(),
14233                    interpreted_response_bytes: scope.interpreted_response_bytes(),
14234                    pages,
14235                })
14236            })
14237            .transpose()?;
14238        let supplied_session_scoped = supplied_session_pages.is_some();
14239        let sources = discovery
14240            .sources()
14241            .iter()
14242            .map(|source| {
14243                let evidence_reference_count = source.evidence_ids().len();
14244                let source_evidence_references = evidence_references
14245                    .by_ref()
14246                    .take(evidence_reference_count)
14247                    .cloned()
14248                    .collect::<Vec<_>>();
14249                if source_evidence_references.len() != evidence_reference_count {
14250                    return Err(ReportError::Serialization);
14251                }
14252                let component =
14253                    source
14254                        .component_kind()
14255                        .zip(source.component_slug())
14256                        .map(|(kind, slug)| WordPressComponentIdentityDocument {
14257                            kind: wordpress_component_kind(kind),
14258                            slug: slug.to_owned(),
14259                        });
14260                let theme = source
14261                    .theme()
14262                    .map(|metadata| WordPressThemeDiscoveryDocument {
14263                        name: metadata.name().map(str::to_owned),
14264                        version: metadata.version().map(str::to_owned),
14265                        template: metadata.template().map(str::to_owned),
14266                        requires_wordpress: metadata.requires_wordpress().map(str::to_owned),
14267                        requires_php: metadata.requires_php().map(str::to_owned),
14268                        tested_up_to: metadata.tested_up_to().map(str::to_owned),
14269                    });
14270                let plugin = source
14271                    .plugin()
14272                    .map(|metadata| WordPressPluginDiscoveryDocument {
14273                        name: metadata.name().map(str::to_owned),
14274                        stable_tag: metadata.stable_tag().map(str::to_owned),
14275                        requires_wordpress: metadata.requires_wordpress().map(str::to_owned),
14276                        requires_php: metadata.requires_php().map(str::to_owned),
14277                        tested_up_to: metadata.tested_up_to().map(str::to_owned),
14278                    });
14279                Ok(WordPressDiscoverySourceDocument {
14280                    kind: source.kind().as_str(),
14281                    association: source.association().as_str(),
14282                    resource_reference: source.resource_reference().to_owned(),
14283                    role_reference: source.role_reference().map(str::to_owned),
14284                    component,
14285                    parent_depth: source.parent_depth(),
14286                    outcome: source.outcome().as_str(),
14287                    request_attempted: source.request_attempted(),
14288                    response_bytes: source.response_bytes(),
14289                    evidence_reference_count,
14290                    evidence_references: source_evidence_references,
14291                    source_page_references: source.source_page_references().to_vec(),
14292                    source_supplied_session_page_references: supplied_session_scoped
14293                        .then(|| source.source_supplied_session_page_references().to_vec()),
14294                    namespaces: source.namespaces().to_vec(),
14295                    theme,
14296                    plugin,
14297                })
14298            })
14299            .collect::<Result<Vec<_>, ReportError>>()?;
14300        if evidence_references.next().is_some() {
14301            return Err(ReportError::Serialization);
14302        }
14303        let layout = discovery.layout();
14304        let layout = WordPressDiscoveryLayoutDocument {
14305            application_reference: layout.application_reference().to_owned(),
14306            declaration: layout.declaration().map(|declaration| {
14307                WordPressDiscoveryLayoutDeclarationDocument {
14308                    schema: WORDPRESS_DISCOVERY_LAYOUT_SCHEMA,
14309                    byte_length: declaration.byte_length(),
14310                    sha256: declaration.sha256().to_owned(),
14311                }
14312            }),
14313            roles: layout
14314                .roles()
14315                .iter()
14316                .map(|role| WordPressDiscoveryLayoutRoleDocument {
14317                    role: role.role().as_str(),
14318                    status: role.status().as_str(),
14319                    basis: role.basis().as_str(),
14320                    reference: role.reference().map(str::to_owned),
14321                    candidate_count: role.candidate_count(),
14322                })
14323                .collect(),
14324            skipped_foreign_origin_count: layout.skipped_foreign_origin_count(),
14325            skipped_sibling_application_count: layout.skipped_sibling_application_count(),
14326            conflicting_association_count: layout.conflicting_association_count(),
14327        };
14328        Ok(Some(Self {
14329            schema: if supplied_session_pages.is_some() {
14330                WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V4
14331            } else if page_collection.is_some() {
14332                WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V3
14333            } else {
14334                WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V2
14335            },
14336            capability_id: WORDPRESS_DISCOVERY_CAPABILITY_ID,
14337            policy_id: discovery.policy_id(),
14338            selected: discovery.selected(),
14339            method: "get",
14340            credential_mode: "anonymous",
14341            seed_count: discovery.seed_count(),
14342            candidate_count: discovery.candidate_count(),
14343            candidate_limit_reached: discovery.candidate_limit_reached(),
14344            omitted_candidate_count: discovery.omitted_candidate_count(),
14345            attempted_request_count: discovery.attempted_request_count(),
14346            completed_response_count: discovery.completed_response_count(),
14347            committed_response_count: discovery.committed_response_count(),
14348            response_bytes: discovery.response_bytes(),
14349            source_count: sources.len(),
14350            layout,
14351            page_collection,
14352            supplied_session_pages,
14353            sources,
14354        }))
14355    }
14356
14357    fn validate(&self) -> Result<(), ReportError> {
14358        let page_validation = self
14359            .page_collection
14360            .as_ref()
14361            .map(WordPressPageCollectionDocument::validate)
14362            .transpose()?;
14363        let supplied_session_page_validation = self
14364            .supplied_session_pages
14365            .as_ref()
14366            .map(WordPressSuppliedSessionPageCollectionDocument::validate)
14367            .transpose()?;
14368        let source_bytes = self.sources.iter().try_fold(0_u64, |total, source| {
14369            total.checked_add(source.response_bytes)
14370        });
14371        let evidence_references = self.sources.iter().try_fold(0_usize, |total, source| {
14372            total.checked_add(source.evidence_reference_count)
14373        });
14374        let unique_evidence_references = self
14375            .sources
14376            .iter()
14377            .flat_map(|source| source.evidence_references.iter())
14378            .collect::<std::collections::BTreeSet<_>>()
14379            .len();
14380        let attempted_sources = self
14381            .sources
14382            .iter()
14383            .filter(|source| source.request_attempted)
14384            .count();
14385        let attempted_rest_sources = self
14386            .sources
14387            .iter()
14388            .filter(|source| source.request_attempted && source.kind == "rest_index")
14389            .count();
14390        let attempted_theme_sources = self
14391            .sources
14392            .iter()
14393            .filter(|source| source.request_attempted && source.kind == "theme_stylesheet")
14394            .count();
14395        let attempted_plugin_sources = self
14396            .sources
14397            .iter()
14398            .filter(|source| source.request_attempted && source.kind == "plugin_readme")
14399            .count();
14400        let unique_source_count = self
14401            .sources
14402            .iter()
14403            .map(|source| source.resource_reference.as_str())
14404            .collect::<std::collections::BTreeSet<_>>()
14405            .len();
14406        let is_page_scoped = page_validation.is_some();
14407        let is_supplied_session_scoped = supplied_session_page_validation.is_some();
14408        let is_form_login_scoped = supplied_session_page_validation
14409            .as_ref()
14410            .is_some_and(|scope| scope.form_login);
14411        let combined_request_count = self.attempted_request_count.checked_add(
14412            self.page_collection
14413                .as_ref()
14414                .map_or(0, |pages| pages.attempted_request_count),
14415        );
14416        let combined_response_bytes = self.response_bytes.checked_add(
14417            self.page_collection
14418                .as_ref()
14419                .map_or(0, |pages| pages.response_bytes),
14420        );
14421        if self.schema
14422            != if is_supplied_session_scoped {
14423                WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V4
14424            } else if is_page_scoped {
14425                WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V3
14426            } else {
14427                WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V2
14428            }
14429            || self.capability_id != WORDPRESS_DISCOVERY_CAPABILITY_ID
14430            || self.policy_id
14431                != if is_supplied_session_scoped {
14432                    if is_form_login_scoped {
14433                        WORDPRESS_FORM_LOGIN_DISCOVERY_POLICY_ID
14434                    } else {
14435                        WORDPRESS_SUPPLIED_SESSION_DISCOVERY_POLICY_ID
14436                    }
14437                } else if is_page_scoped {
14438                    WORDPRESS_PAGE_SCOPED_DISCOVERY_POLICY_ID
14439                } else {
14440                    WORDPRESS_DISCOVERY_POLICY_ID
14441                }
14442            || !self.selected
14443            || self.method != "get"
14444            || self.credential_mode != "anonymous"
14445            || usize::from(self.seed_count) > MAX_WORDPRESS_DISCOVERY_AUDIT_SOURCES
14446            || usize::from(self.candidate_count) > MAX_WORDPRESS_DISCOVERY_AUDIT_SOURCES
14447            || self.seed_count > self.candidate_count
14448            || self.candidate_limit_reached != (self.omitted_candidate_count > 0)
14449            || self.omitted_candidate_count > MAX_WORDPRESS_DISCOVERY_AUDIT_OMITTED_CANDIDATES
14450            || (self.omitted_candidate_count > 0
14451                && usize::from(self.candidate_count) != MAX_WORDPRESS_DISCOVERY_AUDIT_SOURCES)
14452            || self.attempted_request_count > MAX_WORDPRESS_DISCOVERY_AUDIT_REQUESTS
14453            || combined_request_count
14454                .is_none_or(|count| count > MAX_WORDPRESS_DISCOVERY_AUDIT_REQUESTS)
14455            || combined_response_bytes
14456                .is_none_or(|bytes| bytes > MAX_WORDPRESS_DISCOVERY_AUDIT_RESPONSE_BYTES)
14457            || self.attempted_request_count > self.candidate_count
14458            || attempted_sources != usize::from(self.attempted_request_count)
14459            || attempted_rest_sources > MAX_WORDPRESS_DISCOVERY_AUDIT_REST_REQUESTS
14460            || attempted_theme_sources > MAX_WORDPRESS_DISCOVERY_AUDIT_THEME_REQUESTS
14461            || attempted_plugin_sources > MAX_WORDPRESS_DISCOVERY_AUDIT_PLUGIN_REQUESTS
14462            || self.completed_response_count > self.attempted_request_count
14463            || self.committed_response_count != self.completed_response_count
14464            || self.source_count != self.sources.len()
14465            || self.source_count > MAX_WORDPRESS_DISCOVERY_AUDIT_SOURCES
14466            || unique_source_count != self.source_count
14467            || usize::from(self.candidate_count) != self.source_count
14468            || source_bytes != Some(self.response_bytes)
14469            || evidence_references != Some(usize::from(self.committed_response_count))
14470            || unique_evidence_references != usize::from(self.committed_response_count)
14471            || !self.layout.is_valid()
14472            || self.sources.iter().any(|source| {
14473                !source.is_valid(
14474                    &self.layout,
14475                    page_validation
14476                        .as_ref()
14477                        .map(|validation| &validation.accepted_page_references),
14478                    self.page_collection
14479                        .as_ref()
14480                        .map(|pages| pages.entry_page_reference.as_str()),
14481                    supplied_session_page_validation
14482                        .as_ref()
14483                        .map(|validation| &validation.accepted_page_references),
14484                    is_supplied_session_scoped,
14485                )
14486            })
14487        {
14488            return Err(ReportError::Serialization);
14489        }
14490        Ok(())
14491    }
14492
14493    fn metadata(&self) -> Vec<(&'static str, String)> {
14494        let mut metadata = vec![
14495            ("Discovery audit schema", self.schema.to_owned()),
14496            ("Discovery policy", self.policy_id.to_owned()),
14497            (
14498                "Selected application reference",
14499                self.layout.application_reference.clone(),
14500            ),
14501            (
14502                "Layout declaration",
14503                if self.layout.declaration.is_some() {
14504                    "operator supplied".to_owned()
14505                } else {
14506                    "not supplied".to_owned()
14507                },
14508            ),
14509            ("Request method", self.method.to_owned()),
14510            ("Credential mode", self.credential_mode.to_owned()),
14511            ("Admitted seeds", self.seed_count.to_string()),
14512            ("Selected candidates", self.candidate_count.to_string()),
14513            (
14514                "Candidate limit reached",
14515                self.candidate_limit_reached.to_string(),
14516            ),
14517            (
14518                "Omitted candidates",
14519                self.omitted_candidate_count.to_string(),
14520            ),
14521            (
14522                "Attempted requests",
14523                self.attempted_request_count.to_string(),
14524            ),
14525            (
14526                "Completed responses",
14527                self.completed_response_count.to_string(),
14528            ),
14529            ("Discovery response bytes", self.response_bytes.to_string()),
14530            (
14531                "Skipped foreign-origin advertisements",
14532                self.layout.skipped_foreign_origin_count.to_string(),
14533            ),
14534            (
14535                "Skipped sibling-application advertisements",
14536                self.layout.skipped_sibling_application_count.to_string(),
14537            ),
14538            (
14539                "Conflicting layout associations",
14540                self.layout.conflicting_association_count.to_string(),
14541            ),
14542        ];
14543        if let Some(pages) = &self.page_collection {
14544            metadata.extend([
14545                ("Page scope mode", pages.mode.to_owned()),
14546                ("Selected secondary pages", pages.selected_count.to_string()),
14547                (
14548                    "Reused page responses",
14549                    pages.reused_response_count.to_string(),
14550                ),
14551                (
14552                    "Fetched page responses",
14553                    pages.fetched_response_count.to_string(),
14554                ),
14555                (
14556                    "Page request attempts",
14557                    pages.attempted_request_count.to_string(),
14558                ),
14559                (
14560                    "Interpreted secondary-page bytes",
14561                    pages.interpreted_response_bytes.to_string(),
14562                ),
14563                ("New secondary-page bytes", pages.response_bytes.to_string()),
14564            ]);
14565        }
14566        if let Some(pages) = &self.supplied_session_pages {
14567            metadata.extend([
14568                ("Supplied-session page mode", pages.mode.to_owned()),
14569                ("Session policy reference", pages.policy_reference.clone()),
14570                (
14571                    "Session application reference",
14572                    pages.application_reference.clone(),
14573                ),
14574                (
14575                    "Session principal reference",
14576                    pages.principal_reference.clone(),
14577                ),
14578                (
14579                    "Session credential mechanism",
14580                    pages.credential_mechanism.to_owned(),
14581                ),
14582                ("Session epoch", pages.session_epoch.to_string()),
14583                (
14584                    "Selected supplied-session pages",
14585                    pages.selected_count.to_string(),
14586                ),
14587                (
14588                    "Committed supplied-session pages",
14589                    pages.committed_count.to_string(),
14590                ),
14591                (
14592                    "Accepted supplied-session associations",
14593                    pages.accepted_association_count.to_string(),
14594                ),
14595                (
14596                    "Rejected supplied-session associations",
14597                    pages.rejected_association_count.to_string(),
14598                ),
14599                (
14600                    "Supplied-session associations not established",
14601                    pages.not_established_association_count.to_string(),
14602                ),
14603                (
14604                    "Supplied-session pages not evaluated",
14605                    pages.not_evaluated_count.to_string(),
14606                ),
14607                (
14608                    "Interpreted supplied-session bytes",
14609                    pages.interpreted_response_bytes.to_string(),
14610                ),
14611            ]);
14612        }
14613        metadata
14614    }
14615
14616    fn wire_json(&self) -> Result<String, ReportError> {
14617        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
14618    }
14619}
14620
14621#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
14622impl AssessmentWordPressAssetFingerprintAuditDocument {
14623    fn from_execution(
14624        execution: &WordPressAssetFingerprintExecution,
14625        evidence_references: &[String],
14626    ) -> Result<Self, ReportError> {
14627        let catalog = execution.catalog();
14628        let metadata = catalog.catalog();
14629        let provenance = metadata.provenance();
14630        let catalogue = WordPressAssetFingerprintCatalogueDocument {
14631            schema: WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA,
14632            id: metadata.id().to_owned(),
14633            revision: metadata.revision().to_owned(),
14634            source_namespace: metadata.source_namespace().to_owned(),
14635            byte_length: catalog.byte_length(),
14636            sha256: lowercase_hex(catalog.sha256()),
14637            semantic_sha256: lowercase_hex(catalog.semantic_sha256()),
14638            retained_bytes: catalog.retained_bytes(),
14639            component_count: catalog.components().len(),
14640            release_count: catalog.release_count(),
14641            file_count: catalog.file_count(),
14642            provenance: WordPressAssetFingerprintProvenanceDocument {
14643                reference: provenance.reference().to_owned(),
14644                revision: provenance.revision().to_owned(),
14645                notices: provenance
14646                    .notices()
14647                    .iter()
14648                    .map(|notice| WordPressAssetFingerprintNoticeDocument {
14649                        id: notice.id().to_owned(),
14650                        party: notice.party().to_owned(),
14651                        notice: notice.notice().to_owned(),
14652                        license: notice.license().to_owned(),
14653                        license_reference: notice.license_reference().to_owned(),
14654                    })
14655                    .collect(),
14656            },
14657        };
14658        let mut evidence_references = evidence_references.iter();
14659        let resources = execution
14660            .resources()
14661            .iter()
14662            .map(|resource| {
14663                let evidence_reference_count = resource.evidence_ids().len();
14664                let resource_evidence_references = evidence_references
14665                    .by_ref()
14666                    .take(evidence_reference_count)
14667                    .cloned()
14668                    .collect::<Vec<_>>();
14669                if resource_evidence_references.len() != evidence_reference_count {
14670                    return Err(ReportError::Serialization);
14671                }
14672                Ok(WordPressAssetFingerprintResourceDocument {
14673                    component: wordpress_component_identity(resource.component()),
14674                    relative_path: resource.relative_path().to_owned(),
14675                    resource_reference: resource.resource_reference().to_owned(),
14676                    source_page_references: resource.source_page_references().to_vec(),
14677                    observed_variant_count: resource.observed_variant_count(),
14678                    acquisition: resource.acquisition().as_str(),
14679                    outcome: resource.outcome().as_str(),
14680                    request_attempted: resource.request_attempted(),
14681                    interpreted_response_bytes: resource.interpreted_response_bytes(),
14682                    response_bytes: resource.response_bytes(),
14683                    evidence_reference_count,
14684                    evidence_references: resource_evidence_references,
14685                    observation: resource.observation().map(|observation| {
14686                        WordPressAssetFingerprintObservationDocument {
14687                            byte_length: observation.byte_length(),
14688                            sha256: lowercase_hex(observation.sha256()),
14689                        }
14690                    }),
14691                })
14692            })
14693            .collect::<Result<Vec<_>, ReportError>>()?;
14694        if evidence_references.next().is_some() {
14695            return Err(ReportError::Serialization);
14696        }
14697        let components = execution
14698            .component_matches()
14699            .iter()
14700            .map(|matched| {
14701                let catalog_component = catalog.component(matched.identity());
14702                if matched.catalogue_component_listed() != catalog_component.is_some() {
14703                    return Err(ReportError::Serialization);
14704                }
14705                let resources = matched
14706                    .resources()
14707                    .iter()
14708                    .map(|resource| WordPressAssetFingerprintResourceMatchDocument {
14709                        relative_path: resource.relative_path().to_owned(),
14710                        distinct_observation_count: resource.distinct_observation_count(),
14711                        informative: resource.informative(),
14712                        release_relation_count: resource.releases().len(),
14713                        release_relations: resource
14714                            .releases()
14715                            .iter()
14716                            .map(|release| WordPressAssetFingerprintReleaseRelationDocument {
14717                                release_id: release.release_id().to_owned(),
14718                                relation: wordpress_asset_fingerprint_relation(release.relation()),
14719                                unknown_reason: release
14720                                    .unknown_reason()
14721                                    .map(wordpress_asset_fingerprint_unknown_reason),
14722                            })
14723                            .collect(),
14724                    })
14725                    .collect::<Vec<_>>();
14726                let releases = matched
14727                    .releases()
14728                    .iter()
14729                    .map(|release| {
14730                        let Some(source) = catalog_component
14731                            .and_then(|component| {
14732                                component.releases().iter().find(|candidate| {
14733                                    candidate.release_id() == release.release_id()
14734                                })
14735                            })
14736                            .map(|release| release.source())
14737                        else {
14738                            return Err(ReportError::Serialization);
14739                        };
14740                        Ok(WordPressAssetFingerprintReleaseMatchDocument {
14741                            release_id: release.release_id().to_owned(),
14742                            version: release.version().to_owned(),
14743                            build_variant: release.build_variant().map(str::to_owned),
14744                            state: wordpress_asset_fingerprint_release_state(release.state()),
14745                            source: WordPressAssetFingerprintReleaseSourceDocument {
14746                                reference: source.reference().to_owned(),
14747                                revision: source.revision().to_owned(),
14748                                notice_ids: source.notice_ids().to_vec(),
14749                            },
14750                        })
14751                    })
14752                    .collect::<Result<Vec<_>, ReportError>>()?;
14753                let compatible_release_ids = matched
14754                    .compatible_release_ids()
14755                    .map(str::to_owned)
14756                    .collect::<Vec<_>>();
14757                let undetermined_release_ids = matched
14758                    .undetermined_release_ids()
14759                    .map(str::to_owned)
14760                    .collect::<Vec<_>>();
14761                let inconsistent_release_ids = matched
14762                    .inconsistent_release_ids()
14763                    .map(str::to_owned)
14764                    .collect::<Vec<_>>();
14765                Ok(WordPressAssetFingerprintComponentDocument {
14766                    identity: wordpress_component_identity(matched.identity()),
14767                    catalogue_component_listed: matched.catalogue_component_listed(),
14768                    state: wordpress_asset_fingerprint_aggregate_state(matched.state()),
14769                    candidate_resource_count: matched.candidate_resource_count(),
14770                    selected_resource_count: matched.selected_resource_count(),
14771                    completely_interpreted_resource_count: matched
14772                        .completely_interpreted_resource_count(),
14773                    omitted_resource_count: matched.omitted_resource_count(),
14774                    informative_resource_count: matched.informative_resource_count(),
14775                    listed_matrix_complete: matched.listed_matrix_complete(),
14776                    compatible_release_ids,
14777                    undetermined_release_ids,
14778                    inconsistent_release_ids,
14779                    resource_count: resources.len(),
14780                    resources,
14781                    release_count: releases.len(),
14782                    releases,
14783                })
14784            })
14785            .collect::<Result<Vec<_>, ReportError>>()?;
14786        Ok(Self {
14787            schema: WORDPRESS_ASSET_FINGERPRINT_AUDIT_SCHEMA,
14788            capability_id: WORDPRESS_ASSET_FINGERPRINT_CAPABILITY_ID,
14789            policy_id: WORDPRESS_ASSET_FINGERPRINT_POLICY_ID,
14790            selected: true,
14791            representation_profile: WORDPRESS_ASSET_FINGERPRINT_REPRESENTATION_PROFILE,
14792            finite_reference_scope: "listed_releases_only",
14793            same_release_assumption: "considered_paths_share_one_listed_release_artifact_set",
14794            installed_version_assurance: "not_established_by_asset_fingerprints",
14795            source_authenticity: "not_established",
14796            catalogue,
14797            candidate_count: execution.candidate_count(),
14798            selected_resource_count: execution.selected_resource_count(),
14799            omitted_resource_count: execution.omitted_resource_count(),
14800            attempted_request_count: execution.attempted_request_count(),
14801            reused_response_count: execution.reused_response_count(),
14802            fetched_response_count: execution.fetched_response_count(),
14803            response_bytes: execution.response_bytes(),
14804            stop: execution.stop().as_str(),
14805            resource_count: resources.len(),
14806            resources,
14807            component_count: components.len(),
14808            components,
14809        })
14810    }
14811
14812    fn evidence_reference_count(&self) -> usize {
14813        self.resources
14814            .iter()
14815            .map(|resource| resource.evidence_reference_count)
14816            .sum()
14817    }
14818
14819    fn validate(
14820        &self,
14821        review: &AssessmentWordPressAuditDocument,
14822        discovery: &AssessmentWordPressDiscoveryAuditDocument,
14823    ) -> Result<(), ReportError> {
14824        let resource_bytes = self.resources.iter().try_fold(0_u64, |total, resource| {
14825            total.checked_add(resource.response_bytes)
14826        });
14827        let attempts = self
14828            .resources
14829            .iter()
14830            .filter(|resource| resource.request_attempted)
14831            .count();
14832        let reused = self
14833            .resources
14834            .iter()
14835            .filter(|resource| resource.acquisition == "reused")
14836            .count();
14837        let fetched = self
14838            .resources
14839            .iter()
14840            .filter(|resource| resource.acquisition == "fetched" && resource.outcome == "observed")
14841            .count();
14842        let unique_resource_keys = self
14843            .resources
14844            .iter()
14845            .map(|resource| {
14846                (
14847                    resource.component.kind,
14848                    resource.component.slug.as_str(),
14849                    resource.relative_path.as_str(),
14850                )
14851            })
14852            .collect::<std::collections::BTreeSet<_>>();
14853        let ambiguous_variant_resource_keys = self
14854            .resources
14855            .iter()
14856            .filter(|resource| resource.observed_variant_count > 1)
14857            .map(|resource| {
14858                (
14859                    resource.component.kind,
14860                    resource.component.slug.as_str(),
14861                    resource.relative_path.as_str(),
14862                )
14863            })
14864            .collect::<std::collections::BTreeSet<_>>();
14865        let unique_component_keys = self
14866            .components
14867            .iter()
14868            .map(|component| (component.identity.kind, component.identity.slug.as_str()))
14869            .collect::<std::collections::BTreeSet<_>>();
14870        let selected_component_keys = self
14871            .resources
14872            .iter()
14873            .map(|resource| (resource.component.kind, resource.component.slug.as_str()))
14874            .collect::<std::collections::BTreeSet<_>>();
14875        let selected_component_candidate_count = self
14876            .components
14877            .iter()
14878            .try_fold(0_usize, |total, component| {
14879                total.checked_add(component.candidate_resource_count)
14880            });
14881        let selected_component_omitted_count = self
14882            .components
14883            .iter()
14884            .try_fold(0_usize, |total, component| {
14885                total.checked_add(component.omitted_resource_count)
14886            });
14887        let selected_component_release_count = self
14888            .components
14889            .iter()
14890            .try_fold(0_usize, |total, component| {
14891                total.checked_add(component.release_count)
14892            });
14893        let represented_reference_count =
14894            self.components
14895                .iter()
14896                .try_fold(0_usize, |component_total, component| {
14897                    component.resources.iter().try_fold(
14898                        component_total,
14899                        |resource_total, resource| {
14900                            resource.release_relations.iter().try_fold(
14901                                resource_total,
14902                                |total, relation| {
14903                                    if relation.relation != "unknown"
14904                                        || relation.unknown_reason
14905                                            == Some("conflicting_observations")
14906                                    {
14907                                        total.checked_add(1)
14908                                    } else {
14909                                        Some(total)
14910                                    }
14911                                },
14912                            )
14913                        },
14914                    )
14915                });
14916        let source_pages_valid = discovery.page_collection.as_ref().is_none_or(|pages| {
14917            let mut allowed = pages
14918                .pages
14919                .iter()
14920                .filter(|page| page.association == "accepted")
14921                .map(|page| page.page_reference.as_str())
14922                .collect::<std::collections::BTreeSet<_>>();
14923            allowed.insert(pages.entry_page_reference.as_str());
14924            self.resources.iter().all(|resource| {
14925                resource
14926                    .source_page_references
14927                    .iter()
14928                    .all(|reference| allowed.contains(reference.as_str()))
14929            })
14930        });
14931        if self.schema != WORDPRESS_ASSET_FINGERPRINT_AUDIT_SCHEMA
14932            || self.capability_id != WORDPRESS_ASSET_FINGERPRINT_CAPABILITY_ID
14933            || self.policy_id != WORDPRESS_ASSET_FINGERPRINT_POLICY_ID
14934            || !self.selected
14935            || self.representation_profile != WORDPRESS_ASSET_FINGERPRINT_REPRESENTATION_PROFILE
14936            || self.finite_reference_scope != "listed_releases_only"
14937            || self.same_release_assumption
14938                != "considered_paths_share_one_listed_release_artifact_set"
14939            || self.installed_version_assurance != "not_established_by_asset_fingerprints"
14940            || self.source_authenticity != "not_established"
14941            || self.stop != "complete"
14942            || review.schema != "security.wordpress-review-audit/v8"
14943            || self.candidate_count
14944                != u64::try_from(
14945                    self.selected_resource_count
14946                        .checked_add(self.omitted_resource_count)
14947                        .ok_or(ReportError::Serialization)?,
14948                )
14949                .map_err(|_| ReportError::Serialization)?
14950            || self.candidate_count
14951                > u64::try_from(MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES)
14952                    .map_err(|_| ReportError::Serialization)?
14953            || (self.candidate_count > 0 && self.selected_resource_count == 0)
14954            || self.resource_count != self.resources.len()
14955            || self.resource_count != self.selected_resource_count
14956            || self.resource_count > 4
14957            || attempts != usize::from(self.attempted_request_count)
14958            || reused != self.reused_response_count
14959            || fetched != self.fetched_response_count
14960            || resource_bytes != Some(self.response_bytes)
14961            || unique_resource_keys.len() != self.resources.len()
14962            || self.component_count != self.components.len()
14963            || self.component_count > 2
14964            || unique_component_keys.len() != self.components.len()
14965            || unique_component_keys != selected_component_keys
14966            || selected_component_candidate_count.is_none_or(|count| {
14967                u64::try_from(count).map_or(true, |count| count > self.candidate_count)
14968            })
14969            || selected_component_omitted_count
14970                .is_none_or(|count| count > self.omitted_resource_count)
14971            || self
14972                .components
14973                .iter()
14974                .any(|component| !component.catalogue_component_listed)
14975            || self.component_count > self.catalogue.component_count
14976            || selected_component_release_count
14977                .is_none_or(|count| count > self.catalogue.release_count)
14978            || represented_reference_count.is_none_or(|count| count > self.catalogue.file_count)
14979            || !source_pages_valid
14980            || !self.catalogue.is_valid()
14981            || self.resources.iter().any(|resource| !resource.is_valid())
14982            || self.components.iter().any(|component| {
14983                !component.is_valid(
14984                    &self.catalogue,
14985                    &unique_resource_keys,
14986                    &ambiguous_variant_resource_keys,
14987                )
14988            })
14989        {
14990            return Err(ReportError::Serialization);
14991        }
14992        Ok(())
14993    }
14994
14995    fn metadata(&self) -> Vec<(&'static str, String)> {
14996        vec![
14997            ("Fingerprint audit schema", self.schema.to_owned()),
14998            ("Fingerprint policy", self.policy_id.to_owned()),
14999            ("Reference scope", self.finite_reference_scope.to_owned()),
15000            ("Catalogue ID", self.catalogue.id.clone()),
15001            ("Catalogue revision", self.catalogue.revision.clone()),
15002            ("Catalogue SHA-256", self.catalogue.sha256.clone()),
15003            (
15004                "Catalogue semantic SHA-256",
15005                self.catalogue.semantic_sha256.clone(),
15006            ),
15007            (
15008                "Observed asset candidates",
15009                self.candidate_count.to_string(),
15010            ),
15011            (
15012                "Selected resources",
15013                self.selected_resource_count.to_string(),
15014            ),
15015            ("Omitted resources", self.omitted_resource_count.to_string()),
15016            ("Reused responses", self.reused_response_count.to_string()),
15017            ("Fetched responses", self.fetched_response_count.to_string()),
15018            (
15019                "Fingerprint requests",
15020                self.attempted_request_count.to_string(),
15021            ),
15022            (
15023                "Fingerprint response bytes",
15024                self.response_bytes.to_string(),
15025            ),
15026            ("Collection stop", self.stop.to_owned()),
15027        ]
15028    }
15029
15030    fn wire_json(&self) -> Result<String, ReportError> {
15031        serde_json::to_string(self).map_err(|_| ReportError::Serialization)
15032    }
15033}
15034
15035#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15036impl WordPressAssetFingerprintCatalogueDocument {
15037    fn is_valid(&self) -> bool {
15038        self.schema == WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA
15039            && self.byte_length > 0
15040            && self.byte_length <= MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_BYTES as u64
15041            && valid_lowercase_sha256(&self.sha256)
15042            && valid_lowercase_sha256(&self.semantic_sha256)
15043            && self.retained_bytes > 0
15044            && self.retained_bytes <= MAX_WORDPRESS_ASSET_FINGERPRINT_RETAINED_BYTES
15045            && (1..=MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_COMPONENTS)
15046                .contains(&self.component_count)
15047            && self.release_count > 0
15048            && self.release_count
15049                <= MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_COMPONENTS
15050                    * MAX_WORDPRESS_ASSET_FINGERPRINT_RELEASES_PER_COMPONENT
15051            && self.file_count > 0
15052            && self.file_count <= MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_FILES
15053            && self.provenance.notices.len() <= 32
15054            && self
15055                .provenance
15056                .notices
15057                .iter()
15058                .map(|notice| notice.id.as_str())
15059                .collect::<std::collections::BTreeSet<_>>()
15060                .len()
15061                == self.provenance.notices.len()
15062    }
15063}
15064
15065#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15066impl WordPressAssetFingerprintResourceDocument {
15067    fn is_valid(&self) -> bool {
15068        let observed = self.outcome == "observed";
15069        matches!(self.component.kind, "plugin" | "theme")
15070            && crate::wordpress_review::valid_slug(&self.component.slug)
15071            && self.relative_path.len() <= MAX_WORDPRESS_ASSET_FINGERPRINT_PATH_BYTES
15072            && crate::wordpress_review::valid_wordpress_asset_fingerprint_path(&self.relative_path)
15073            && valid_wordpress_discovery_reference(&self.resource_reference)
15074            && !self.source_page_references.is_empty()
15075            && self.source_page_references.len() <= 4
15076            && self
15077                .source_page_references
15078                .windows(2)
15079                .all(|pair| pair[0] < pair[1])
15080            && self
15081                .source_page_references
15082                .iter()
15083                .all(|reference| valid_wordpress_discovery_reference(reference))
15084            && self.observed_variant_count > 0
15085            && self.observed_variant_count <= MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES
15086            && matches!(self.acquisition, "reused" | "fetched" | "not_acquired")
15087            && matches!(
15088                self.outcome,
15089                "observed"
15090                    | "ordinary_response_ineligible"
15091                    | "request_failed"
15092                    | "unauthorized"
15093                    | "not_found"
15094                    | "redirect_observed"
15095                    | "partial_or_incomplete"
15096                    | "unsupported_media_type"
15097                    | "unsupported_content_encoding"
15098                    | "inconsistent_content_length"
15099                    | "conflicting_representations"
15100            )
15101            && match self.acquisition {
15102                "reused" => {
15103                    !self.request_attempted
15104                        && self.response_bytes == 0
15105                        && matches!(self.outcome, "observed" | "conflicting_representations")
15106                },
15107                "fetched" => {
15108                    self.request_attempted
15109                        && matches!(
15110                            self.outcome,
15111                            "observed"
15112                                | "request_failed"
15113                                | "unauthorized"
15114                                | "not_found"
15115                                | "redirect_observed"
15116                                | "partial_or_incomplete"
15117                                | "unsupported_media_type"
15118                                | "unsupported_content_encoding"
15119                                | "inconsistent_content_length"
15120                        )
15121                },
15122                "not_acquired" => {
15123                    !self.request_attempted
15124                        && self.response_bytes == 0
15125                        && self.outcome == "ordinary_response_ineligible"
15126                },
15127                _ => false,
15128            }
15129            && self.interpreted_response_bytes <= MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES
15130            && self.evidence_reference_count == self.evidence_references.len()
15131            && match self.outcome {
15132                "observed" => self.evidence_reference_count == 1,
15133                "conflicting_representations" => (1..=8).contains(&self.evidence_reference_count),
15134                _ => self.evidence_reference_count == 0,
15135            }
15136            && self
15137                .evidence_references
15138                .iter()
15139                .all(|reference| valid_opaque_assessment_reference(reference, "evidence"))
15140            && observed == self.observation.is_some()
15141            && self.observation.as_ref().is_none_or(|observation| {
15142                observation.byte_length == self.interpreted_response_bytes
15143                    && observation.byte_length <= MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES
15144                    && valid_lowercase_sha256(&observation.sha256)
15145            })
15146            && (observed || self.interpreted_response_bytes == 0)
15147    }
15148}
15149
15150#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15151impl WordPressAssetFingerprintComponentDocument {
15152    fn is_valid(
15153        &self,
15154        catalogue: &WordPressAssetFingerprintCatalogueDocument,
15155        observed_resources: &std::collections::BTreeSet<(&str, &str, &str)>,
15156        ambiguous_variant_resources: &std::collections::BTreeSet<(&str, &str, &str)>,
15157    ) -> bool {
15158        let release_ids = self
15159            .releases
15160            .iter()
15161            .map(|release| release.release_id.as_str())
15162            .collect::<std::collections::BTreeSet<_>>();
15163        let compatible = self
15164            .compatible_release_ids
15165            .iter()
15166            .map(String::as_str)
15167            .collect::<std::collections::BTreeSet<_>>();
15168        let undetermined = self
15169            .undetermined_release_ids
15170            .iter()
15171            .map(String::as_str)
15172            .collect::<std::collections::BTreeSet<_>>();
15173        let inconsistent = self
15174            .inconsistent_release_ids
15175            .iter()
15176            .map(String::as_str)
15177            .collect::<std::collections::BTreeSet<_>>();
15178        let classified_release_ids = compatible
15179            .iter()
15180            .chain(&undetermined)
15181            .chain(&inconsistent)
15182            .copied()
15183            .collect::<std::collections::BTreeSet<_>>();
15184        let has_unassessed_variant = self.resources.iter().any(|resource| {
15185            ambiguous_variant_resources.contains(&(
15186                self.identity.kind,
15187                self.identity.slug.as_str(),
15188                resource.relative_path.as_str(),
15189            ))
15190        });
15191        let collection_complete = self.candidate_resource_count == self.selected_resource_count
15192            && self.selected_resource_count == self.completely_interpreted_resource_count
15193            && !has_unassessed_variant;
15194        matches!(self.identity.kind, "plugin" | "theme")
15195            && crate::wordpress_review::valid_slug(&self.identity.slug)
15196            && self.release_count == self.releases.len()
15197            && self.release_count <= MAX_WORDPRESS_ASSET_FINGERPRINT_RELEASES_PER_COMPONENT
15198            && self.catalogue_component_listed != self.releases.is_empty()
15199            && self.candidate_resource_count
15200                == self
15201                    .selected_resource_count
15202                    .saturating_add(self.omitted_resource_count)
15203            && self.candidate_resource_count > 0
15204            && self.selected_resource_count > 0
15205            && self.selected_resource_count
15206                <= MAX_WORDPRESS_ASSET_FINGERPRINT_RESOURCES_PER_COMPONENT
15207            && self.completely_interpreted_resource_count <= self.selected_resource_count
15208            && self.completely_interpreted_resource_count <= self.resource_count
15209            && self.resource_count <= self.selected_resource_count
15210            && (collection_complete
15211                || (!self.listed_matrix_complete
15212                    && !matches!(
15213                        self.state,
15214                        "single_catalogue_candidate"
15215                            | "multiple_catalogue_candidates"
15216                            | "no_consistent_catalogue_release"
15217                            | "no_catalogue_byte_match"
15218                    )))
15219            && release_ids.len() == self.releases.len()
15220            && compatible.is_disjoint(&undetermined)
15221            && compatible.is_disjoint(&inconsistent)
15222            && undetermined.is_disjoint(&inconsistent)
15223            && classified_release_ids == release_ids
15224            && self.resource_count == self.resources.len()
15225            && self.resource_count <= MAX_WORDPRESS_ASSET_FINGERPRINT_RESOURCES_PER_COMPONENT
15226            && !(has_unassessed_variant
15227                && (self.listed_matrix_complete
15228                    || matches!(
15229                        self.state,
15230                        "single_catalogue_candidate" | "multiple_catalogue_candidates"
15231                    )))
15232            && self
15233                .resources
15234                .iter()
15235                .map(|resource| resource.relative_path.as_str())
15236                .collect::<std::collections::BTreeSet<_>>()
15237                .len()
15238                == self.resources.len()
15239            && self.resources.iter().all(|resource| {
15240                observed_resources.contains(&(
15241                    self.identity.kind,
15242                    self.identity.slug.as_str(),
15243                    resource.relative_path.as_str(),
15244                )) && resource.release_relation_count == resource.release_relations.len()
15245                    && resource.release_relations.len() == self.releases.len()
15246                    && resource
15247                        .release_relations
15248                        .iter()
15249                        .map(|relation| relation.release_id.as_str())
15250                        .collect::<std::collections::BTreeSet<_>>()
15251                        == release_ids
15252            })
15253            && self.informative_resource_count
15254                == self
15255                    .resources
15256                    .iter()
15257                    .filter(|resource| resource.informative)
15258                    .count()
15259            && self.releases.iter().all(|release| {
15260                let notice_ids = release
15261                    .source
15262                    .notice_ids
15263                    .iter()
15264                    .map(String::as_str)
15265                    .collect::<std::collections::BTreeSet<_>>();
15266                notice_ids.len() == release.source.notice_ids.len()
15267                    && notice_ids.iter().all(|id| {
15268                        catalogue
15269                            .provenance
15270                            .notices
15271                            .iter()
15272                            .any(|notice| notice.id == *id)
15273                    })
15274            })
15275    }
15276}
15277
15278#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15279const fn wordpress_asset_fingerprint_relation(
15280    relation: WordPressAssetFingerprintRelation,
15281) -> &'static str {
15282    match relation {
15283        WordPressAssetFingerprintRelation::Match => "match",
15284        WordPressAssetFingerprintRelation::Mismatch => "mismatch",
15285        WordPressAssetFingerprintRelation::Unknown => "unknown",
15286    }
15287}
15288
15289#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15290const fn wordpress_asset_fingerprint_unknown_reason(
15291    reason: WordPressAssetFingerprintUnknownReason,
15292) -> &'static str {
15293    match reason {
15294        WordPressAssetFingerprintUnknownReason::MissingReference => "missing_reference",
15295        WordPressAssetFingerprintUnknownReason::ConflictingObservations => {
15296            "conflicting_observations"
15297        },
15298    }
15299}
15300
15301#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15302const fn wordpress_asset_fingerprint_release_state(
15303    state: WordPressAssetFingerprintReleaseState,
15304) -> &'static str {
15305    match state {
15306        WordPressAssetFingerprintReleaseState::Compatible => "compatible",
15307        WordPressAssetFingerprintReleaseState::Inconsistent => "inconsistent",
15308        WordPressAssetFingerprintReleaseState::Undetermined => "undetermined",
15309    }
15310}
15311
15312#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15313const fn wordpress_asset_fingerprint_aggregate_state(
15314    state: WordPressAssetFingerprintAggregateState,
15315) -> &'static str {
15316    match state {
15317        WordPressAssetFingerprintAggregateState::SingleCatalogueCandidate => {
15318            "single_catalogue_candidate"
15319        },
15320        WordPressAssetFingerprintAggregateState::MultipleCatalogueCandidates => {
15321            "multiple_catalogue_candidates"
15322        },
15323        WordPressAssetFingerprintAggregateState::ProvisionalCandidates => "provisional_candidates",
15324        WordPressAssetFingerprintAggregateState::NoConsistentCatalogueRelease => {
15325            "no_consistent_catalogue_release"
15326        },
15327        WordPressAssetFingerprintAggregateState::NoCatalogueByteMatch => "no_catalogue_byte_match",
15328        WordPressAssetFingerprintAggregateState::Undetermined => "undetermined",
15329    }
15330}
15331
15332#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15333struct ValidatedWordPressPageCollection {
15334    accepted_page_references: std::collections::BTreeSet<String>,
15335}
15336
15337#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15338struct ValidatedWordPressSuppliedSessionPageCollection {
15339    accepted_page_references: std::collections::BTreeSet<String>,
15340    form_login: bool,
15341}
15342
15343#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15344impl WordPressSuppliedSessionPageCollectionDocument {
15345    fn validate(&self) -> Result<ValidatedWordPressSuppliedSessionPageCollection, ReportError> {
15346        let mut page_references = std::collections::BTreeSet::new();
15347        let mut resource_references = std::collections::BTreeSet::new();
15348        let mut resource_evidence_references = std::collections::BTreeSet::new();
15349        let mut evidence_references = std::collections::BTreeSet::new();
15350        let mut accepted_page_references = std::collections::BTreeSet::new();
15351        let mut committed_count = 0_usize;
15352        let mut accepted_count = 0_usize;
15353        let mut rejected_count = 0_usize;
15354        let mut not_established_count = 0_usize;
15355        let mut not_evaluated_count = 0_usize;
15356        let mut interpreted_response_bytes = 0_u64;
15357        for page in &self.pages {
15358            if !page.is_valid()
15359                || !page_references.insert(page.page_reference.clone())
15360                || !resource_references.insert(page.resource_reference.clone())
15361                || page
15362                    .resource_evidence_reference
15363                    .as_ref()
15364                    .is_some_and(|reference| {
15365                        !resource_evidence_references.insert(reference.clone())
15366                    })
15367                || page
15368                    .evidence_references
15369                    .iter()
15370                    .any(|reference| !evidence_references.insert(reference.clone()))
15371            {
15372                return Err(ReportError::Serialization);
15373            }
15374            committed_count += usize::from(page.resource_evidence_reference.is_some());
15375            match page.association {
15376                "accepted" => {
15377                    accepted_count += 1;
15378                    accepted_page_references.insert(page.page_reference.clone());
15379                },
15380                "rejected" => rejected_count += 1,
15381                "not_established" => not_established_count += 1,
15382                _ => return Err(ReportError::Serialization),
15383            }
15384            not_evaluated_count += usize::from(matches!(
15385                page.outcome,
15386                "unsupported_content" | "invalid_utf8" | "not_evaluated"
15387            ));
15388            interpreted_response_bytes = interpreted_response_bytes
15389                .checked_add(page.interpreted_response_bytes)
15390                .ok_or(ReportError::Serialization)?;
15391        }
15392        let form_login = self.mode == "form_login_supplied_session_resources";
15393        if !matches!(
15394            self.mode,
15395            "committed_supplied_session_resources" | "form_login_supplied_session_resources"
15396        ) || !valid_wordpress_supplied_session_reference(
15397            &self.policy_reference,
15398            "supplied-session-policy-sha256:",
15399        ) || !valid_wordpress_supplied_session_reference(
15400            &self.application_reference,
15401            "supplied-session-application-sha256:",
15402        ) || self.principal_reference != "supplied-session-principal-0001"
15403            || !matches!(
15404                self.credential_mechanism,
15405                "authorization_header" | "cookie_jar"
15406            )
15407            || if form_login {
15408                self.session_epoch > 1
15409            } else {
15410                self.session_epoch != 1
15411            }
15412            || usize::from(self.selected_count) != self.pages.len()
15413            || self.pages.len() > MAX_WORDPRESS_SUPPLIED_SESSION_PAGE_AUDIT_COUNT
15414            || usize::from(self.committed_count) != committed_count
15415            || usize::from(self.accepted_association_count) != accepted_count
15416            || usize::from(self.rejected_association_count) != rejected_count
15417            || usize::from(self.not_established_association_count) != not_established_count
15418            || accepted_count
15419                .checked_add(rejected_count)
15420                .and_then(|count| count.checked_add(not_established_count))
15421                != Some(self.pages.len())
15422            || usize::from(self.not_evaluated_count) != not_evaluated_count
15423            || interpreted_response_bytes != self.interpreted_response_bytes
15424            || self.interpreted_response_bytes
15425                > MAX_WORDPRESS_SUPPLIED_SESSION_PAGE_AUDIT_INTERPRETED_BYTES
15426        {
15427            return Err(ReportError::Serialization);
15428        }
15429        if self.session_epoch == 0
15430            && (committed_count != 0
15431                || accepted_count != 0
15432                || rejected_count != 0
15433                || not_established_count != self.pages.len()
15434                || not_evaluated_count != self.pages.len()
15435                || interpreted_response_bytes != 0
15436                || self.pages.iter().any(|page| {
15437                    page.outcome != "not_evaluated"
15438                        || page.resource_evidence_reference.is_some()
15439                        || !page.evidence_references.is_empty()
15440                }))
15441        {
15442            return Err(ReportError::Serialization);
15443        }
15444        Ok(ValidatedWordPressSuppliedSessionPageCollection {
15445            accepted_page_references,
15446            form_login,
15447        })
15448    }
15449
15450    #[cfg(feature = "supplied-session-review")]
15451    fn validate_against_supplied_session(
15452        &self,
15453        session: &AssessmentSuppliedSessionAuditDocument,
15454    ) -> Result<(), ReportError> {
15455        let form_login = self.mode == "form_login_supplied_session_resources";
15456        let acquisition_matches = if form_login {
15457            session.schema == "security.supplied-session-audit/v3"
15458                && self.credential_mechanism == "cookie_jar"
15459                && session.login.as_ref().map(|login| login.final_epoch) == Some(self.session_epoch)
15460        } else {
15461            matches!(
15462                session.schema,
15463                "security.supplied-session-audit/v1" | "security.supplied-session-audit/v2"
15464            ) && self.session_epoch == 1
15465        };
15466        if !acquisition_matches
15467            || self.policy_reference != session.policy_reference
15468            || self.application_reference != session.application_reference
15469            || self.principal_reference != session.principal_reference
15470            || self.credential_mechanism != session.credential_mechanism
15471            || usize::from(self.selected_count) != session.resources.len()
15472            || self.selected_count != session.selected_resource_count
15473            || self.committed_count != session.committed_resource_count
15474        {
15475            return Err(ReportError::Serialization);
15476        }
15477        let mut seen = std::collections::BTreeSet::new();
15478        for page in &self.pages {
15479            if !seen.insert(page.resource_reference.as_str()) {
15480                return Err(ReportError::Serialization);
15481            }
15482            let resource = session
15483                .resources
15484                .iter()
15485                .find(|resource| resource.resource_reference == page.resource_reference)
15486                .ok_or(ReportError::Serialization)?;
15487            let committed_evidence_reference = if resource.outcome == "committed" {
15488                resource.evidence_reference.as_deref()
15489            } else {
15490                None
15491            };
15492            let planned_before_session = form_login
15493                && self.session_epoch == 0
15494                && resource.epoch == 1
15495                && resource.outcome == "not_dispatched"
15496                && resource.status.is_none()
15497                && resource.response_bytes == 0;
15498            if (!planned_before_session && resource.epoch != self.session_epoch)
15499                || page.resource_evidence_reference.as_deref() != committed_evidence_reference
15500                || page.interpreted_response_bytes > resource.response_bytes
15501            {
15502                return Err(ReportError::Serialization);
15503            }
15504        }
15505        if seen.len() != session.resources.len() {
15506            return Err(ReportError::Serialization);
15507        }
15508        Ok(())
15509    }
15510}
15511
15512#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15513impl WordPressSuppliedSessionPageDocument {
15514    fn is_valid(&self) -> bool {
15515        let evidence_shape_valid = match self.association {
15516            "accepted" => self.outcome == "accepted" && self.evidence_reference_count == 1,
15517            "rejected" => {
15518                matches!(
15519                    self.outcome,
15520                    "incompatible_application" | "soft_404" | "login_response" | "truncated"
15521                ) && self.evidence_reference_count == 1
15522            },
15523            "not_established" => match self.outcome {
15524                "no_component_signals" => self.evidence_reference_count == 1,
15525                "unsupported_content" | "invalid_utf8" | "not_evaluated" => {
15526                    self.evidence_reference_count == 0 && self.interpreted_response_bytes == 0
15527                },
15528                _ => false,
15529            },
15530            _ => false,
15531        };
15532        valid_wordpress_discovery_reference(&self.page_reference)
15533            && valid_wordpress_supplied_session_reference(
15534                &self.resource_reference,
15535                "supplied-session-resource-sha256:",
15536            )
15537            && self
15538                .resource_evidence_reference
15539                .as_deref()
15540                .is_none_or(|reference| {
15541                    valid_wordpress_supplied_session_reference(
15542                        reference,
15543                        "supplied-session-resource-evidence-sha256:",
15544                    )
15545                })
15546            && self.acquisition == "reused_supplied_session_response"
15547            && evidence_shape_valid
15548            && self.interpreted_response_bytes <= 64 * 1024
15549            && self.evidence_reference_count == self.evidence_references.len()
15550            && self
15551                .evidence_references
15552                .windows(2)
15553                .all(|pair| pair[0] < pair[1])
15554            && self
15555                .evidence_references
15556                .iter()
15557                .all(|reference| valid_opaque_assessment_reference(reference, "evidence"))
15558            && match (self.resource_evidence_reference.as_ref(), self.outcome) {
15559                (None, "not_evaluated") => {
15560                    self.evidence_reference_count == 0 && self.interpreted_response_bytes == 0
15561                },
15562                (Some(_), outcome) => outcome != "not_evaluated",
15563                (None, _) => false,
15564            }
15565            && self.fingerprint_evaluation == "not_selected_in_v1"
15566    }
15567}
15568
15569#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15570fn valid_wordpress_supplied_session_reference(value: &str, prefix: &str) -> bool {
15571    value.len() == prefix.len() + 64
15572        && value.starts_with(prefix)
15573        && value[prefix.len()..]
15574            .bytes()
15575            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
15576}
15577
15578#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15579impl WordPressPageCollectionDocument {
15580    fn validate(&self) -> Result<ValidatedWordPressPageCollection, ReportError> {
15581        let attempted = self
15582            .pages
15583            .iter()
15584            .filter(|page| page.request_attempted)
15585            .count();
15586        let completed = self
15587            .pages
15588            .iter()
15589            .filter(|page| page.evidence_reference_count == 1)
15590            .count();
15591        let interpreted_bytes = self.pages.iter().try_fold(0_u64, |total, page| {
15592            total.checked_add(page.interpreted_response_bytes)
15593        });
15594        let response_bytes = self
15595            .pages
15596            .iter()
15597            .try_fold(0_u64, |total, page| total.checked_add(page.response_bytes));
15598        let mut page_references = std::collections::BTreeSet::new();
15599        let mut evidence_references = std::collections::BTreeSet::new();
15600        let mut accepted_page_references = std::collections::BTreeSet::new();
15601        for page in &self.pages {
15602            if !page.is_valid()
15603                || !page_references.insert(page.page_reference.clone())
15604                || page
15605                    .evidence_references
15606                    .iter()
15607                    .any(|reference| !evidence_references.insert(reference.clone()))
15608            {
15609                return Err(ReportError::Serialization);
15610            }
15611            if page.association == "accepted" {
15612                accepted_page_references.insert(page.page_reference.clone());
15613            }
15614        }
15615        let reused = self
15616            .pages
15617            .iter()
15618            .filter(|page| page.acquisition == "reused" && page.evidence_reference_count == 1)
15619            .count();
15620        let fetched = self
15621            .pages
15622            .iter()
15623            .filter(|page| page.acquisition == "fetched" && page.evidence_reference_count == 1)
15624            .count();
15625        let not_observed = self
15626            .pages
15627            .iter()
15628            .filter(|page| page.acquisition == "not_observed")
15629            .count();
15630        let rejected = self
15631            .pages
15632            .iter()
15633            .filter(|page| page.association == "rejected")
15634            .count();
15635        let accepted = self
15636            .pages
15637            .iter()
15638            .filter(|page| page.association == "accepted")
15639            .count();
15640        if !matches!(self.mode, "observed" | "linked")
15641            || !valid_wordpress_discovery_reference(&self.entry_page_reference)
15642            || self.candidate_count > MAX_WORDPRESS_PAGE_DISCOVERY_AUDIT_CANDIDATES
15643            || self.selected_count > MAX_WORDPRESS_PAGE_DISCOVERY_AUDIT_REQUESTS
15644            || u16::from(self.selected_count) > self.candidate_count
15645            || usize::from(self.selected_count) != self.pages.len()
15646            || self.candidate_count
15647                != u16::from(self.selected_count).saturating_add(self.omitted_candidate_count)
15648            || self.omitted_candidate_count > MAX_WORDPRESS_PAGE_DISCOVERY_AUDIT_CANDIDATES
15649            || self.reused_response_count as usize != reused
15650            || self.fetched_response_count as usize != fetched
15651            || self.not_observed_count as usize != not_observed
15652            || self.rejected_response_count as usize != rejected
15653            || self.accepted_association_count as usize != accepted
15654            || self.rejected_association_count as usize != rejected
15655            || self.attempted_request_count > MAX_WORDPRESS_PAGE_DISCOVERY_AUDIT_REQUESTS
15656            || self.attempted_request_count as usize != attempted
15657            || self.completed_response_count as usize != completed
15658            || self.committed_response_count != self.completed_response_count
15659            || interpreted_bytes != Some(self.interpreted_response_bytes)
15660            || response_bytes != Some(self.response_bytes)
15661            || self.interpreted_response_bytes
15662                > MAX_WORDPRESS_PAGE_DISCOVERY_AUDIT_INTERPRETED_BYTES
15663            || (self.mode == "observed"
15664                && (self.attempted_request_count != 0
15665                    || self.fetched_response_count != 0
15666                    || self.response_bytes != 0))
15667        {
15668            return Err(ReportError::Serialization);
15669        }
15670        Ok(ValidatedWordPressPageCollection {
15671            accepted_page_references,
15672        })
15673    }
15674}
15675
15676#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15677impl WordPressPageSourceDocument {
15678    fn is_valid(&self) -> bool {
15679        let acquisition_valid = matches!(self.acquisition, "reused" | "fetched" | "not_observed");
15680        let association_valid = matches!(
15681            self.association,
15682            "accepted" | "rejected" | "not_established"
15683        );
15684        let outcome_valid = matches!(
15685            self.outcome,
15686            "accepted"
15687                | "no_component_signals"
15688                | "not_observed"
15689                | "incompatible_application"
15690                | "soft_404"
15691                | "login_response"
15692                | "unsupported_content"
15693                | "malformed"
15694                | "truncated"
15695                | "request_failed"
15696                | "rate_limited"
15697                | "budget_exhausted"
15698                | "cancelled"
15699                | "deadline_exceeded"
15700        );
15701        valid_wordpress_discovery_reference(&self.page_reference)
15702            && acquisition_valid
15703            && association_valid
15704            && outcome_valid
15705            && self.interpreted_response_bytes <= 256 * 1024
15706            && self.evidence_reference_count <= 1
15707            && self.evidence_references.len() == self.evidence_reference_count
15708            && self
15709                .evidence_references
15710                .iter()
15711                .all(|reference| valid_opaque_assessment_reference(reference, "evidence"))
15712            && (self.evidence_reference_count == 1 || self.interpreted_response_bytes == 0)
15713            && (self.request_attempted || self.response_bytes == 0)
15714            && match self.acquisition {
15715                "reused" => {
15716                    !self.request_attempted
15717                        && self.response_bytes == 0
15718                        && self.evidence_reference_count == 1
15719                },
15720                "fetched" => self.request_attempted && self.evidence_reference_count == 1,
15721                "not_observed" => {
15722                    !self.request_attempted
15723                        && self.response_bytes == 0
15724                        && self.interpreted_response_bytes == 0
15725                        && self.evidence_reference_count == 0
15726                        && self.outcome == "not_observed"
15727                },
15728                _ => false,
15729            }
15730            && match self.association {
15731                "accepted" => self.outcome == "accepted" && self.evidence_reference_count == 1,
15732                "rejected" => {
15733                    !matches!(
15734                        self.outcome,
15735                        "accepted" | "no_component_signals" | "not_observed"
15736                    ) && self.evidence_reference_count == 1
15737                },
15738                "not_established" => {
15739                    matches!(self.outcome, "no_component_signals" | "not_observed")
15740                        && (self.outcome == "not_observed") != (self.evidence_reference_count == 1)
15741                },
15742                _ => false,
15743            }
15744    }
15745}
15746
15747#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15748fn wordpress_discovery_matches_review(
15749    review: &AssessmentWordPressAuditDocument,
15750    discovery: &AssessmentWordPressDiscoveryAuditDocument,
15751) -> bool {
15752    for source in &discovery.sources {
15753        if !source.is_exact_role_bound_component(&discovery.layout) {
15754            continue;
15755        }
15756        let Some(source_component) = source.component.as_ref() else {
15757            return false;
15758        };
15759        let Some(review_component) = review.components.iter().find(|component| {
15760            component.identity.kind == source_component.kind
15761                && component.identity.slug == source_component.slug
15762        }) else {
15763            return false;
15764        };
15765        if !review_component
15766            .identity_sources
15767            .contains(&"same_origin_asset_path")
15768        {
15769            return false;
15770        }
15771    }
15772
15773    let mut discovered = std::collections::BTreeMap::new();
15774    let mut required = std::collections::BTreeMap::new();
15775    for source in &discovery.sources {
15776        if source.kind != "theme_stylesheet" || !source.is_exact_theme_role_bound(&discovery.layout)
15777        {
15778            continue;
15779        }
15780        let Some(version) = source
15781            .theme
15782            .as_ref()
15783            .and_then(|theme| theme.version.as_deref())
15784        else {
15785            continue;
15786        };
15787        let Some(component) = source.component.as_ref() else {
15788            return false;
15789        };
15790        let key = (component.kind, component.slug.as_str());
15791        if source.outcome != "observed"
15792            || component.kind != "theme"
15793            || discovered.insert(key, version).is_some()
15794            || (source.parent_depth == 0 && required.insert(key, version).is_some())
15795        {
15796            return false;
15797        }
15798    }
15799
15800    let mut reviewed = std::collections::BTreeMap::new();
15801    for component in &review.components {
15802        for version in &component.versions {
15803            if version.source != "theme_stylesheet_declaration" {
15804                continue;
15805            }
15806            if component.identity.kind != "theme"
15807                || reviewed
15808                    .insert(
15809                        (component.identity.kind, component.identity.slug.as_str()),
15810                        version.value.as_str(),
15811                    )
15812                    .is_some()
15813            {
15814                return false;
15815            }
15816        }
15817    }
15818    required
15819        .iter()
15820        .all(|(component, version)| reviewed.get(component) == Some(version))
15821        && reviewed
15822            .iter()
15823            .all(|(component, version)| discovered.get(component) == Some(version))
15824}
15825
15826#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15827impl WordPressDiscoveryLayoutDocument {
15828    fn is_valid(&self) -> bool {
15829        const ROLES: [&str; 4] = ["core", "themes", "plugins", "rest_index"];
15830        valid_wordpress_discovery_reference(&self.application_reference)
15831            && self.declaration.as_ref().is_none_or(|declaration| {
15832                declaration.schema == WORDPRESS_DISCOVERY_LAYOUT_SCHEMA
15833                    && declaration.byte_length > 0
15834                    && declaration.byte_length <= MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES
15835                    && valid_lowercase_sha256(&declaration.sha256)
15836            })
15837            && self.roles.len() == ROLES.len()
15838            && self
15839                .roles
15840                .iter()
15841                .zip(ROLES)
15842                .all(|(role, expected)| role.role == expected && role.is_valid())
15843            && self.declaration.is_some()
15844                == self
15845                    .roles
15846                    .iter()
15847                    .any(|role| role.basis == "operator_declaration")
15848    }
15849
15850    fn exact_role(&self, role: &str) -> Option<&WordPressDiscoveryLayoutRoleDocument> {
15851        self.roles
15852            .iter()
15853            .find(|entry| entry.role == role && entry.status == "exact")
15854    }
15855
15856    fn exact_role_reference(&self, role: &str) -> Option<&str> {
15857        self.exact_role(role)
15858            .and_then(|entry| entry.reference.as_deref())
15859    }
15860}
15861
15862#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15863impl WordPressDiscoveryLayoutRoleDocument {
15864    fn is_valid(&self) -> bool {
15865        let role_valid = matches!(self.role, "core" | "themes" | "plugins" | "rest_index");
15866        let basis_valid = matches!(
15867            self.basis,
15868            "conventional_asset" | "structured_advertisement" | "operator_declaration" | "none"
15869        ) && match self.role {
15870            "rest_index" => matches!(
15871                self.basis,
15872                "structured_advertisement" | "operator_declaration" | "none"
15873            ),
15874            "core" | "themes" | "plugins" => matches!(
15875                self.basis,
15876                "conventional_asset" | "operator_declaration" | "none"
15877            ),
15878            _ => false,
15879        };
15880        role_valid
15881            && basis_valid
15882            && match self.status {
15883                "exact" => {
15884                    self.basis != "none"
15885                        && self.candidate_count > 0
15886                        && self
15887                            .reference
15888                            .as_deref()
15889                            .is_some_and(valid_wordpress_discovery_reference)
15890                },
15891                "ambiguous" => {
15892                    self.basis != "none" && self.candidate_count >= 2 && self.reference.is_none()
15893                },
15894                "unresolved" => {
15895                    self.basis == "none" && self.candidate_count == 0 && self.reference.is_none()
15896                },
15897                _ => false,
15898            }
15899    }
15900}
15901
15902#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
15903impl WordPressDiscoverySourceDocument {
15904    fn is_valid(
15905        &self,
15906        layout: &WordPressDiscoveryLayoutDocument,
15907        accepted_page_references: Option<&std::collections::BTreeSet<String>>,
15908        entry_page_reference: Option<&str>,
15909        accepted_supplied_session_page_references: Option<&std::collections::BTreeSet<String>>,
15910        supplied_session_scoped: bool,
15911    ) -> bool {
15912        let component_valid = self.component.as_ref().is_none_or(|component| {
15913            matches!(component.kind, "core" | "plugin" | "theme")
15914                && crate::wordpress_review::valid_slug(&component.slug)
15915                && ((component.kind == "core") == (component.slug == "wordpress"))
15916        });
15917        let strings_valid = self
15918            .namespaces
15919            .iter()
15920            .all(|value| valid_wordpress_discovery_namespace(value))
15921            && self
15922                .theme
15923                .as_ref()
15924                .is_none_or(WordPressThemeDiscoveryDocument::is_valid)
15925            && self
15926                .plugin
15927                .as_ref()
15928                .is_none_or(WordPressPluginDiscoveryDocument::is_valid);
15929        let source_shape_valid = match self.kind {
15930            "rest_index" => {
15931                self.component.is_none()
15932                    && self.parent_depth == 0
15933                    && self.theme.is_none()
15934                    && self.plugin.is_none()
15935            },
15936            "theme_stylesheet" => {
15937                self.component
15938                    .as_ref()
15939                    .is_some_and(|component| component.kind == "theme")
15940                    && self.parent_depth <= 1
15941                    && self.namespaces.is_empty()
15942                    && self.plugin.is_none()
15943            },
15944            "plugin_readme" => {
15945                self.component
15946                    .as_ref()
15947                    .is_some_and(|component| component.kind == "plugin")
15948                    && self.parent_depth == 0
15949                    && self.namespaces.is_empty()
15950                    && self.theme.is_none()
15951            },
15952            _ => false,
15953        };
15954        let observed_shape_valid = match self.kind {
15955            "rest_index" => !self.namespaces.is_empty(),
15956            "theme_stylesheet" => self
15957                .theme
15958                .as_ref()
15959                .is_some_and(|metadata| metadata.name.is_some()),
15960            "plugin_readme" => self
15961                .plugin
15962                .as_ref()
15963                .is_some_and(|metadata| metadata.name.is_some()),
15964            _ => false,
15965        };
15966        let completed_response = matches!(
15967            self.outcome,
15968            "observed"
15969                | "no_metadata"
15970                | "not_found"
15971                | "unauthorized"
15972                | "rate_limited"
15973                | "redirect_observed"
15974                | "unsupported_content"
15975                | "malformed"
15976                | "truncated"
15977        );
15978        let outcome_valid = completed_response
15979            || matches!(
15980                self.outcome,
15981                "invalid_advertisement"
15982                    | "request_failed"
15983                    | "budget_exhausted"
15984                    | "cancelled"
15985                    | "deadline_exceeded"
15986                    | "not_selected_by_limit"
15987                    | "not_attempted_after_throttle"
15988            );
15989        let metadata_absent =
15990            self.namespaces.is_empty() && self.theme.is_none() && self.plugin.is_none();
15991        let definitely_not_dispatched = matches!(
15992            self.outcome,
15993            "invalid_advertisement" | "not_selected_by_limit" | "not_attempted_after_throttle"
15994        );
15995        let role = match self.kind {
15996            "rest_index" => "rest_index",
15997            "theme_stylesheet" => "themes",
15998            "plugin_readme" => "plugins",
15999            _ => return false,
16000        };
16001        let exact_role_bound = self.role_reference.as_deref().is_some_and(|reference| {
16002            layout.exact_role_reference(role) == Some(reference)
16003                && valid_wordpress_discovery_reference(reference)
16004        });
16005        let binding_required = self.request_attempted
16006            || completed_response
16007            || self.theme.is_some()
16008            || self.plugin.is_some()
16009            || !self.namespaces.is_empty();
16010        let association_valid = match self.association {
16011            "structured_advertisement" | "operator_qualified_advertisement" => {
16012                role == "rest_index"
16013                    && self.role_reference.is_some()
16014                    && layout.exact_role(role).is_some_and(|entry| {
16015                        entry.basis
16016                            == if self.association == "structured_advertisement" {
16017                                "structured_advertisement"
16018                            } else {
16019                                "operator_declaration"
16020                            }
16021                    })
16022            },
16023            "observed_conventional" | "explicit_operator" => {
16024                matches!(role, "themes" | "plugins")
16025                    && self.parent_depth == 0
16026                    && self.role_reference.is_some()
16027                    && layout.exact_role(role).is_some_and(|entry| {
16028                        entry.basis
16029                            == if self.association == "observed_conventional" {
16030                                "conventional_asset"
16031                            } else {
16032                                "operator_declaration"
16033                            }
16034                    })
16035            },
16036            "same_theme_base_parent" => {
16037                role == "themes" && self.parent_depth == 1 && self.role_reference.is_some()
16038            },
16039            "invalid_advertisement" => {
16040                role == "rest_index"
16041                    && self.outcome == "invalid_advertisement"
16042                    && self.role_reference.is_none()
16043            },
16044            _ => false,
16045        };
16046        let source_pages_valid = match (accepted_page_references, entry_page_reference) {
16047            (Some(accepted), Some(entry)) => {
16048                !self.source_page_references.is_empty()
16049                    && self.source_page_references.len() <= 4
16050                    && self
16051                        .source_page_references
16052                        .windows(2)
16053                        .all(|pair| pair[0] < pair[1])
16054                    && self.source_page_references.iter().all(|reference| {
16055                        valid_wordpress_discovery_reference(reference)
16056                            && (reference == entry || accepted.contains(reference))
16057                    })
16058            },
16059            (None, None) => self.source_page_references.is_empty(),
16060            _ => false,
16061        };
16062        let supplied_session_pages_valid = match (
16063            supplied_session_scoped,
16064            self.source_supplied_session_page_references.as_ref(),
16065            accepted_supplied_session_page_references,
16066        ) {
16067            (true, Some(references), Some(accepted)) => {
16068                references.len() <= MAX_WORDPRESS_SUPPLIED_SESSION_PAGE_AUDIT_COUNT
16069                    && references.windows(2).all(|pair| pair[0] < pair[1])
16070                    && references.iter().all(|reference| {
16071                        valid_wordpress_discovery_reference(reference)
16072                            && accepted.contains(reference)
16073                    })
16074            },
16075            (false, None, None) => true,
16076            _ => false,
16077        };
16078        component_valid
16079            && strings_valid
16080            && source_shape_valid
16081            && outcome_valid
16082            && source_pages_valid
16083            && supplied_session_pages_valid
16084            && valid_wordpress_discovery_reference(&self.resource_reference)
16085            && association_valid
16086            && ((!binding_required && self.role_reference.is_none()) || exact_role_bound)
16087            && self.evidence_reference_count == usize::from(completed_response)
16088            && self.evidence_references.len() == self.evidence_reference_count
16089            && self
16090                .evidence_references
16091                .iter()
16092                .all(|reference| valid_opaque_assessment_reference(reference, "evidence"))
16093            && (!completed_response || self.request_attempted)
16094            && (self.request_attempted || self.response_bytes == 0)
16095            && (!definitely_not_dispatched || !self.request_attempted)
16096            && (if self.outcome == "observed" {
16097                observed_shape_valid
16098            } else {
16099                metadata_absent
16100            })
16101            && self.namespaces.len() <= MAX_WORDPRESS_DISCOVERY_AUDIT_NAMESPACES
16102    }
16103
16104    fn is_exact_role_bound_component(&self, layout: &WordPressDiscoveryLayoutDocument) -> bool {
16105        if self.parent_depth != 0 || !matches!(self.kind, "theme_stylesheet" | "plugin_readme") {
16106            return false;
16107        }
16108        let role = if self.kind == "theme_stylesheet" {
16109            "themes"
16110        } else {
16111            "plugins"
16112        };
16113        self.role_reference
16114            .as_deref()
16115            .is_some_and(|reference| layout.exact_role_reference(role) == Some(reference))
16116    }
16117
16118    fn is_exact_theme_role_bound(&self, layout: &WordPressDiscoveryLayoutDocument) -> bool {
16119        self.kind == "theme_stylesheet"
16120            && self.parent_depth <= 1
16121            && self
16122                .role_reference
16123                .as_deref()
16124                .is_some_and(|reference| layout.exact_role_reference("themes") == Some(reference))
16125    }
16126}
16127
16128#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16129fn valid_wordpress_discovery_reference(value: &str) -> bool {
16130    valid_prefixed_lowercase_sha256(value, "sha256:")
16131}
16132
16133#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16134impl WordPressThemeDiscoveryDocument {
16135    fn is_valid(&self) -> bool {
16136        [
16137            self.name.as_deref(),
16138            self.template.as_deref(),
16139            self.requires_wordpress.as_deref(),
16140            self.requires_php.as_deref(),
16141            self.tested_up_to.as_deref(),
16142        ]
16143        .into_iter()
16144        .flatten()
16145        .all(valid_wordpress_discovery_metadata)
16146            && self
16147                .version
16148                .as_deref()
16149                .is_none_or(valid_wordpress_discovery_version)
16150    }
16151}
16152
16153#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16154impl WordPressPluginDiscoveryDocument {
16155    fn is_valid(&self) -> bool {
16156        [
16157            self.name.as_deref(),
16158            self.stable_tag.as_deref(),
16159            self.requires_wordpress.as_deref(),
16160            self.requires_php.as_deref(),
16161            self.tested_up_to.as_deref(),
16162        ]
16163        .into_iter()
16164        .flatten()
16165        .all(valid_wordpress_discovery_metadata)
16166    }
16167}
16168
16169#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16170fn valid_wordpress_discovery_metadata(value: &str) -> bool {
16171    !value.is_empty()
16172        && value.len() <= MAX_WORDPRESS_DISCOVERY_AUDIT_METADATA_BYTES
16173        && !value.chars().any(char::is_control)
16174}
16175
16176#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16177fn valid_wordpress_discovery_version(value: &str) -> bool {
16178    !value.is_empty()
16179        && value.len() <= MAX_WORDPRESS_DISCOVERY_AUDIT_VERSION_BYTES
16180        && !value.chars().any(char::is_control)
16181}
16182
16183#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16184fn valid_wordpress_discovery_namespace(value: &str) -> bool {
16185    !value.is_empty()
16186        && value.len() <= MAX_WORDPRESS_DISCOVERY_AUDIT_NAMESPACE_BYTES
16187        && value.is_ascii()
16188        && !value.chars().any(char::is_control)
16189}
16190
16191#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16192fn wordpress_external_advisory_group(
16193    evaluation: &WordPressExternalEvaluationDocument,
16194) -> WordPressPresentationGroup {
16195    match evaluation.applicability {
16196        "version_match_under_selected_policy" => WordPressPresentationGroup::ReviewCandidate,
16197        "no_version_match_under_selected_policy" => WordPressPresentationGroup::Contradicted,
16198        _ => WordPressPresentationGroup::Limitation,
16199    }
16200}
16201
16202#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16203fn wordpress_advisory_group(advisory: &WordPressAdvisoryDocument) -> WordPressPresentationGroup {
16204    match advisory.applicability {
16205        "candidate_match_on_declared_facts" => WordPressPresentationGroup::ReviewCandidate,
16206        "contradicted_by_declared_facts" => WordPressPresentationGroup::Contradicted,
16207        _ => WordPressPresentationGroup::Limitation,
16208    }
16209}
16210
16211#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16212fn advisory_has_source_guidance(advisory: &WordPressAdvisoryDocument) -> bool {
16213    !advisory.fixed_versions.is_empty() || advisory.remediation.is_some()
16214}
16215
16216#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16217fn external_evaluation_has_source_guidance(
16218    evaluation: &WordPressExternalEvaluationDocument,
16219) -> bool {
16220    evaluation.source_patched
16221        || !evaluation.source_patched_versions.is_empty()
16222        || !evaluation.source_remediation.is_empty()
16223}
16224
16225#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16226struct WordPressExternalComponentValidation<'a> {
16227    component: &'a WordPressComponentDocument,
16228    evidence_status: &'static str,
16229    evidence_row_count: usize,
16230    distinct_spelling_count: usize,
16231    semantic_status: Option<&'static str>,
16232    semantic_reason: Option<&'static str>,
16233    selected_version: Option<crate::wordpress_version::ProfiledVersionKey>,
16234}
16235
16236#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16237impl WordPressExternalReviewDocument {
16238    fn external_collection_limit(&self) -> usize {
16239        const RESOURCE_V1_COLLECTION_LIMIT: usize = 64;
16240        if matches!(
16241            self.resource_policy,
16242            Some(WORDFENCE_V3_RESOURCE_POLICY_V2 | WORDFENCE_V3_RESOURCE_POLICY_V3)
16243        ) {
16244            crate::wordpress_review::MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION
16245        } else {
16246            RESOURCE_V1_COLLECTION_LIMIT
16247        }
16248    }
16249
16250    fn external_description_limit(&self) -> usize {
16251        const RESOURCE_V1_DESCRIPTION_LIMIT: usize = 2_048;
16252        const RESOURCE_V2_DESCRIPTION_LIMIT: usize = 4_096;
16253        if matches!(
16254            self.resource_policy,
16255            Some(WORDFENCE_V3_RESOURCE_POLICY_V2 | WORDFENCE_V3_RESOURCE_POLICY_V3)
16256        ) {
16257            RESOURCE_V2_DESCRIPTION_LIMIT
16258        } else {
16259            RESOURCE_V1_DESCRIPTION_LIMIT
16260        }
16261    }
16262
16263    fn accounted_retained_limit(&self) -> usize {
16264        const RESOURCE_V1_RETAINED_INDEX_LIMIT: usize = 64 * 1_024 * 1_024;
16265        const RESOURCE_V2_RETAINED_INDEX_LIMIT: usize = 128 * 1_024 * 1_024;
16266        match self.resource_policy {
16267            Some(WORDFENCE_V3_RESOURCE_POLICY_V3) => {
16268                crate::wordpress_review::MAX_WORDFENCE_V3_RETAINED_BYTES
16269            },
16270            Some(WORDFENCE_V3_RESOURCE_POLICY_V2) => RESOURCE_V2_RETAINED_INDEX_LIMIT,
16271            _ => RESOURCE_V1_RETAINED_INDEX_LIMIT,
16272        }
16273    }
16274
16275    fn source_identity_contract_is_valid(&self, source_identity: bool) -> bool {
16276        let identity_counts = [
16277            self.counts.exact_identity_associations,
16278            self.counts.candidate_identity_associations,
16279            self.counts.ambiguous_identity_associations,
16280            self.counts.unresolved_identity_associations,
16281        ];
16282        if !source_identity {
16283            return self.mapping_revision == WORDFENCE_V3_MAPPING_REVISION
16284                && self.identity_mapping_policy.is_none()
16285                && self.identity_source_assurance.is_none()
16286                && self.resource_policy.is_none()
16287                && self.input.accounted_retained_bytes.is_none()
16288                && self.counts.mapped_unselected_associations.is_none()
16289                && self.counts.projected_identity_limitations.is_none()
16290                && self.counts.unprojected_identity_limitations.is_none()
16291                && identity_counts.iter().all(Option::is_none)
16292                && self.evaluations.iter().all(|evaluation| {
16293                    evaluation.identity_mapping.is_none()
16294                        && evaluation.identity_collision_raw_count.is_none()
16295                        && evaluation.key.source_component.is_none()
16296                        && evaluation.key.source_association_fingerprint.is_none()
16297                })
16298                && self.identity_limitations.is_none();
16299        }
16300
16301        let [Some(exact), Some(candidate), Some(ambiguous), Some(unresolved)] = identity_counts
16302        else {
16303            return false;
16304        };
16305        let mapping_revision_is_valid = match self.mapping_revision {
16306            WORDFENCE_V3_MAPPING_REVISION => candidate == 0 && ambiguous == 0 && unresolved == 0,
16307            WORDFENCE_V3_MAPPING_REVISION_V2 => candidate != 0 || ambiguous != 0 || unresolved != 0,
16308            _ => false,
16309        };
16310        let resource_policy_is_valid = matches!(
16311            self.resource_policy,
16312            Some(
16313                WORDFENCE_V3_RESOURCE_POLICY_V1
16314                    | WORDFENCE_V3_RESOURCE_POLICY_V2
16315                    | WORDFENCE_V3_RESOURCE_POLICY_V3
16316            )
16317        );
16318        let v6_is_required = self.mapping_revision == WORDFENCE_V3_MAPPING_REVISION_V2
16319            || self.resource_policy != Some(WORDFENCE_V3_RESOURCE_POLICY_V1);
16320        let limitations_match = match (
16321            self.counts.projected_identity_limitations,
16322            self.counts.unprojected_identity_limitations,
16323            self.identity_limitations.as_ref(),
16324        ) {
16325            (Some(projected), Some(unprojected), Some(limitations)) => {
16326                projected == limitations.len()
16327                    && projected <= MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS
16328                    && projected.checked_add(unprojected) == Some(unresolved)
16329            },
16330            _ => false,
16331        };
16332        let excluded_partition_matches = self
16333            .counts
16334            .mapped_unselected_associations
16335            .and_then(|mapped| mapped.checked_add(unresolved))
16336            == Some(self.counts.excluded_associations);
16337        mapping_revision_is_valid
16338            && self.identity_mapping_policy == Some(WORDFENCE_V3_IDENTITY_MAPPING_POLICY)
16339            && self.identity_source_assurance == Some("not_established")
16340            && resource_policy_is_valid
16341            && v6_is_required
16342            && limitations_match
16343            && excluded_partition_matches
16344            && self.input.accounted_retained_bytes.is_some()
16345            && exact
16346                .checked_add(candidate)
16347                .and_then(|count| count.checked_add(ambiguous))
16348                .and_then(|count| count.checked_add(unresolved))
16349                == Some(self.counts.software_associations)
16350    }
16351
16352    fn is_valid(
16353        &self,
16354        components: &[WordPressComponentDocument],
16355        explicit: bool,
16356        source_identity: bool,
16357    ) -> bool {
16358        let selected_profile = match (
16359            explicit,
16360            self.comparison_profile,
16361            self.policy_selection,
16362            self.source_semantics_assurance,
16363        ) {
16364            (false, None, None, None) => None,
16365            (
16366                true,
16367                Some(profile @ ("numeric-dotted/v1" | "php-release-subset/v1")),
16368                Some("explicit_operator"),
16369                Some("not_established"),
16370            ) => Some(profile),
16371            _ => return false,
16372        };
16373        if self.source_namespace != "wordfence-intelligence"
16374            || self.source_format != "wordfence-v3-production"
16375            || !self.source_identity_contract_is_valid(source_identity)
16376            || (!explicit
16377                && self.comparison_policy
16378                    != WordPressExternalComparisonPolicy::WordfenceV3SourceSemanticsUnresolvedV1
16379                        .id())
16380            || (explicit
16381                && self.comparison_policy
16382                    != WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1
16383                        .id())
16384            || self.input.byte_length == 0
16385            || self.input.byte_length
16386                > crate::wordpress_review::MAX_WORDFENCE_V3_PRODUCTION_BYTES as u64
16387            || !valid_lowercase_sha256(&self.input.sha256)
16388            || !valid_lowercase_sha256(&self.input.semantic_sha256)
16389            || self
16390                .input
16391                .accounted_retained_bytes
16392                .is_some_and(|bytes| bytes == 0 || bytes > self.accounted_retained_limit())
16393            || self.counts.parsed_records > crate::wordpress_review::MAX_WORDFENCE_V3_RECORDS
16394            || self.counts.software_associations
16395                > crate::wordpress_review::MAX_WORDFENCE_V3_SOFTWARE_ASSOCIATIONS
16396            || self.counts.selected_associations != self.evaluations.len()
16397            || self.counts.selected_associations > MAX_WORDPRESS_ADVISORY_RECORDS
16398            || self.notices.len() > MAX_WORDPRESS_ADVISORY_RECORDS
16399            || !wordpress_external_source_counts_are_valid(
16400                self.counts.parsed_records,
16401                self.counts.software_associations,
16402            )
16403            || self
16404                .counts
16405                .selected_associations
16406                .checked_add(self.counts.excluded_associations)
16407                != Some(self.counts.software_associations)
16408            || self
16409                .counts
16410                .evaluable_associations
16411                .checked_add(self.counts.unsupported_associations)
16412                != Some(self.counts.selected_associations)
16413            || !self.counts.is_valid(explicit)
16414        {
16415            return false;
16416        }
16417
16418        let mut notices_by_id = std::collections::BTreeMap::new();
16419        let notices_are_valid = self.notices.iter().all(|notice| {
16420            valid_prefixed_lowercase_sha256(&notice.id, "wordfence-notice-sha256:")
16421                && notices_by_id.insert(notice.id.as_str(), notice).is_none()
16422        });
16423        if !notices_are_valid || !self.notices.windows(2).all(|pair| pair[0].id < pair[1].id) {
16424            return false;
16425        }
16426
16427        let mut resolution_work = 0_usize;
16428        let mut component_validations = std::collections::BTreeMap::new();
16429        for component in components {
16430            if selected_profile.is_some() {
16431                let Some(next_work) = resolution_work.checked_add(component.versions.len()) else {
16432                    return false;
16433                };
16434                resolution_work = next_work;
16435                if resolution_work > crate::wordpress_review::MAX_WORDPRESS_VERSION_RESOLUTION_WORK
16436                {
16437                    return false;
16438                }
16439            }
16440            let Some(validation) = external_component_validation(component, selected_profile)
16441            else {
16442                return false;
16443            };
16444            if component_validations
16445                .insert(
16446                    (component.identity.kind, component.identity.slug.as_str()),
16447                    validation,
16448                )
16449                .is_some()
16450            {
16451                return false;
16452            }
16453        }
16454
16455        let mut evaluation_ids = std::collections::BTreeSet::new();
16456        let mut source_association_base_ids = std::collections::BTreeSet::new();
16457        let mut referenced_notices = std::collections::BTreeSet::new();
16458        let mut interpretation_work = 0_usize;
16459        let mut selected_identity_counts = [0_usize; 3];
16460        let collection_limit = self.external_collection_limit();
16461        let description_limit = self.external_description_limit();
16462        let expanded_source_values = matches!(
16463            self.resource_policy,
16464            Some(WORDFENCE_V3_RESOURCE_POLICY_V2 | WORDFENCE_V3_RESOURCE_POLICY_V3)
16465        );
16466        let evaluations_are_valid = self.evaluations.iter().all(|evaluation| {
16467            let Some(profiled_validation) = component_validations.get(&(
16468                evaluation.key.component.kind,
16469                evaluation.key.component.slug.as_str(),
16470            )) else {
16471                return false;
16472            };
16473            let Some(identity_mapping) =
16474                external_identity_mapping_is_valid(evaluation, source_identity)
16475            else {
16476                return false;
16477            };
16478            if evaluation
16479                .identity_collision_raw_count
16480                .is_some_and(|count| count > self.counts.software_associations)
16481            {
16482                return false;
16483            }
16484            selected_identity_counts[match identity_mapping {
16485                WordfenceV3IdentityMapping::Exact => 0,
16486                WordfenceV3IdentityMapping::AsciiCaseFoldCandidate => 1,
16487                WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous => 2,
16488            }] += 1;
16489            let unprofiled_validation;
16490            let validation = if explicit && identity_mapping != WordfenceV3IdentityMapping::Exact {
16491                unprofiled_validation =
16492                    external_component_validation(profiled_validation.component, None);
16493                let Some(validation) = unprofiled_validation.as_ref() else {
16494                    return false;
16495                };
16496                validation
16497            } else {
16498                profiled_validation
16499            };
16500            let component = validation.component;
16501            if explicit && identity_mapping == WordfenceV3IdentityMapping::Exact {
16502                let Some(next_work) =
16503                    crate::wordpress_version::checked_accumulate_external_interpretation_work(
16504                        interpretation_work,
16505                        evaluation.affected_ranges.len(),
16506                        evaluation.source_patched_versions.len(),
16507                        crate::wordpress_review::MAX_WORDPRESS_EVALUATION_WORK,
16508                    )
16509                else {
16510                    return false;
16511                };
16512                interpretation_work = next_work;
16513            } else if explicit {
16514                let Some(next_work) =
16515                    interpretation_work.checked_add(evaluation.affected_ranges.len())
16516                else {
16517                    return false;
16518                };
16519                interpretation_work = next_work;
16520                if interpretation_work > crate::wordpress_review::MAX_WORDPRESS_EVALUATION_WORK {
16521                    return false;
16522                }
16523            }
16524            let source_kind = evaluation
16525                .key
16526                .source_component
16527                .as_ref()
16528                .map_or(evaluation.key.component.kind, |source| source.kind);
16529            let source_slug = evaluation
16530                .key
16531                .source_component
16532                .as_ref()
16533                .map_or(evaluation.key.component.slug.as_str(), |source| {
16534                    source.slug.as_str()
16535                });
16536            let identity = (
16537                evaluation.key.source_namespace.as_str(),
16538                evaluation.key.upstream_id.as_str(),
16539                evaluation.key.component.kind,
16540                evaluation.key.component.slug.as_str(),
16541                source_kind,
16542                source_slug,
16543                evaluation.key.source_association_fingerprint.as_deref(),
16544            );
16545            let source_association_base_is_unique = source_association_base_ids.insert((
16546                evaluation.key.source_namespace.as_str(),
16547                evaluation.key.upstream_id.as_str(),
16548                source_kind,
16549                source_slug,
16550            ));
16551            let mut local_notices = std::collections::BTreeSet::new();
16552            let evaluation_notices_are_valid = evaluation.notice_ids.iter().all(|id| {
16553                if notices_by_id.contains_key(id.as_str()) && local_notices.insert(id.as_str()) {
16554                    referenced_notices.insert(id.clone());
16555                    true
16556                } else {
16557                    false
16558                }
16559            });
16560            let has_defiant_notice = evaluation.notice_ids.iter().any(|id| {
16561                notices_by_id
16562                    .get(id.as_str())
16563                    .is_some_and(|notice| notice.party == "defiant")
16564            });
16565            let expected_record_reference =
16566                wordpress_external_record_reference(&evaluation.references);
16567            let validated_version = external_version_evidence_resolution_is_valid(
16568                &evaluation.version_evidence_resolution,
16569                validation,
16570            );
16571            evaluation.key.source_namespace == self.source_namespace
16572                && valid_lowercase_uuid(&evaluation.key.upstream_id)
16573                && evaluation_ids.insert(identity)
16574                && (expanded_source_values || source_association_base_is_unique)
16575                && component.evidence_class == evaluation.component_evidence
16576                && (!source_identity
16577                    || evaluation
16578                        .key
16579                        .source_association_fingerprint
16580                        .as_deref()
16581                        .is_some_and(|fingerprint| {
16582                            valid_prefixed_lowercase_sha256(
16583                                fingerprint,
16584                                "wordfence-source-association-sha256:",
16585                            )
16586                        }))
16587                && validated_version.is_some()
16588                && evaluation.references.len()
16589                    <= crate::wordpress_review::MAX_WORDFENCE_V3_REFERENCES
16590                && external_researchers_are_valid(&evaluation.researchers, expanded_source_values)
16591                && evaluation.description.len() <= description_limit
16592                && evaluation
16593                    .cwe
16594                    .as_ref()
16595                    .is_none_or(|cwe| cwe.description.len() <= description_limit)
16596                && evaluation.affected_ranges.len() <= collection_limit
16597                && evaluation.source_patched_versions.len() <= collection_limit
16598                && external_source_versions_are_valid(
16599                    &evaluation.source_patched_versions,
16600                    expanded_source_values,
16601                )
16602                && evaluation.notice_ids.len()
16603                    <= crate::wordpress_review::MAX_WORDFENCE_V3_NOTICE_PARTIES
16604                && evaluation_notices_are_valid
16605                && evaluation.record_reference.as_deref() == expected_record_reference
16606                && (!has_defiant_notice
16607                    || evaluation
16608                        .record_reference
16609                        .as_deref()
16610                        .is_some_and(is_wordfence_vulnerability_reference))
16611                && (evaluation.cve_link.is_none() || evaluation.cve.is_some())
16612                && evaluation
16613                    .affected_ranges
16614                    .iter()
16615                    .all(|range| range.is_valid(expanded_source_values))
16616                && external_range_labels_are_unique(&evaluation.affected_ranges)
16617                && evaluation.is_valid_interpretation(
16618                    explicit,
16619                    selected_profile,
16620                    validated_version.flatten(),
16621                    identity_mapping,
16622                )
16623                && evaluation.execution.exploit_execution == "not_performed"
16624                && evaluation.execution.impact_validation == "not_performed"
16625        });
16626        let mut limitation_ids = std::collections::BTreeSet::new();
16627        let limitations_are_valid = if source_identity {
16628            self.identity_limitations
16629                .as_ref()
16630                .is_some_and(|limitations| {
16631                    limitations.iter().all(|limitation| {
16632                        let source_association_base_is_unique =
16633                            source_association_base_ids.insert((
16634                                limitation.key.source_namespace,
16635                                limitation.key.upstream_id.as_str(),
16636                                limitation.key.source_component.kind,
16637                                limitation.key.source_component.slug.as_str(),
16638                            ));
16639                        limitation.is_valid(
16640                            self.source_namespace,
16641                            &notices_by_id,
16642                            &mut referenced_notices,
16643                            collection_limit,
16644                            expanded_source_values,
16645                        ) && (expanded_source_values || source_association_base_is_unique)
16646                            && limitation_ids.insert((
16647                                limitation.key.source_namespace,
16648                                limitation.key.upstream_id.as_str(),
16649                                limitation.key.source_component.kind,
16650                                limitation.key.source_component.slug.as_str(),
16651                                limitation.key.source_association_fingerprint.as_str(),
16652                            ))
16653                    }) && limitations.windows(2).all(|pair| {
16654                        (
16655                            pair[0].key.source_namespace,
16656                            pair[0].key.upstream_id.as_str(),
16657                            pair[0].key.source_component.kind,
16658                            pair[0].key.source_component.slug.as_str(),
16659                            pair[0].key.source_association_fingerprint.as_str(),
16660                        ) < (
16661                            pair[1].key.source_namespace,
16662                            pair[1].key.upstream_id.as_str(),
16663                            pair[1].key.source_component.kind,
16664                            pair[1].key.source_component.slug.as_str(),
16665                            pair[1].key.source_association_fingerprint.as_str(),
16666                        )
16667                    })
16668                })
16669        } else {
16670            self.identity_limitations.is_none()
16671        };
16672        evaluations_are_valid
16673            && limitations_are_valid
16674            && self
16675                .counts
16676                .selected_identity_counts_are_valid(selected_identity_counts)
16677            && self.counts.matches_evaluations(&self.evaluations, explicit)
16678            && referenced_notices == notices_by_id.keys().map(|id| (*id).to_owned()).collect()
16679            && self.evaluations.windows(2).all(|pair| {
16680                (
16681                    pair[0].key.source_namespace.as_str(),
16682                    pair[0].key.upstream_id.as_str(),
16683                    pair[0].key.component.kind,
16684                    pair[0].key.component.slug.as_str(),
16685                    pair[0]
16686                        .key
16687                        .source_component
16688                        .as_ref()
16689                        .map_or(pair[0].key.component.kind, |source| source.kind),
16690                    pair[0]
16691                        .key
16692                        .source_component
16693                        .as_ref()
16694                        .map_or(pair[0].key.component.slug.as_str(), |source| {
16695                            source.slug.as_str()
16696                        }),
16697                    pair[0].key.source_association_fingerprint.as_deref(),
16698                ) < (
16699                    pair[1].key.source_namespace.as_str(),
16700                    pair[1].key.upstream_id.as_str(),
16701                    pair[1].key.component.kind,
16702                    pair[1].key.component.slug.as_str(),
16703                    pair[1]
16704                        .key
16705                        .source_component
16706                        .as_ref()
16707                        .map_or(pair[1].key.component.kind, |source| source.kind),
16708                    pair[1]
16709                        .key
16710                        .source_component
16711                        .as_ref()
16712                        .map_or(pair[1].key.component.slug.as_str(), |source| {
16713                            source.slug.as_str()
16714                        }),
16715                    pair[1].key.source_association_fingerprint.as_deref(),
16716                )
16717            })
16718    }
16719}
16720
16721#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16722impl WordPressExternalIdentityLimitationDocument {
16723    fn is_valid(
16724        &self,
16725        namespace: &str,
16726        notices: &std::collections::BTreeMap<&str, &WordPressExternalNoticeDocument>,
16727        referenced_notices: &mut std::collections::BTreeSet<String>,
16728        collection_limit: usize,
16729        expanded_source_values: bool,
16730    ) -> bool {
16731        let expected_record_reference = wordpress_external_record_reference(&self.references);
16732        let mut local_notices = std::collections::BTreeSet::new();
16733        let notice_ids_are_valid = self.notice_ids.iter().all(|id| {
16734            if notices.contains_key(id.as_str()) && local_notices.insert(id.as_str()) {
16735                referenced_notices.insert(id.clone());
16736                true
16737            } else {
16738                false
16739            }
16740        });
16741        let has_defiant_notice = self.notice_ids.iter().any(|id| {
16742            notices
16743                .get(id.as_str())
16744                .is_some_and(|notice| notice.party == "defiant")
16745        });
16746        self.key.source_namespace == namespace
16747            && valid_lowercase_uuid(&self.key.upstream_id)
16748            && valid_prefixed_lowercase_sha256(
16749                &self.key.source_association_fingerprint,
16750                "wordfence-source-association-sha256:",
16751            )
16752            && valid_wordfence_raw_source_identity(&self.key.source_component)
16753            && !wordfence_raw_source_identity_can_map(&self.key.source_component)
16754            && self.identity_resolution == "canonical_identity_unavailable"
16755            && !self.title.is_empty()
16756            && self.title.len() <= 1_024
16757            && !self.display_name.is_empty()
16758            && self.display_name.len() <= 1_024
16759            && self.references.len() <= crate::wordpress_review::MAX_WORDFENCE_V3_REFERENCES
16760            && self.record_reference.as_deref() == expected_record_reference
16761            && (!has_defiant_notice
16762                || self
16763                    .record_reference
16764                    .as_deref()
16765                    .is_some_and(is_wordfence_vulnerability_reference))
16766            && self.affected_ranges.len() <= collection_limit
16767            && self
16768                .affected_ranges
16769                .iter()
16770                .all(|range| range.is_valid(expanded_source_values))
16771            && external_range_labels_are_unique(&self.affected_ranges)
16772            && self.range_evaluations.len() == self.affected_ranges.len()
16773            && self.range_evaluations.iter().all(|range| {
16774                range.relation == "not_evaluated"
16775                    && range.reason == "canonical_identity_unavailable"
16776            })
16777            && self.source_patched_versions.len() <= collection_limit
16778            && external_source_versions_are_valid(
16779                &self.source_patched_versions,
16780                expanded_source_values,
16781            )
16782            && self.source_remediation.len()
16783                <= crate::wordpress_review::MAX_WORDPRESS_REMEDIATION_BYTES
16784            && self.notice_ids.len() <= crate::wordpress_review::MAX_WORDFENCE_V3_NOTICE_PARTIES
16785            && notice_ids_are_valid
16786            && self.version_relation == "not_evaluated"
16787            && self.version_relation_reason == "canonical_identity_unavailable"
16788            && self.applicability == "indeterminate"
16789            && self.execution.exploit_execution == "not_performed"
16790            && self.execution.impact_validation == "not_performed"
16791    }
16792}
16793
16794#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16795impl WordPressExternalCountsDocument {
16796    fn selected_identity_counts_are_valid(&self, selected: [usize; 3]) -> bool {
16797        match (
16798            self.exact_identity_associations,
16799            self.candidate_identity_associations,
16800            self.ambiguous_identity_associations,
16801            self.unresolved_identity_associations,
16802        ) {
16803            (None, None, None, None) => {
16804                selected[0] == self.selected_associations && selected[1] == 0 && selected[2] == 0
16805            },
16806            (Some(exact), Some(candidate), Some(ambiguous), Some(_)) => {
16807                selected[0] <= exact
16808                    && selected[1] <= candidate
16809                    && selected[2] <= ambiguous
16810                    && selected
16811                        .iter()
16812                        .try_fold(0_usize, |total, count| total.checked_add(*count))
16813                        == Some(self.selected_associations)
16814            },
16815            _ => false,
16816        }
16817    }
16818
16819    fn is_valid(&self, explicit: bool) -> bool {
16820        let extended = [
16821            self.within_associations,
16822            self.outside_associations,
16823            self.indeterminate_associations,
16824            self.selected_ranges,
16825            self.evaluated_ranges,
16826            self.containing_ranges,
16827            self.noncontaining_ranges,
16828            self.unsupported_ranges,
16829            self.invalid_ranges,
16830            self.not_evaluated_ranges,
16831            self.partial_range_coverage_associations,
16832        ];
16833        if !explicit {
16834            return extended.iter().all(Option::is_none)
16835                && self.evaluable_associations == 0
16836                && self.unsupported_associations == self.selected_associations;
16837        }
16838        let [Some(within), Some(outside), Some(indeterminate), Some(selected_ranges), Some(evaluated_ranges), Some(containing), Some(noncontaining), Some(unsupported_ranges), Some(invalid_ranges), Some(not_evaluated_ranges), Some(partial)] =
16839            extended
16840        else {
16841            return false;
16842        };
16843        within
16844            .checked_add(outside)
16845            .is_some_and(|value| value == self.evaluable_associations)
16846            && self.unsupported_associations == indeterminate
16847            && self
16848                .evaluable_associations
16849                .checked_add(indeterminate)
16850                .is_some_and(|value| value == self.selected_associations)
16851            && containing
16852                .checked_add(noncontaining)
16853                .is_some_and(|value| value == evaluated_ranges)
16854            && evaluated_ranges
16855                .checked_add(unsupported_ranges)
16856                .and_then(|value| value.checked_add(invalid_ranges))
16857                .and_then(|value| value.checked_add(not_evaluated_ranges))
16858                .is_some_and(|value| value == selected_ranges)
16859            && partial <= within
16860    }
16861
16862    fn matches_evaluations(
16863        &self,
16864        evaluations: &[WordPressExternalEvaluationDocument],
16865        explicit: bool,
16866    ) -> bool {
16867        if !explicit {
16868            return true;
16869        }
16870        let mut within = 0_usize;
16871        let mut outside = 0_usize;
16872        let mut indeterminate = 0_usize;
16873        let mut containing = 0_usize;
16874        let mut noncontaining = 0_usize;
16875        let mut unsupported = 0_usize;
16876        let mut invalid = 0_usize;
16877        let mut not_evaluated = 0_usize;
16878        let mut partial = 0_usize;
16879        for evaluation in evaluations {
16880            match evaluation.version_relation {
16881                "within_supported_range_under_selected_policy" => within += 1,
16882                "outside_declared_ranges_under_selected_policy" => outside += 1,
16883                "indeterminate" => indeterminate += 1,
16884                _ => return false,
16885            }
16886            if evaluation.version_relation_reason == Some("containing_range_with_partial_coverage")
16887            {
16888                partial += 1;
16889            }
16890            let Some(ranges) = &evaluation.range_evaluations else {
16891                return false;
16892            };
16893            for range in ranges {
16894                match range.relation {
16895                    "contains" => containing += 1,
16896                    "does_not_contain" => noncontaining += 1,
16897                    "unsupported" => unsupported += 1,
16898                    "invalid_under_profile" => invalid += 1,
16899                    "not_evaluated" => not_evaluated += 1,
16900                    _ => return false,
16901                }
16902            }
16903        }
16904        self.within_associations == Some(within)
16905            && self.outside_associations == Some(outside)
16906            && self.indeterminate_associations == Some(indeterminate)
16907            && self.selected_ranges
16908                == containing
16909                    .checked_add(noncontaining)
16910                    .and_then(|value| value.checked_add(unsupported))
16911                    .and_then(|value| value.checked_add(invalid))
16912                    .and_then(|value| value.checked_add(not_evaluated))
16913            && self.containing_ranges == Some(containing)
16914            && self.noncontaining_ranges == Some(noncontaining)
16915            && self.unsupported_ranges == Some(unsupported)
16916            && self.invalid_ranges == Some(invalid)
16917            && self.not_evaluated_ranges == Some(not_evaluated)
16918            && self.partial_range_coverage_associations == Some(partial)
16919    }
16920}
16921
16922#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
16923impl WordPressExternalEvaluationDocument {
16924    fn is_valid_interpretation(
16925        &self,
16926        explicit: bool,
16927        selected_profile: Option<&str>,
16928        selected_version: Option<&crate::wordpress_version::ProfiledVersionKey>,
16929        identity_mapping: WordfenceV3IdentityMapping,
16930    ) -> bool {
16931        if !explicit {
16932            return self.range_evaluations.is_none()
16933                && self.version_relation == "source_comparison_semantics_unresolved"
16934                && self.version_relation_reason.is_none()
16935                && self.applicability == "indeterminate_unsupported";
16936        }
16937        let Some(ranges) = &self.range_evaluations else {
16938            return false;
16939        };
16940        if ranges.len() != self.affected_ranges.len()
16941            || !ranges
16942                .iter()
16943                .all(WordPressExternalRangeEvaluationDocument::is_valid)
16944        {
16945            return false;
16946        }
16947        let Some(profile) = selected_profile else {
16948            return false;
16949        };
16950        if identity_mapping != WordfenceV3IdentityMapping::Exact {
16951            let expected_reason = match identity_mapping {
16952                WordfenceV3IdentityMapping::Exact => return false,
16953                WordfenceV3IdentityMapping::AsciiCaseFoldCandidate => "identity_mapping_candidate",
16954                WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous => "identity_mapping_ambiguous",
16955            };
16956            return selected_version.is_none()
16957                && self.version_evidence_resolution.semantic_status.is_none()
16958                && self.version_evidence_resolution.semantic_reason.is_none()
16959                && ranges.iter().all(|range| {
16960                    range.relation == "not_evaluated" && range.reason == expected_reason
16961                })
16962                && self.version_relation == "indeterminate"
16963                && self.version_relation_reason == Some(expected_reason)
16964                && self.applicability == "indeterminate";
16965        }
16966        let semantic_status = self.version_evidence_resolution.semantic_status;
16967        if !ranges
16968            .iter()
16969            .zip(&self.affected_ranges)
16970            .all(|(rendered, source)| {
16971                expected_external_range_interpretation(
16972                    source,
16973                    profile,
16974                    semantic_status,
16975                    selected_version,
16976                ) == Some((rendered.relation, rendered.reason))
16977            })
16978        {
16979            return false;
16980        }
16981        let contains = ranges.iter().any(|range| range.relation == "contains");
16982        let unsupported = ranges.iter().any(|range| range.relation == "unsupported");
16983        let invalid = ranges
16984            .iter()
16985            .any(|range| range.relation == "invalid_under_profile");
16986        let source_patched_version_conflict = external_patched_version_conflicts(
16987            &self.source_patched_versions,
16988            &self.affected_ranges,
16989            profile,
16990        );
16991        let all_outside = !ranges.is_empty()
16992            && ranges
16993                .iter()
16994                .all(|range| range.relation == "does_not_contain");
16995        let semantic_status = self.version_evidence_resolution.semantic_status;
16996        match (
16997            self.version_relation,
16998            self.version_relation_reason,
16999            self.applicability,
17000        ) {
17001            (
17002                "within_supported_range_under_selected_policy",
17003                Some("containing_range"),
17004                "version_match_under_selected_policy",
17005            ) => {
17006                semantic_status == Some("supported_equivalent")
17007                    && contains
17008                    && !unsupported
17009                    && !invalid
17010                    && !source_patched_version_conflict
17011            },
17012            (
17013                "within_supported_range_under_selected_policy",
17014                Some("containing_range_with_partial_coverage"),
17015                "version_match_under_selected_policy",
17016            ) => {
17017                semantic_status == Some("supported_equivalent")
17018                    && contains
17019                    && unsupported
17020                    && !invalid
17021                    && !source_patched_version_conflict
17022            },
17023            (
17024                "outside_declared_ranges_under_selected_policy",
17025                Some("all_ranges_outside"),
17026                "no_version_match_under_selected_policy",
17027            ) => {
17028                semantic_status == Some("supported_equivalent")
17029                    && all_outside
17030                    && !source_patched_version_conflict
17031            },
17032            ("indeterminate", Some("invalid_affected_range"), "indeterminate") => invalid,
17033            (
17034                "indeterminate",
17035                Some("source_patched_version_within_affected_range"),
17036                "indeterminate",
17037            ) => !invalid && source_patched_version_conflict,
17038            ("indeterminate", Some("missing_version_evidence"), "indeterminate") => {
17039                semantic_status == Some("missing") && !invalid && !source_patched_version_conflict
17040            },
17041            ("indeterminate", Some("conflicting_version_evidence"), "indeterminate") => {
17042                semantic_status == Some("conflicting")
17043                    && !invalid
17044                    && !source_patched_version_conflict
17045            },
17046            ("indeterminate", Some("unsupported_version_evidence"), "indeterminate") => {
17047                semantic_status == Some("unsupported")
17048                    && !invalid
17049                    && !source_patched_version_conflict
17050            },
17051            ("indeterminate", Some("missing_affected_ranges"), "indeterminate") => {
17052                semantic_status == Some("supported_equivalent")
17053                    && ranges.is_empty()
17054                    && !source_patched_version_conflict
17055            },
17056            ("indeterminate", Some("unsupported_affected_range"), "indeterminate") => {
17057                semantic_status == Some("supported_equivalent")
17058                    && unsupported
17059                    && !contains
17060                    && !invalid
17061                    && !source_patched_version_conflict
17062            },
17063            _ => false,
17064        }
17065    }
17066}
17067
17068#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17069impl WordPressExternalRangeEvaluationDocument {
17070    fn is_valid(&self) -> bool {
17071        matches!(
17072            (self.relation, self.reason),
17073            ("contains", "selected_version_within_bounds")
17074                | ("does_not_contain", "selected_version_outside_bounds")
17075                | ("not_evaluated", "missing_version_evidence")
17076                | ("not_evaluated", "conflicting_version_evidence")
17077                | ("not_evaluated", "unsupported_version_evidence")
17078                | ("not_evaluated", "identity_mapping_candidate")
17079                | ("not_evaluated", "identity_mapping_ambiguous")
17080                | ("unsupported", "unsupported_lower_bound")
17081                | ("unsupported", "unsupported_upper_bound")
17082                | ("unsupported", "unsupported_both_bounds")
17083                | ("invalid_under_profile", "reversed_bounds")
17084                | ("invalid_under_profile", "empty_exclusive_interval")
17085        )
17086    }
17087}
17088
17089#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17090fn wordpress_external_source_counts_are_valid(
17091    parsed_records: usize,
17092    software_associations: usize,
17093) -> bool {
17094    match parsed_records {
17095        0 => software_associations == 0,
17096        count => {
17097            software_associations >= count
17098                && count
17099                    .checked_mul(MAX_WORDFENCE_V3_SOFTWARE_PER_RECORD)
17100                    .is_some_and(|maximum| software_associations <= maximum)
17101        },
17102    }
17103}
17104
17105#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17106fn external_version_evidence_resolution_is_valid<'a>(
17107    resolution: &WordPressExternalVersionEvidenceResolutionDocument,
17108    validation: &'a WordPressExternalComponentValidation<'_>,
17109) -> Option<Option<&'a crate::wordpress_version::ProfiledVersionKey>> {
17110    (resolution.evidence_row_count == validation.evidence_row_count
17111        && resolution.distinct_spelling_count == validation.distinct_spelling_count
17112        && resolution.status == validation.evidence_status
17113        && resolution.semantic_status == validation.semantic_status
17114        && resolution.semantic_reason == validation.semantic_reason)
17115        .then_some(validation.selected_version.as_ref())
17116}
17117
17118#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17119fn external_component_validation<'a>(
17120    component: &'a WordPressComponentDocument,
17121    selected_profile: Option<&str>,
17122) -> Option<WordPressExternalComponentValidation<'a>> {
17123    let distinct_spelling_count = component
17124        .versions
17125        .iter()
17126        .map(|version| version.value.as_str())
17127        .collect::<std::collections::BTreeSet<_>>()
17128        .len();
17129    let evidence_row_count = component.versions.len();
17130    let evidence_status =
17131        wordpress_external_version_evidence_status(evidence_row_count, distinct_spelling_count)?;
17132    let (semantic_status, semantic_reason, selected_version) = match selected_profile {
17133        None => (None, None, None),
17134        Some(profile) => {
17135            let (status, reason, selected) = external_semantic_resolution(component, profile)?;
17136            (Some(status), Some(reason), selected)
17137        },
17138    };
17139    Some(WordPressExternalComponentValidation {
17140        component,
17141        evidence_status,
17142        evidence_row_count,
17143        distinct_spelling_count,
17144        semantic_status,
17145        semantic_reason,
17146        selected_version,
17147    })
17148}
17149
17150#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17151fn external_semantic_resolution(
17152    component: &WordPressComponentDocument,
17153    profile: &str,
17154) -> Option<(
17155    &'static str,
17156    &'static str,
17157    Option<crate::wordpress_version::ProfiledVersionKey>,
17158)> {
17159    let profile = match profile {
17160        "numeric-dotted/v1" => WordPressComparisonProfile::NumericDottedV1,
17161        "php-release-subset/v1" => WordPressComparisonProfile::PhpReleaseSubsetV1,
17162        _ => return None,
17163    };
17164    if component.versions.is_empty() {
17165        return Some(("missing", "no_version_evidence", None));
17166    }
17167    let mut selected = None::<crate::wordpress_version::ProfiledVersionKey>;
17168    let mut conflicting = false;
17169    let mut unsupported = false;
17170    for evidence in &component.versions {
17171        match crate::wordpress_version::ProfiledVersionKey::parse(profile, &evidence.value) {
17172            Ok(version) => {
17173                if selected.as_ref().is_some_and(|selected| {
17174                    selected.compare(&version).ok() != Some(std::cmp::Ordering::Equal)
17175                }) {
17176                    conflicting = true;
17177                } else if selected.is_none() {
17178                    selected = Some(version);
17179                }
17180            },
17181            Err(_) => unsupported = true,
17182        }
17183    }
17184    if conflicting {
17185        Some(("conflicting", "conflicting_version_evidence", None))
17186    } else if unsupported || selected.is_none() {
17187        Some(("unsupported", "unsupported_version_evidence", None))
17188    } else if component.versions.len() == 1 {
17189        Some(("supported_equivalent", "single_supported_version", selected))
17190    } else {
17191        Some((
17192            "supported_equivalent",
17193            "equivalent_supported_versions",
17194            selected,
17195        ))
17196    }
17197}
17198
17199#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17200fn expected_external_range_interpretation(
17201    range: &WordPressExternalRangeDocument,
17202    profile: &str,
17203    semantic_status: Option<&str>,
17204    selected_version: Option<&crate::wordpress_version::ProfiledVersionKey>,
17205) -> Option<(&'static str, &'static str)> {
17206    let profile = match profile {
17207        "numeric-dotted/v1" => WordPressComparisonProfile::NumericDottedV1,
17208        "php-release-subset/v1" => WordPressComparisonProfile::PhpReleaseSubsetV1,
17209        _ => return None,
17210    };
17211    let lower = reporting_external_range_endpoint(
17212        range.from_kind,
17213        &range.from_version,
17214        range.from_inclusive,
17215        profile,
17216    );
17217    let upper = reporting_external_range_endpoint(
17218        range.to_kind,
17219        &range.to_version,
17220        range.to_inclusive,
17221        profile,
17222    );
17223    let (lower, upper) = match (lower, upper) {
17224        (Err(()), Err(())) => return Some(("unsupported", "unsupported_both_bounds")),
17225        (Err(()), Ok(_)) => return Some(("unsupported", "unsupported_lower_bound")),
17226        (Ok(_), Err(())) => return Some(("unsupported", "unsupported_upper_bound")),
17227        (Ok(lower), Ok(upper)) => (lower, upper),
17228    };
17229    if let (Some((lower, lower_inclusive)), Some((upper, upper_inclusive))) = (&lower, &upper) {
17230        match lower.compare(upper).ok()? {
17231            std::cmp::Ordering::Greater => {
17232                return Some(("invalid_under_profile", "reversed_bounds"));
17233            },
17234            std::cmp::Ordering::Equal if !(*lower_inclusive && *upper_inclusive) => {
17235                return Some(("invalid_under_profile", "empty_exclusive_interval"));
17236            },
17237            std::cmp::Ordering::Less | std::cmp::Ordering::Equal => {},
17238        }
17239    }
17240    let Some(selected) = selected_version else {
17241        return match semantic_status {
17242            Some("missing") => Some(("not_evaluated", "missing_version_evidence")),
17243            Some("conflicting") => Some(("not_evaluated", "conflicting_version_evidence")),
17244            Some("unsupported") => Some(("not_evaluated", "unsupported_version_evidence")),
17245            _ => None,
17246        };
17247    };
17248    if semantic_status != Some("supported_equivalent") {
17249        return None;
17250    }
17251    let above_lower = lower.as_ref().is_none_or(|(lower, inclusive)| {
17252        lower.compare(selected).ok().is_some_and(|ordering| {
17253            ordering == std::cmp::Ordering::Less
17254                || (ordering == std::cmp::Ordering::Equal && *inclusive)
17255        })
17256    });
17257    let below_upper = upper.as_ref().is_none_or(|(upper, inclusive)| {
17258        selected.compare(upper).ok().is_some_and(|ordering| {
17259            ordering == std::cmp::Ordering::Less
17260                || (ordering == std::cmp::Ordering::Equal && *inclusive)
17261        })
17262    });
17263    Some(if above_lower && below_upper {
17264        ("contains", "selected_version_within_bounds")
17265    } else {
17266        ("does_not_contain", "selected_version_outside_bounds")
17267    })
17268}
17269
17270#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17271fn reporting_external_range_endpoint(
17272    kind: &str,
17273    value: &str,
17274    inclusive: bool,
17275    profile: WordPressComparisonProfile,
17276) -> Result<Option<(crate::wordpress_version::ProfiledVersionKey, bool)>, ()> {
17277    match kind {
17278        "any" if value == "*" => Ok(None),
17279        "declared" if value != "*" => {
17280            crate::wordpress_version::ProfiledVersionKey::parse(profile, value)
17281                .map(|version| Some((version, inclusive)))
17282                .map_err(|_| ())
17283        },
17284        _ => Err(()),
17285    }
17286}
17287
17288#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17289fn external_patched_version_conflicts(
17290    patched_versions: &[String],
17291    ranges: &[WordPressExternalRangeDocument],
17292    profile: &str,
17293) -> bool {
17294    let profile = match profile {
17295        "numeric-dotted/v1" => WordPressComparisonProfile::NumericDottedV1,
17296        "php-release-subset/v1" => WordPressComparisonProfile::PhpReleaseSubsetV1,
17297        _ => return false,
17298    };
17299    patched_versions.iter().any(|declared| {
17300        crate::wordpress_version::ProfiledVersionKey::parse(profile, declared)
17301            .ok()
17302            .is_some_and(|version| {
17303                ranges.iter().any(|range| {
17304                    expected_external_range_interpretation(
17305                        range,
17306                        profile.id(),
17307                        Some("supported_equivalent"),
17308                        Some(&version),
17309                    )
17310                    .is_some_and(|(relation, _)| relation == "contains")
17311                })
17312            })
17313    })
17314}
17315
17316#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17317impl WordPressExternalRangeDocument {
17318    fn is_valid(&self, expanded_source_values: bool) -> bool {
17319        external_range_value_is_valid(self.from_kind, &self.from_version, expanded_source_values)
17320            && external_range_value_is_valid(self.to_kind, &self.to_version, expanded_source_values)
17321    }
17322}
17323
17324#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17325fn external_range_labels_are_unique(ranges: &[WordPressExternalRangeDocument]) -> bool {
17326    let mut labels = std::collections::BTreeSet::new();
17327    ranges
17328        .iter()
17329        .all(|range| labels.insert(range.label.as_str()))
17330}
17331
17332#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17333fn external_range_value_is_valid(kind: &str, value: &str, expanded_source_values: bool) -> bool {
17334    match kind {
17335        "any" => value == "*",
17336        "declared" => external_source_version_is_valid(value, expanded_source_values),
17337        _ => false,
17338    }
17339}
17340
17341#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17342fn external_source_versions_are_valid(values: &[String], expanded_source_values: bool) -> bool {
17343    let mut unique = std::collections::BTreeSet::new();
17344    values.iter().all(|value| {
17345        external_source_version_is_valid(value, expanded_source_values)
17346            && unique.insert(value.as_str())
17347    })
17348}
17349
17350#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17351fn external_source_version_is_valid(value: &str, expanded_source_values: bool) -> bool {
17352    if !expanded_source_values {
17353        return valid_inventory_version(value) && value != "*";
17354    }
17355    !value.is_empty()
17356        && value.len() <= crate::wordpress_review::MAX_WORDPRESS_VERSION_BYTES
17357        && value != "*"
17358        && !value.chars().any(char::is_control)
17359        && !value.chars().next().is_some_and(char::is_whitespace)
17360        && !value.chars().next_back().is_some_and(char::is_whitespace)
17361}
17362
17363#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17364fn external_researchers_are_valid(values: &[String], expanded_source_values: bool) -> bool {
17365    if values.len() > crate::wordpress_review::MAX_WORDFENCE_V3_RESEARCHERS {
17366        return false;
17367    }
17368    let mut unique = std::collections::BTreeSet::new();
17369    values.iter().all(|value| {
17370        !value.is_empty()
17371            && value.len() <= crate::wordpress_review::MAX_WORDFENCE_V3_RESEARCHER_BYTES
17372            && !value
17373                .chars()
17374                .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
17375            && (expanded_source_values || unique.insert(value.as_str()))
17376    })
17377}
17378
17379#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17380fn valid_prefixed_lowercase_sha256(value: &str, prefix: &str) -> bool {
17381    value
17382        .strip_prefix(prefix)
17383        .is_some_and(valid_lowercase_sha256)
17384}
17385
17386#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17387fn valid_lowercase_uuid(value: &str) -> bool {
17388    value.len() == 36
17389        && value.bytes().enumerate().all(|(index, byte)| match index {
17390            8 | 13 | 18 | 23 => byte == b'-',
17391            _ => byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte),
17392        })
17393}
17394
17395#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17396impl WordPressInventoryImportDocument {
17397    fn is_valid(&self, components: &[WordPressComponentDocument]) -> bool {
17398        let mut classes = std::collections::BTreeSet::new();
17399        let mut total_bytes = 0_usize;
17400        for input in &self.inputs {
17401            if !matches!(
17402                input.class,
17403                "wp_cli_plugins_json" | "wp_cli_themes_json" | "wp_cli_core_version_file"
17404            ) || !classes.insert(input.class)
17405                || input.byte_length == 0
17406                || (input.class == "wp_cli_core_version_file"
17407                    && input.byte_length > crate::wordpress_review::MAX_WORDPRESS_VERSION_BYTES + 2)
17408                || !valid_lowercase_sha256(&input.sha256)
17409            {
17410                return false;
17411            }
17412            let Some(next) = total_bytes.checked_add(input.byte_length) else {
17413                return false;
17414            };
17415            total_bytes = next;
17416        }
17417        let supplied = |class| classes.contains(class);
17418        if self.inputs.is_empty()
17419            || self.inputs.len() > 3
17420            || total_bytes > MAX_WORDPRESS_SAVED_INVENTORY_BYTES
17421            || (self.coverage.core == "supplied") != supplied("wp_cli_core_version_file")
17422            || (self.coverage.plugins == "supplied") != supplied("wp_cli_plugins_json")
17423            || (self.coverage.themes == "supplied") != supplied("wp_cli_themes_json")
17424            || !matches!(self.coverage.core, "supplied" | "not_supplied")
17425            || !matches!(self.coverage.plugins, "supplied" | "not_supplied")
17426            || !matches!(self.coverage.themes, "supplied" | "not_supplied")
17427        {
17428            return false;
17429        }
17430
17431        let inventory_components = components
17432            .iter()
17433            .filter(|component| component.inventory_status.is_some())
17434            .count();
17435        if self.component_count != inventory_components
17436            || self
17437                .component_count
17438                .checked_add(self.limitations.len())
17439                .is_none_or(|count| {
17440                    count > crate::wordpress_review::MAX_WORDPRESS_CONTEXT_COMPONENTS
17441                })
17442            || components
17443                .iter()
17444                .any(|component| !component.inventory_status_is_valid(&self.coverage))
17445        {
17446            return false;
17447        }
17448
17449        let mut limitations = std::collections::BTreeSet::new();
17450        self.limitations.iter().all(|limitation| {
17451            self.coverage.plugins == "supplied"
17452                && limitation.status == "drop_in"
17453                && limitation.reason == "drop_in_identity_is_not_catalog_slug"
17454                && valid_inventory_label(&limitation.declared_name)
17455                && limitation
17456                    .version
17457                    .as_deref()
17458                    .is_none_or(valid_inventory_version)
17459                && limitations.insert(limitation.declared_name.as_str())
17460        })
17461    }
17462}
17463
17464#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17465impl WordPressComponentDocument {
17466    fn inventory_status_is_valid(&self, coverage: &WordPressInventoryCoverageDocument) -> bool {
17467        let operator_supplied = self.identity_sources.contains(&"operator_context");
17468        let Some(status) = self.inventory_status else {
17469            return !operator_supplied;
17470        };
17471        if !operator_supplied {
17472            return false;
17473        }
17474        match status {
17475            "core_version_supplied" => {
17476                self.identity.kind == "core"
17477                    && self.identity.slug == "wordpress"
17478                    && self.activation.is_none()
17479                    && coverage.core == "supplied"
17480            },
17481            "active" => {
17482                ((self.identity.kind == "plugin" && coverage.plugins == "supplied")
17483                    || (self.identity.kind == "theme" && coverage.themes == "supplied"))
17484                    && self.activation == Some("active")
17485            },
17486            "inactive" => {
17487                ((self.identity.kind == "plugin" && coverage.plugins == "supplied")
17488                    || (self.identity.kind == "theme" && coverage.themes == "supplied"))
17489                    && self.activation == Some("inactive")
17490            },
17491            "active_network" => {
17492                self.identity.kind == "plugin"
17493                    && coverage.plugins == "supplied"
17494                    && self.activation == Some("network_active")
17495            },
17496            "must_use" => {
17497                self.identity.kind == "plugin"
17498                    && coverage.plugins == "supplied"
17499                    && self.activation.is_none()
17500            },
17501            "parent" => {
17502                self.identity.kind == "theme"
17503                    && coverage.themes == "supplied"
17504                    && self.activation.is_none()
17505            },
17506            _ => false,
17507        }
17508    }
17509}
17510
17511#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17512fn valid_lowercase_sha256(value: &str) -> bool {
17513    value.len() == 64
17514        && value
17515            .bytes()
17516            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
17517}
17518
17519#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17520fn valid_inventory_label(value: &str) -> bool {
17521    !value.is_empty()
17522        && value.len() <= crate::wordpress_review::MAX_WORDPRESS_SLUG_BYTES
17523        && !matches!(value, "." | "..")
17524        && value
17525            .bytes()
17526            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
17527}
17528
17529#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17530fn valid_inventory_version(value: &str) -> bool {
17531    !value.is_empty()
17532        && value.len() <= crate::wordpress_review::MAX_WORDPRESS_VERSION_BYTES
17533        && !value
17534            .chars()
17535            .any(|character| character.is_whitespace() || character.is_control())
17536}
17537
17538#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17539impl WordPressAdvisoryDocument {
17540    fn profile_fields_are_valid(&self, profiled: bool) -> bool {
17541        if !profiled {
17542            return self.comparison_profile.is_none()
17543                && self.version_resolution.is_none()
17544                && self.version_resolution_reason.is_none();
17545        }
17546        let Some(profile) = self.comparison_profile else {
17547            return false;
17548        };
17549        if !matches!(profile, "numeric-dotted/v1" | "php-release-subset/v1") {
17550            return false;
17551        }
17552        matches!(
17553            (self.version_resolution, self.version_resolution_reason),
17554            (Some("missing"), Some("no_version_evidence"))
17555                | (
17556                    Some("supported_equivalent"),
17557                    Some("single_supported_version" | "equivalent_supported_versions")
17558                )
17559                | (Some("conflicting"), Some("conflicting_version_evidence"))
17560                | (Some("unsupported"), Some("unsupported_version_evidence"))
17561        )
17562    }
17563}
17564
17565#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17566fn wordpress_component_identity(
17567    identity: &crate::wordpress_review::WordPressComponentIdentity,
17568) -> WordPressComponentIdentityDocument {
17569    WordPressComponentIdentityDocument {
17570        kind: wordpress_component_kind(identity.kind()),
17571        slug: identity.slug().to_owned(),
17572    }
17573}
17574
17575#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17576fn wordpress_prerequisite(
17577    prerequisite: &WordPressPrerequisite,
17578    outcome: WordPressPrerequisiteOutcome,
17579) -> WordPressPrerequisiteDocument {
17580    let (kind, expected, patch_id) = match prerequisite {
17581        WordPressPrerequisite::HostingOs { equals } => {
17582            ("hosting_os", wordpress_hosting_os(*equals).to_owned(), None)
17583        },
17584        WordPressPrerequisite::Multisite { equals } => {
17585            ("multisite", wordpress_multisite(*equals).to_owned(), None)
17586        },
17587        WordPressPrerequisite::Activation { equals } => {
17588            ("activation", wordpress_activation(*equals).to_owned(), None)
17589        },
17590        WordPressPrerequisite::Patch { id, equals } => (
17591            "patch",
17592            wordpress_patch(*equals).to_owned(),
17593            Some(id.clone()),
17594        ),
17595        WordPressPrerequisite::Unsupported { name } => ("unsupported", name.clone(), None),
17596    };
17597    WordPressPrerequisiteDocument {
17598        kind,
17599        expected,
17600        patch_id,
17601        outcome: wordpress_prerequisite_outcome(outcome),
17602    }
17603}
17604
17605#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17606const fn wordpress_component_kind(kind: WordPressComponentKind) -> &'static str {
17607    match kind {
17608        WordPressComponentKind::Core => "core",
17609        WordPressComponentKind::Plugin => "plugin",
17610        WordPressComponentKind::Theme => "theme",
17611    }
17612}
17613
17614#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17615const fn wordpress_catalog_status(status: WordPressCatalogStatus) -> &'static str {
17616    match status {
17617        WordPressCatalogStatus::CatalogNotSupplied => "catalogue_not_supplied",
17618        WordPressCatalogStatus::Evaluated => "evaluated",
17619    }
17620}
17621
17622#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17623const fn wordpress_evidence_source(source: WordPressEvidenceSource) -> &'static str {
17624    match source {
17625        WordPressEvidenceSource::GeneratorMetadata => "generator_metadata",
17626        WordPressEvidenceSource::SameOriginAssetPath => "same_origin_asset_path",
17627        WordPressEvidenceSource::ThemeStylesheetDeclaration => "theme_stylesheet_declaration",
17628        WordPressEvidenceSource::OperatorContext => "operator_context",
17629    }
17630}
17631
17632#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17633const fn wordpress_confidence(confidence: WordPressEvidenceConfidence) -> &'static str {
17634    match confidence {
17635        WordPressEvidenceConfidence::PublicDeclaration => "public_declaration",
17636        WordPressEvidenceConfidence::StructuralHint => "structural_hint",
17637        WordPressEvidenceConfidence::OperatorAssertion => "operator_assertion",
17638    }
17639}
17640
17641#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17642const fn wordpress_evidence_class(class: WordPressComponentEvidenceClass) -> &'static str {
17643    match class {
17644        WordPressComponentEvidenceClass::ObservedHint => "observed_hint",
17645        WordPressComponentEvidenceClass::OperatorSupplied => "operator_supplied",
17646        WordPressComponentEvidenceClass::Conflicting => "conflicting",
17647        WordPressComponentEvidenceClass::Unknown => "unknown",
17648    }
17649}
17650
17651#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17652const fn wordpress_activation(state: WordPressActivationState) -> &'static str {
17653    match state {
17654        WordPressActivationState::Active => "active",
17655        WordPressActivationState::Inactive => "inactive",
17656        WordPressActivationState::NetworkActive => "network_active",
17657        WordPressActivationState::Unknown => "unknown",
17658    }
17659}
17660
17661#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17662const fn wordpress_inventory_category_status(
17663    status: WordPressInventoryCategoryStatus,
17664) -> &'static str {
17665    match status {
17666        WordPressInventoryCategoryStatus::NotSupplied => "not_supplied",
17667        WordPressInventoryCategoryStatus::Supplied => "supplied",
17668    }
17669}
17670
17671#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17672const fn wordpress_inventory_entry_status(status: WordPressInventoryEntryStatus) -> &'static str {
17673    match status {
17674        WordPressInventoryEntryStatus::CoreVersionSupplied => "core_version_supplied",
17675        WordPressInventoryEntryStatus::Active => "active",
17676        WordPressInventoryEntryStatus::Inactive => "inactive",
17677        WordPressInventoryEntryStatus::NetworkActive => "active_network",
17678        WordPressInventoryEntryStatus::MustUse => "must_use",
17679        WordPressInventoryEntryStatus::DropIn => "drop_in",
17680        WordPressInventoryEntryStatus::Parent => "parent",
17681    }
17682}
17683
17684#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17685const fn wordpress_inventory_limitation_reason(
17686    reason: WordPressInventoryLimitationReason,
17687) -> &'static str {
17688    match reason {
17689        WordPressInventoryLimitationReason::DropInIdentityIsNotCatalogSlug => {
17690            "drop_in_identity_is_not_catalog_slug"
17691        },
17692    }
17693}
17694
17695#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17696const fn wordpress_local_input_class(class: WordPressLocalInputClass) -> &'static str {
17697    match class {
17698        WordPressLocalInputClass::PluginsJson => "wp_cli_plugins_json",
17699        WordPressLocalInputClass::ThemesJson => "wp_cli_themes_json",
17700        WordPressLocalInputClass::CoreVersionFile => "wp_cli_core_version_file",
17701    }
17702}
17703
17704#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17705fn lowercase_hex(bytes: &[u8]) -> String {
17706    const DIGITS: &[u8; 16] = b"0123456789abcdef";
17707    let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
17708    for byte in bytes {
17709        encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
17710        encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
17711    }
17712    encoded
17713}
17714
17715#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17716fn wordpress_external_review_document(
17717    review: &crate::wordpress_review::WordPressExternalReview,
17718) -> WordPressExternalReviewDocument {
17719    let counts = review.counts();
17720    let comparison_policy = wordpress_external_comparison_policy(review.comparison_policy());
17721    let explicit = review.comparison_profile().is_some();
17722    let source_identity = review.requires_audit_v6();
17723    WordPressExternalReviewDocument {
17724        source_namespace: review.source_namespace(),
17725        source_format: review.source_format(),
17726        mapping_revision: review.mapping_revision(),
17727        identity_mapping_policy: source_identity.then_some(review.identity_mapping_policy()),
17728        identity_source_assurance: source_identity.then_some(review.identity_source_assurance()),
17729        resource_policy: source_identity.then_some(review.resource_policy()),
17730        comparison_policy,
17731        comparison_profile: review
17732            .comparison_profile()
17733            .map(wordpress_comparison_profile),
17734        policy_selection: explicit.then_some("explicit_operator"),
17735        source_semantics_assurance: explicit.then_some("not_established"),
17736        input: WordPressExternalInputDocument {
17737            byte_length: review.byte_length(),
17738            sha256: lowercase_hex(review.sha256()),
17739            semantic_sha256: lowercase_hex(review.semantic_sha256()),
17740            accounted_retained_bytes: source_identity.then_some(review.retained_bytes()),
17741        },
17742        counts: WordPressExternalCountsDocument {
17743            parsed_records: counts.parsed_records(),
17744            software_associations: counts.software_associations(),
17745            exact_identity_associations: source_identity
17746                .then_some(counts.exact_identity_associations()),
17747            candidate_identity_associations: source_identity
17748                .then_some(counts.candidate_identity_associations()),
17749            ambiguous_identity_associations: source_identity
17750                .then_some(counts.ambiguous_identity_associations()),
17751            unresolved_identity_associations: source_identity
17752                .then_some(counts.unresolved_identity_associations()),
17753            projected_identity_limitations: source_identity
17754                .then_some(counts.projected_identity_limitations()),
17755            unprojected_identity_limitations: source_identity
17756                .then_some(counts.unprojected_identity_limitations()),
17757            selected_associations: counts.selected_associations(),
17758            evaluable_associations: counts.evaluable_associations(),
17759            unsupported_associations: counts.unsupported_associations(),
17760            excluded_associations: counts.excluded_associations(),
17761            mapped_unselected_associations: source_identity
17762                .then_some(counts.mapped_unselected_associations()),
17763            within_associations: explicit.then_some(counts.within_associations()),
17764            outside_associations: explicit.then_some(counts.outside_associations()),
17765            indeterminate_associations: explicit.then_some(counts.indeterminate_associations()),
17766            selected_ranges: explicit.then_some(counts.selected_ranges()),
17767            evaluated_ranges: explicit.then_some(counts.evaluated_ranges()),
17768            containing_ranges: explicit.then_some(counts.containing_ranges()),
17769            noncontaining_ranges: explicit.then_some(counts.noncontaining_ranges()),
17770            unsupported_ranges: explicit.then_some(counts.unsupported_ranges()),
17771            invalid_ranges: explicit.then_some(counts.invalid_ranges()),
17772            not_evaluated_ranges: explicit.then_some(counts.not_evaluated_ranges()),
17773            partial_range_coverage_associations: explicit
17774                .then_some(counts.partial_range_coverage_associations()),
17775        },
17776        notices: review
17777            .notices()
17778            .iter()
17779            .map(|notice| WordPressExternalNoticeDocument {
17780                id: notice.id().to_owned(),
17781                message: notice.message().to_owned(),
17782                party: notice.party().to_owned(),
17783                notice: notice.notice().to_owned(),
17784                license: notice.license().to_owned(),
17785                license_url: notice.license_url().to_owned(),
17786            })
17787            .collect(),
17788        evaluations: review
17789            .evaluations()
17790            .iter()
17791            .map(|evaluation| wordpress_external_evaluation_document(evaluation, source_identity))
17792            .collect(),
17793        identity_limitations: source_identity.then(|| {
17794            review
17795                .identity_limitations()
17796                .iter()
17797                .map(wordpress_external_identity_limitation_document)
17798                .collect()
17799        }),
17800    }
17801}
17802
17803#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17804const fn wordpress_external_comparison_policy(
17805    policy: WordPressExternalComparisonPolicy,
17806) -> &'static str {
17807    match policy {
17808        WordPressExternalComparisonPolicy::WordfenceV3SourceSemanticsUnresolvedV1 => {
17809            "wordfence-v3/source-semantics-unresolved/v1"
17810        },
17811        WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1 => {
17812            "termivar.wordfence-v3-explicit-interpretation/v1"
17813        },
17814    }
17815}
17816
17817#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17818fn external_identity_mapping_is_valid(
17819    evaluation: &WordPressExternalEvaluationDocument,
17820    source_identity: bool,
17821) -> Option<WordfenceV3IdentityMapping> {
17822    if !source_identity {
17823        return (evaluation.identity_mapping.is_none()
17824            && evaluation.identity_collision_raw_count.is_none()
17825            && evaluation.key.source_component.is_none())
17826        .then_some(WordfenceV3IdentityMapping::Exact);
17827    }
17828
17829    let source = evaluation.key.source_component.as_ref()?;
17830    if !valid_wordfence_source_identity(source) {
17831        return None;
17832    }
17833    let mapping = match evaluation.identity_mapping? {
17834        "exact" => WordfenceV3IdentityMapping::Exact,
17835        "ascii_case_fold_candidate" => WordfenceV3IdentityMapping::AsciiCaseFoldCandidate,
17836        "ascii_case_fold_ambiguous" => WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous,
17837        _ => return None,
17838    };
17839    let canonical = &evaluation.key.component;
17840    let same_kind = source.kind == canonical.kind;
17841    let exact = same_kind && source.slug == canonical.slug;
17842    let case_fold = same_kind
17843        && source.slug != canonical.slug
17844        && source.slug.eq_ignore_ascii_case(&canonical.slug);
17845    match mapping {
17846        WordfenceV3IdentityMapping::Exact => {
17847            (exact && evaluation.identity_collision_raw_count.is_none()).then_some(mapping)
17848        },
17849        WordfenceV3IdentityMapping::AsciiCaseFoldCandidate => {
17850            (case_fold && evaluation.identity_collision_raw_count.is_none()).then_some(mapping)
17851        },
17852        WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous => evaluation
17853            .identity_collision_raw_count
17854            .is_some_and(|count| count >= 2)
17855            .then_some(mapping)
17856            .filter(|_| case_fold),
17857    }
17858}
17859
17860#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17861fn valid_wordfence_source_identity(
17862    identity: &WordPressExternalSourceComponentIdentityDocument,
17863) -> bool {
17864    const MAX_SOURCE_SLUG_BYTES: usize = 128;
17865    matches!(identity.kind, "core" | "plugin" | "theme")
17866        && !identity.slug.is_empty()
17867        && identity.slug.len() <= MAX_SOURCE_SLUG_BYTES
17868        && !identity.slug.starts_with('-')
17869        && !identity.slug.ends_with('-')
17870        && !identity.slug.contains("--")
17871        && identity
17872            .slug
17873            .bytes()
17874            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
17875}
17876
17877#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17878fn valid_wordfence_raw_source_identity(
17879    identity: &WordPressExternalSourceComponentIdentityDocument,
17880) -> bool {
17881    const MAX_SOURCE_SLUG_BYTES: usize = 128;
17882    matches!(identity.kind, "core" | "plugin" | "theme")
17883        && !identity.slug.is_empty()
17884        && identity.slug.len() <= MAX_SOURCE_SLUG_BYTES
17885        && identity.slug.is_ascii()
17886        && !identity.slug.bytes().any(|byte| byte.is_ascii_control())
17887        && identity
17888            .slug
17889            .bytes()
17890            .any(|byte| byte.is_ascii_alphanumeric())
17891}
17892
17893#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17894fn wordfence_raw_source_identity_can_map(
17895    identity: &WordPressExternalSourceComponentIdentityDocument,
17896) -> bool {
17897    valid_wordfence_source_identity(identity)
17898        && identity.slug.len() <= crate::wordpress_review::MAX_WORDPRESS_SLUG_BYTES
17899        && match identity.kind {
17900            "core" => identity.slug.eq_ignore_ascii_case("wordpress"),
17901            "plugin" | "theme" => !identity.slug.eq_ignore_ascii_case("wordpress"),
17902            _ => false,
17903        }
17904}
17905
17906#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
17907fn wordpress_external_evaluation_document(
17908    evaluation: &crate::wordpress_review::WordPressExternalAdvisoryEvaluation,
17909    source_identity: bool,
17910) -> WordPressExternalEvaluationDocument {
17911    let key = evaluation.key();
17912    WordPressExternalEvaluationDocument {
17913        key: WordPressExternalEvaluationKeyDocument {
17914            source_namespace: key.source_namespace().to_owned(),
17915            upstream_id: key.upstream_id().to_owned(),
17916            component: wordpress_component_identity(key.component()),
17917            source_component: source_identity.then(|| {
17918                WordPressExternalSourceComponentIdentityDocument {
17919                    kind: wordpress_component_kind(key.source_component().kind()),
17920                    slug: key.source_component().slug().to_owned(),
17921                }
17922            }),
17923            source_association_fingerprint: source_identity.then(|| {
17924                format!(
17925                    "wordfence-source-association-sha256:{}",
17926                    lowercase_hex(key.source_association_sha256())
17927                )
17928            }),
17929        },
17930        identity_mapping: source_identity.then_some(evaluation.identity_mapping().as_str()),
17931        identity_collision_raw_count: if source_identity {
17932            evaluation.identity_collision_raw_count()
17933        } else {
17934            None
17935        },
17936        title: evaluation.title().to_owned(),
17937        display_name: evaluation.display_name().to_owned(),
17938        informational: evaluation.informational(),
17939        description: evaluation.description().to_owned(),
17940        references: evaluation.references().to_vec(),
17941        record_reference: wordpress_external_record_reference(evaluation.references())
17942            .map(str::to_owned),
17943        cwe: evaluation.cwe().map(|cwe| WordPressExternalCweDocument {
17944            id: cwe.id(),
17945            name: cwe.name().to_owned(),
17946            description: cwe.description().to_owned(),
17947        }),
17948        cvss: evaluation.cvss().map(|cvss| WordPressExternalCvssDocument {
17949            vector: cvss.vector().to_owned(),
17950            score: cvss.score().to_owned(),
17951            rating: wordpress_external_cvss_rating(cvss.rating()),
17952        }),
17953        cve: evaluation.cve().map(str::to_owned),
17954        cve_link: evaluation.cve_link().map(str::to_owned),
17955        researchers: evaluation.researchers().to_vec(),
17956        source_dates: WordPressExternalSourceDatesDocument {
17957            published: evaluation.published().map(str::to_owned),
17958            updated: evaluation.updated().map(str::to_owned),
17959        },
17960        affected_ranges: evaluation
17961            .affected_ranges()
17962            .iter()
17963            .map(|range| {
17964                let (from_kind, from_version) =
17965                    wordpress_external_range_value(range.from().value());
17966                let (to_kind, to_version) = wordpress_external_range_value(range.to().value());
17967                WordPressExternalRangeDocument {
17968                    label: range.label().to_owned(),
17969                    from_kind,
17970                    from_version,
17971                    from_inclusive: range.from().inclusive(),
17972                    to_kind,
17973                    to_version,
17974                    to_inclusive: range.to().inclusive(),
17975                }
17976            })
17977            .collect(),
17978        range_evaluations: evaluation.range_evaluations().map(|ranges| {
17979            ranges
17980                .iter()
17981                .map(|range| WordPressExternalRangeEvaluationDocument {
17982                    relation: wordpress_external_range_relation(range.relation()),
17983                    reason: wordpress_external_range_reason(range.reason()),
17984                })
17985                .collect()
17986        }),
17987        source_patched: evaluation.source_patched(),
17988        source_patched_versions: evaluation.source_patched_versions().to_vec(),
17989        source_remediation: evaluation.source_remediation().to_owned(),
17990        notice_ids: evaluation.notice_ids().to_vec(),
17991        component_evidence: wordpress_evidence_class(evaluation.component_evidence()),
17992        version_evidence_resolution: wordpress_external_version_evidence_resolution(
17993            evaluation.version_evidence_resolution(),
17994        ),
17995        version_relation: wordpress_external_version_relation(evaluation.version_relation()),
17996        version_relation_reason: evaluation.range_evaluations().map(|_| {
17997            wordpress_external_version_relation_reason(evaluation.version_relation_reason())
17998        }),
17999        applicability: wordpress_external_applicability(evaluation.applicability()),
18000        execution: WordPressExternalExecutionDocument {
18001            exploit_execution: wordpress_execution(evaluation.exploit_execution()),
18002            impact_validation: wordpress_execution(evaluation.impact_validation()),
18003        },
18004    }
18005}
18006
18007#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18008fn wordpress_external_identity_limitation_document(
18009    limitation: &crate::wordpress_review::WordPressExternalIdentityLimitation,
18010) -> WordPressExternalIdentityLimitationDocument {
18011    let affected_ranges = limitation
18012        .affected_ranges()
18013        .iter()
18014        .map(wordpress_external_range_document)
18015        .collect::<Vec<_>>();
18016    WordPressExternalIdentityLimitationDocument {
18017        key: WordPressExternalIdentityLimitationKeyDocument {
18018            source_namespace: "wordfence-intelligence",
18019            upstream_id: limitation.upstream_id().to_owned(),
18020            source_component: WordPressExternalSourceComponentIdentityDocument {
18021                kind: wordpress_component_kind(limitation.source_component().kind()),
18022                slug: limitation.source_component().slug().to_owned(),
18023            },
18024            source_association_fingerprint: format!(
18025                "wordfence-source-association-sha256:{}",
18026                lowercase_hex(limitation.source_association_sha256())
18027            ),
18028        },
18029        identity_resolution: "canonical_identity_unavailable",
18030        title: limitation.title().to_owned(),
18031        display_name: limitation.display_name().to_owned(),
18032        references: limitation.references().to_vec(),
18033        record_reference: wordpress_external_record_reference(limitation.references())
18034            .map(str::to_owned),
18035        range_evaluations: affected_ranges
18036            .iter()
18037            .map(|_| WordPressExternalRangeEvaluationDocument {
18038                relation: "not_evaluated",
18039                reason: "canonical_identity_unavailable",
18040            })
18041            .collect(),
18042        affected_ranges,
18043        source_patched: limitation.source_patched(),
18044        source_patched_versions: limitation.source_patched_versions().to_vec(),
18045        source_remediation: limitation.source_remediation().to_owned(),
18046        notice_ids: limitation.notice_ids().to_vec(),
18047        version_relation: "not_evaluated",
18048        version_relation_reason: "canonical_identity_unavailable",
18049        applicability: "indeterminate",
18050        execution: WordPressExternalExecutionDocument {
18051            exploit_execution: "not_performed",
18052            impact_validation: "not_performed",
18053        },
18054    }
18055}
18056
18057#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18058fn wordpress_external_range_document(
18059    range: &crate::wordpress_review::WordfenceV3AffectedRange,
18060) -> WordPressExternalRangeDocument {
18061    let (from_kind, from_version) = wordpress_external_range_value(range.from().value());
18062    let (to_kind, to_version) = wordpress_external_range_value(range.to().value());
18063    WordPressExternalRangeDocument {
18064        label: range.label().to_owned(),
18065        from_kind,
18066        from_version,
18067        from_inclusive: range.from().inclusive(),
18068        to_kind,
18069        to_version,
18070        to_inclusive: range.to().inclusive(),
18071    }
18072}
18073
18074#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18075fn wordpress_external_record_reference(references: &[String]) -> Option<&str> {
18076    references
18077        .iter()
18078        .map(String::as_str)
18079        .filter(|reference| is_wordfence_vulnerability_reference(reference))
18080        .min()
18081        .or_else(|| {
18082            references
18083                .iter()
18084                .map(String::as_str)
18085                .filter(|reference| is_strict_https_reference(reference))
18086                .min()
18087        })
18088}
18089
18090#[cfg(all(
18091    feature = "scanning",
18092    any(feature = "wordpress-review", feature = "control-reference-mapping")
18093))]
18094fn is_strict_https_reference(value: &str) -> bool {
18095    url::Url::parse(value).ok().is_some_and(|url| {
18096        url.scheme() == "https"
18097            && url.has_host()
18098            && url.username().is_empty()
18099            && url.password().is_none()
18100            && url.port().is_none()
18101            && url.as_str() == value
18102    })
18103}
18104
18105#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18106fn is_wordfence_vulnerability_reference(value: &str) -> bool {
18107    const PREFIX: &str = "/threat-intel/vulnerabilities/";
18108    url::Url::parse(value).ok().is_some_and(|url| {
18109        matches!(url.scheme(), "http" | "https")
18110            && url.host_str() == Some("www.wordfence.com")
18111            && url.username().is_empty()
18112            && url.password().is_none()
18113            && url.port().is_none()
18114            && url.as_str() == value
18115            && url.path().starts_with(PREFIX)
18116            && url.path().len() > PREFIX.len()
18117            && has_supported_wordfence_reference_query(&url)
18118            && url.fragment().is_none()
18119    })
18120}
18121
18122#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18123fn has_supported_wordfence_reference_query(url: &url::Url) -> bool {
18124    const MAX_SOURCE_VALUE_BYTES: usize = 64;
18125    // Keep this in lockstep with the strict Production import contract.
18126    match url.query() {
18127        None => true,
18128        Some(query) => query.strip_prefix("source=").is_some_and(|value| {
18129            !value.is_empty()
18130                && value.len() <= MAX_SOURCE_VALUE_BYTES
18131                && value.bytes().all(|byte| {
18132                    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
18133                })
18134        }),
18135    }
18136}
18137
18138#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18139fn wordpress_external_version_evidence_resolution(
18140    resolution: &crate::wordpress_review::WordPressExternalVersionEvidenceResolution,
18141) -> WordPressExternalVersionEvidenceResolutionDocument {
18142    WordPressExternalVersionEvidenceResolutionDocument {
18143        status: match resolution.status() {
18144            WordPressExternalVersionEvidenceStatus::Missing => "missing",
18145            WordPressExternalVersionEvidenceStatus::SingleDeclaration => "single_declaration",
18146            WordPressExternalVersionEvidenceStatus::RepeatedExactDeclaration => {
18147                "repeated_exact_declaration"
18148            },
18149            WordPressExternalVersionEvidenceStatus::MultipleDistinctDeclarations => {
18150                "multiple_distinct_declarations"
18151            },
18152        },
18153        evidence_row_count: resolution.evidence_row_count(),
18154        distinct_spelling_count: resolution.distinct_spelling_count(),
18155        semantic_status: resolution
18156            .semantic_status()
18157            .map(wordpress_version_resolution),
18158        semantic_reason: resolution
18159            .semantic_reason()
18160            .map(wordpress_version_resolution_reason),
18161    }
18162}
18163
18164#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18165const fn wordpress_external_version_evidence_status(
18166    evidence_row_count: usize,
18167    distinct_spelling_count: usize,
18168) -> Option<&'static str> {
18169    match (evidence_row_count, distinct_spelling_count) {
18170        (0, 0) => Some("missing"),
18171        (1, 1) => Some("single_declaration"),
18172        (count, 1) if count > 1 => Some("repeated_exact_declaration"),
18173        (count, distinct) if count > 1 && distinct > 1 && distinct <= count => {
18174            Some("multiple_distinct_declarations")
18175        },
18176        _ => None,
18177    }
18178}
18179
18180#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18181fn wordpress_external_range_value(value: &WordfenceV3RangeValue) -> (&'static str, String) {
18182    match value {
18183        WordfenceV3RangeValue::Any => ("any", "*".to_owned()),
18184        WordfenceV3RangeValue::Declared(value) => ("declared", value.clone()),
18185    }
18186}
18187
18188#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18189const fn wordpress_external_cvss_rating(rating: WordfenceV3CvssRating) -> &'static str {
18190    match rating {
18191        WordfenceV3CvssRating::None => "none",
18192        WordfenceV3CvssRating::Low => "low",
18193        WordfenceV3CvssRating::Medium => "medium",
18194        WordfenceV3CvssRating::High => "high",
18195        WordfenceV3CvssRating::Critical => "critical",
18196    }
18197}
18198
18199#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18200const fn wordpress_external_version_relation(
18201    relation: WordPressExternalVersionRelation,
18202) -> &'static str {
18203    match relation {
18204        WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved => {
18205            "source_comparison_semantics_unresolved"
18206        },
18207        WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy => {
18208            "within_supported_range_under_selected_policy"
18209        },
18210        WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy => {
18211            "outside_declared_ranges_under_selected_policy"
18212        },
18213        WordPressExternalVersionRelation::Indeterminate => "indeterminate",
18214    }
18215}
18216
18217#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18218const fn wordpress_external_version_relation_reason(
18219    reason: WordPressExternalVersionRelationReason,
18220) -> &'static str {
18221    match reason {
18222        WordPressExternalVersionRelationReason::SourceComparisonSemanticsUnresolved => {
18223            "source_comparison_semantics_unresolved"
18224        },
18225        WordPressExternalVersionRelationReason::ContainingRange => "containing_range",
18226        WordPressExternalVersionRelationReason::ContainingRangeWithPartialCoverage => {
18227            "containing_range_with_partial_coverage"
18228        },
18229        WordPressExternalVersionRelationReason::AllRangesOutside => "all_ranges_outside",
18230        WordPressExternalVersionRelationReason::MissingVersionEvidence => {
18231            "missing_version_evidence"
18232        },
18233        WordPressExternalVersionRelationReason::ConflictingVersionEvidence => {
18234            "conflicting_version_evidence"
18235        },
18236        WordPressExternalVersionRelationReason::UnsupportedVersionEvidence => {
18237            "unsupported_version_evidence"
18238        },
18239        WordPressExternalVersionRelationReason::MissingAffectedRanges => "missing_affected_ranges",
18240        WordPressExternalVersionRelationReason::UnsupportedAffectedRange => {
18241            "unsupported_affected_range"
18242        },
18243        WordPressExternalVersionRelationReason::InvalidAffectedRange => "invalid_affected_range",
18244        WordPressExternalVersionRelationReason::SourcePatchedVersionWithinAffectedRange => {
18245            "source_patched_version_within_affected_range"
18246        },
18247        WordPressExternalVersionRelationReason::IdentityMappingCandidate => {
18248            "identity_mapping_candidate"
18249        },
18250        WordPressExternalVersionRelationReason::IdentityMappingAmbiguous => {
18251            "identity_mapping_ambiguous"
18252        },
18253    }
18254}
18255
18256#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18257const fn wordpress_external_range_relation(
18258    relation: WordPressExternalRangeRelation,
18259) -> &'static str {
18260    match relation {
18261        WordPressExternalRangeRelation::Contains => "contains",
18262        WordPressExternalRangeRelation::DoesNotContain => "does_not_contain",
18263        WordPressExternalRangeRelation::NotEvaluated => "not_evaluated",
18264        WordPressExternalRangeRelation::Unsupported => "unsupported",
18265        WordPressExternalRangeRelation::InvalidUnderProfile => "invalid_under_profile",
18266    }
18267}
18268
18269#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18270const fn wordpress_external_range_reason(reason: WordPressExternalRangeReason) -> &'static str {
18271    match reason {
18272        WordPressExternalRangeReason::SelectedVersionWithinBounds => {
18273            "selected_version_within_bounds"
18274        },
18275        WordPressExternalRangeReason::SelectedVersionOutsideBounds => {
18276            "selected_version_outside_bounds"
18277        },
18278        WordPressExternalRangeReason::MissingVersionEvidence => "missing_version_evidence",
18279        WordPressExternalRangeReason::ConflictingVersionEvidence => "conflicting_version_evidence",
18280        WordPressExternalRangeReason::UnsupportedVersionEvidence => "unsupported_version_evidence",
18281        WordPressExternalRangeReason::UnsupportedLowerBound => "unsupported_lower_bound",
18282        WordPressExternalRangeReason::UnsupportedUpperBound => "unsupported_upper_bound",
18283        WordPressExternalRangeReason::UnsupportedBothBounds => "unsupported_both_bounds",
18284        WordPressExternalRangeReason::ReversedBounds => "reversed_bounds",
18285        WordPressExternalRangeReason::EmptyExclusiveInterval => "empty_exclusive_interval",
18286        WordPressExternalRangeReason::IdentityMappingCandidate => "identity_mapping_candidate",
18287        WordPressExternalRangeReason::IdentityMappingAmbiguous => "identity_mapping_ambiguous",
18288    }
18289}
18290
18291#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18292const fn wordpress_external_applicability(
18293    applicability: WordPressExternalApplicability,
18294) -> &'static str {
18295    match applicability {
18296        WordPressExternalApplicability::IndeterminateUnsupported => "indeterminate_unsupported",
18297        WordPressExternalApplicability::VersionMatchUnderSelectedPolicy => {
18298            "version_match_under_selected_policy"
18299        },
18300        WordPressExternalApplicability::NoVersionMatchUnderSelectedPolicy => {
18301            "no_version_match_under_selected_policy"
18302        },
18303        WordPressExternalApplicability::Indeterminate => "indeterminate",
18304    }
18305}
18306
18307#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18308const fn wordpress_hosting_os(os: WordPressHostingOs) -> &'static str {
18309    match os {
18310        WordPressHostingOs::Linux => "linux",
18311        WordPressHostingOs::Windows => "windows",
18312        WordPressHostingOs::Macos => "macos",
18313        WordPressHostingOs::Bsd => "bsd",
18314        WordPressHostingOs::Other => "other",
18315    }
18316}
18317
18318#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18319const fn wordpress_multisite(state: WordPressMultisiteState) -> &'static str {
18320    match state {
18321        WordPressMultisiteState::Enabled => "enabled",
18322        WordPressMultisiteState::Disabled => "disabled",
18323    }
18324}
18325
18326#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18327const fn wordpress_patch(state: WordPressPatchState) -> &'static str {
18328    match state {
18329        WordPressPatchState::Applied => "applied",
18330        WordPressPatchState::NotApplied => "not_applied",
18331        WordPressPatchState::Unknown => "unknown",
18332    }
18333}
18334
18335#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18336const fn wordpress_version_relation(relation: WordPressVersionRelation) -> &'static str {
18337    match relation {
18338        WordPressVersionRelation::WithinDeclaredRange => "within_declared_range",
18339        WordPressVersionRelation::OutsideDeclaredRanges => "outside_declared_ranges",
18340        WordPressVersionRelation::Unknown => "unknown",
18341        WordPressVersionRelation::Unsupported => "unsupported",
18342    }
18343}
18344
18345#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18346const fn wordpress_comparison_profile(profile: WordPressComparisonProfile) -> &'static str {
18347    match profile {
18348        WordPressComparisonProfile::NumericDottedV1 => "numeric-dotted/v1",
18349        WordPressComparisonProfile::PhpReleaseSubsetV1 => "php-release-subset/v1",
18350    }
18351}
18352
18353#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18354const fn wordpress_version_resolution(resolution: WordPressVersionResolution) -> &'static str {
18355    match resolution {
18356        WordPressVersionResolution::Missing => "missing",
18357        WordPressVersionResolution::SupportedEquivalent => "supported_equivalent",
18358        WordPressVersionResolution::Conflicting => "conflicting",
18359        WordPressVersionResolution::Unsupported => "unsupported",
18360    }
18361}
18362
18363#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18364const fn wordpress_version_resolution_reason(
18365    reason: WordPressVersionResolutionReason,
18366) -> &'static str {
18367    match reason {
18368        WordPressVersionResolutionReason::NoVersionEvidence => "no_version_evidence",
18369        WordPressVersionResolutionReason::SingleSupportedVersion => "single_supported_version",
18370        WordPressVersionResolutionReason::EquivalentSupportedVersions => {
18371            "equivalent_supported_versions"
18372        },
18373        WordPressVersionResolutionReason::ConflictingVersionEvidence => {
18374            "conflicting_version_evidence"
18375        },
18376        WordPressVersionResolutionReason::UnsupportedVersionEvidence => {
18377            "unsupported_version_evidence"
18378        },
18379    }
18380}
18381
18382#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18383const fn wordpress_prerequisite_outcome(outcome: WordPressPrerequisiteOutcome) -> &'static str {
18384    match outcome {
18385        WordPressPrerequisiteOutcome::MatchedOnSuppliedFacts => "matched_on_supplied_facts",
18386        WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts => {
18387            "contradicted_on_supplied_facts"
18388        },
18389        WordPressPrerequisiteOutcome::Unknown => "unknown",
18390        WordPressPrerequisiteOutcome::Unsupported => "unsupported",
18391    }
18392}
18393
18394#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18395const fn wordpress_applicability(applicability: WordPressApplicability) -> &'static str {
18396    match applicability {
18397        WordPressApplicability::CandidateMatchOnDeclaredFacts => {
18398            "candidate_match_on_declared_facts"
18399        },
18400        WordPressApplicability::ContradictedByDeclaredFacts => "contradicted_by_declared_facts",
18401        WordPressApplicability::IndeterminateMissingEvidence => "indeterminate_missing_evidence",
18402        WordPressApplicability::IndeterminateUnsupported => "indeterminate_unsupported",
18403    }
18404}
18405
18406#[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18407const fn wordpress_execution(status: WordPressExecutionStatus) -> &'static str {
18408    match status {
18409        WordPressExecutionStatus::NotPerformed => "not_performed",
18410    }
18411}
18412
18413#[cfg(feature = "scanning")]
18414#[derive(Serialize)]
18415struct AssessmentItemDocument<'a> {
18416    schema: &'a str,
18417    capability_id: &'a str,
18418    subject_reference: String,
18419    #[serde(skip_serializing)]
18420    presentation_target: &'static str,
18421    title: &'a str,
18422    disposition: &'static str,
18423    claim_basis: &'static str,
18424    severity: Option<&'static str>,
18425    confidence_ppm: u32,
18426    fingerprint: &'a str,
18427    evidence_count: u64,
18428    redacted_summary: &'a str,
18429    category: &'a str,
18430    cwe: Option<&'a str>,
18431    remediation: AssessmentRemediationDocument<'a>,
18432    evidence_references: Vec<String>,
18433    control_evidence_references: Vec<String>,
18434    candidate_evidence_references: Vec<String>,
18435    case_reference: Option<String>,
18436    outcome_reference: Option<String>,
18437    verification_stage: Option<&'static str>,
18438}
18439
18440#[cfg(feature = "scanning")]
18441impl<'a> AssessmentItemDocument<'a> {
18442    fn from_item(item: &'a crate::web_runtime::AssessmentItem) -> Result<Self, ReportError> {
18443        let remediation = item.remediation();
18444        let linkage = AssessmentBasisLinkageDocument::from_basis(item.basis())?;
18445        let evidence_count =
18446            u64::try_from(item.evidence_count()).map_err(|_| ReportError::Serialization)?;
18447        if linkage.reference_count()? != evidence_count {
18448            return Err(ReportError::Serialization);
18449        }
18450        Ok(Self {
18451            schema: item.schema(),
18452            capability_id: item.capability_id(),
18453            subject_reference: item.subject_reference().to_string(),
18454            presentation_target: item.presentation_target_kind(),
18455            title: item.title(),
18456            disposition: item.disposition().as_str(),
18457            claim_basis: assessment_basis_token(item.basis()),
18458            severity: item.severity().map(severity_token),
18459            confidence_ppm: item.confidence().parts_per_million(),
18460            fingerprint: item.fingerprint(),
18461            evidence_count,
18462            redacted_summary: item.redacted_summary(),
18463            category: item.category(),
18464            cwe: item.cwe(),
18465            remediation: AssessmentRemediationDocument {
18466                id: remediation.id(),
18467                summary: remediation.summary(),
18468            },
18469            evidence_references: linkage.evidence_references,
18470            control_evidence_references: linkage.control_evidence_references,
18471            candidate_evidence_references: linkage.candidate_evidence_references,
18472            case_reference: linkage.case_reference,
18473            outcome_reference: linkage.outcome_reference,
18474            verification_stage: linkage.verification_stage,
18475        })
18476    }
18477
18478    fn validate(&self) -> Result<(), ReportError> {
18479        if !valid_opaque_assessment_reference(&self.subject_reference, "subject") {
18480            return Err(ReportError::Serialization);
18481        }
18482        if !matches!(
18483            self.presentation_target,
18484            "assessment_subject"
18485                | "query_parameter"
18486                | "ssrf_oast_query"
18487                | "authorization_resource"
18488                | "openapi_document"
18489                | "rest_operation"
18490        ) {
18491            return Err(ReportError::Serialization);
18492        }
18493        let mut references: Vec<&str> = Vec::new();
18494        for reference in self
18495            .evidence_references
18496            .iter()
18497            .chain(&self.control_evidence_references)
18498            .chain(&self.candidate_evidence_references)
18499        {
18500            if !valid_opaque_assessment_reference(reference, "evidence")
18501                || references.contains(&reference.as_str())
18502            {
18503                return Err(ReportError::Serialization);
18504            }
18505            references.push(reference);
18506        }
18507        if u64::try_from(references.len()).map_err(|_| ReportError::Serialization)?
18508            != self.evidence_count
18509        {
18510            return Err(ReportError::Serialization);
18511        }
18512        let linkage_is_valid = match self.claim_basis {
18513            "observation" => {
18514                self.disposition == "informational"
18515                    && !self.evidence_references.is_empty()
18516                    && self.control_evidence_references.is_empty()
18517                    && self.candidate_evidence_references.is_empty()
18518                    && self.case_reference.is_none()
18519                    && self.outcome_reference.is_none()
18520                    && self.verification_stage.is_none()
18521            },
18522            "differential" => {
18523                let atomic_pair = self.evidence_references.len() == 1
18524                    && self.control_evidence_references.is_empty()
18525                    && self.candidate_evidence_references.is_empty();
18526                let matched_pair = self.evidence_references.is_empty()
18527                    && !self.control_evidence_references.is_empty()
18528                    && !self.candidate_evidence_references.is_empty();
18529                self.disposition == "needs_review"
18530                    && (atomic_pair || matched_pair)
18531                    && self.case_reference.is_none()
18532                    && self.outcome_reference.is_none()
18533                    && self.verification_stage.is_none()
18534            },
18535            "verifier_transition" => {
18536                self.disposition == "confirmed"
18537                    && !self.evidence_references.is_empty()
18538                    && self.control_evidence_references.is_empty()
18539                    && self.candidate_evidence_references.is_empty()
18540                    && self.case_reference.as_deref().is_some_and(|reference| {
18541                        valid_opaque_assessment_reference(reference, "case")
18542                    })
18543                    && self.outcome_reference.as_deref().is_some_and(|reference| {
18544                        valid_opaque_assessment_reference(reference, "outcome")
18545                    })
18546                    && matches!(self.verification_stage, Some("passive" | "active"))
18547            },
18548            _ => false,
18549        };
18550        if linkage_is_valid {
18551            Ok(())
18552        } else {
18553            Err(ReportError::Serialization)
18554        }
18555    }
18556}
18557
18558#[cfg(feature = "scanning")]
18559fn valid_opaque_assessment_reference(value: &str, kind: &str) -> bool {
18560    let Some(suffix) = value
18561        .strip_prefix(kind)
18562        .and_then(|value| value.strip_prefix('-'))
18563    else {
18564        return false;
18565    };
18566    suffix.len() >= 4
18567        && suffix.bytes().all(|byte| byte.is_ascii_digit())
18568        && (suffix.len() == 4 || !suffix.starts_with('0'))
18569}
18570
18571#[cfg(feature = "scanning")]
18572fn assessment_reference_list(references: &[String]) -> String {
18573    if references.is_empty() {
18574        "not applicable".to_owned()
18575    } else {
18576        references.join(", ")
18577    }
18578}
18579
18580#[cfg(feature = "scanning")]
18581struct AssessmentBasisLinkageDocument {
18582    evidence_references: Vec<String>,
18583    control_evidence_references: Vec<String>,
18584    candidate_evidence_references: Vec<String>,
18585    case_reference: Option<String>,
18586    outcome_reference: Option<String>,
18587    verification_stage: Option<&'static str>,
18588}
18589
18590#[cfg(feature = "scanning")]
18591impl AssessmentBasisLinkageDocument {
18592    fn from_basis(basis: &AssessmentBasis) -> Result<Self, ReportError> {
18593        match basis {
18594            AssessmentBasis::Observation(observation) => {
18595                let evidence_references = observation
18596                    .evidence()
18597                    .iter()
18598                    .map(ToString::to_string)
18599                    .collect::<Vec<_>>();
18600                if evidence_references.is_empty() {
18601                    return Err(ReportError::Serialization);
18602                }
18603                Ok(Self {
18604                    evidence_references,
18605                    control_evidence_references: Vec::new(),
18606                    candidate_evidence_references: Vec::new(),
18607                    case_reference: None,
18608                    outcome_reference: None,
18609                    verification_stage: None,
18610                })
18611            },
18612            AssessmentBasis::Differential(differential) => {
18613                if let Some(reference) = differential.paired_comparison() {
18614                    if !differential.control().is_empty() || !differential.candidate().is_empty() {
18615                        return Err(ReportError::Serialization);
18616                    }
18617                    return Ok(Self {
18618                        evidence_references: vec![reference.to_string()],
18619                        control_evidence_references: Vec::new(),
18620                        candidate_evidence_references: Vec::new(),
18621                        case_reference: None,
18622                        outcome_reference: None,
18623                        verification_stage: None,
18624                    });
18625                }
18626                let control_evidence_references = differential
18627                    .control()
18628                    .iter()
18629                    .map(ToString::to_string)
18630                    .collect::<Vec<_>>();
18631                let candidate_evidence_references = differential
18632                    .candidate()
18633                    .iter()
18634                    .map(ToString::to_string)
18635                    .collect::<Vec<_>>();
18636                if control_evidence_references.is_empty()
18637                    || candidate_evidence_references.is_empty()
18638                {
18639                    return Err(ReportError::Serialization);
18640                }
18641                Ok(Self {
18642                    evidence_references: Vec::new(),
18643                    control_evidence_references,
18644                    candidate_evidence_references,
18645                    case_reference: None,
18646                    outcome_reference: None,
18647                    verification_stage: None,
18648                })
18649            },
18650            AssessmentBasis::Verifier(verifier) => {
18651                let evidence_references = verifier
18652                    .evidence()
18653                    .iter()
18654                    .map(ToString::to_string)
18655                    .collect::<Vec<_>>();
18656                if evidence_references.is_empty() {
18657                    return Err(ReportError::Serialization);
18658                }
18659                Ok(Self {
18660                    evidence_references,
18661                    control_evidence_references: Vec::new(),
18662                    candidate_evidence_references: Vec::new(),
18663                    case_reference: Some(verifier.case_reference().to_string()),
18664                    outcome_reference: Some(verifier.outcome_reference().to_string()),
18665                    verification_stage: Some(verifier.stage().as_str()),
18666                })
18667            },
18668        }
18669    }
18670
18671    fn reference_count(&self) -> Result<u64, ReportError> {
18672        self.evidence_references
18673            .len()
18674            .checked_add(self.control_evidence_references.len())
18675            .and_then(|count| count.checked_add(self.candidate_evidence_references.len()))
18676            .and_then(|count| u64::try_from(count).ok())
18677            .ok_or(ReportError::Serialization)
18678    }
18679}
18680
18681#[cfg(feature = "scanning")]
18682#[derive(Serialize)]
18683struct AssessmentRemediationDocument<'a> {
18684    id: &'a str,
18685    summary: &'a str,
18686}
18687
18688#[cfg(feature = "scanning")]
18689const fn assessment_basis_token(basis: &AssessmentBasis) -> &'static str {
18690    match basis {
18691        AssessmentBasis::Observation(_) => "observation",
18692        AssessmentBasis::Differential(_) => "differential",
18693        AssessmentBasis::Verifier(_) => "verifier_transition",
18694    }
18695}
18696
18697#[derive(Serialize)]
18698struct ReportDocument<'a> {
18699    schema: &'static str,
18700    source_schema: &'a str,
18701    status: &'static str,
18702    stop_code: &'static str,
18703    target: &'a str,
18704    authorized_origin: &'a str,
18705    started_at: String,
18706    completed_at: String,
18707    accounting: AccountingDocument,
18708    steps: Vec<StepDocument<'a>>,
18709    outcomes: Vec<OutcomeDocument<'a>>,
18710}
18711
18712impl<'a> ReportDocument<'a> {
18713    fn from_report(report: &'a RunReport) -> Result<Self, ReportError> {
18714        Ok(Self {
18715            schema: REPORT_DOCUMENT_SCHEMA,
18716            source_schema: report.schema(),
18717            status: run_status_token(report.status()),
18718            stop_code: stop_code_token(report.stop_reason().code()),
18719            target: report.target(),
18720            authorized_origin: report.authorized_origin(),
18721            started_at: report.started_at().to_rfc3339(),
18722            completed_at: report.completed_at().to_rfc3339(),
18723            accounting: AccountingDocument::from_report(report),
18724            steps: report.steps().iter().map(StepDocument::from_step).collect(),
18725            outcomes: report
18726                .outcomes()
18727                .iter()
18728                .map(OutcomeDocument::from_outcome)
18729                .collect::<Result<Vec<_>, _>>()?,
18730        })
18731    }
18732
18733    fn metadata(&self) -> [(&'static str, &str); 8] {
18734        [
18735            ("Schema", self.schema),
18736            ("Source schema", self.source_schema),
18737            ("Status", self.status),
18738            ("Stop code", self.stop_code),
18739            ("Target", self.target),
18740            ("Authorized origin", self.authorized_origin),
18741            ("Started at", &self.started_at),
18742            ("Completed at", &self.completed_at),
18743        ]
18744    }
18745}
18746
18747#[derive(Serialize)]
18748struct AccountingDocument {
18749    requests: AccountingDimension,
18750    response_body_bytes: AccountingDimension,
18751    request_body_bytes: AccountingDimension,
18752    wall_time_ms: AccountingDimension,
18753}
18754
18755impl AccountingDocument {
18756    fn from_report(report: &RunReport) -> Self {
18757        let accounting = report.accounting();
18758        Self {
18759            requests: AccountingDimension::from_accounting(accounting.requests()),
18760            response_body_bytes: AccountingDimension::from_accounting(
18761                accounting.response_body_bytes(),
18762            ),
18763            request_body_bytes: AccountingDimension::from_accounting(
18764                accounting.request_body_bytes(),
18765            ),
18766            wall_time_ms: AccountingDimension::from_accounting(accounting.wall_time_ms()),
18767        }
18768    }
18769
18770    fn dimensions(&self) -> [(&'static str, &AccountingDimension); 4] {
18771        [
18772            ("requests", &self.requests),
18773            ("response_body_bytes", &self.response_body_bytes),
18774            ("request_body_bytes", &self.request_body_bytes),
18775            ("wall_time_ms", &self.wall_time_ms),
18776        ]
18777    }
18778}
18779
18780#[derive(Serialize)]
18781struct AccountingDimension {
18782    mode: &'static str,
18783    limit: Option<String>,
18784    consumed: Option<String>,
18785    remaining: Option<String>,
18786}
18787
18788impl AccountingDimension {
18789    fn from_accounting(accounting: &ResourceAccounting) -> Self {
18790        Self {
18791            mode: accounting_mode_token(accounting.mode()),
18792            limit: accounting.limit().map(|value| value.to_string()),
18793            consumed: accounting.consumed().map(|value| value.to_string()),
18794            remaining: accounting.remaining().map(|value| value.to_string()),
18795        }
18796    }
18797}
18798
18799#[derive(Serialize)]
18800struct StepDocument<'a> {
18801    ordinal: u32,
18802    action_id: &'a str,
18803    status: &'static str,
18804    duration_ms: String,
18805}
18806
18807impl<'a> StepDocument<'a> {
18808    fn from_step(step: &'a termivar_core::RunStepReport) -> Self {
18809        Self {
18810            ordinal: step.ordinal(),
18811            action_id: step.action_id(),
18812            status: step_status_token(step.status()),
18813            duration_ms: step.duration_ms().to_string(),
18814        }
18815    }
18816}
18817
18818#[derive(Serialize)]
18819struct OutcomeDocument<'a> {
18820    kind: &'static str,
18821    action_id: &'a str,
18822    severity: &'static str,
18823    disposition: &'static str,
18824    confidence_ppm: u32,
18825    evidence_count: u64,
18826    redacted_summary: &'a str,
18827}
18828
18829impl<'a> OutcomeDocument<'a> {
18830    fn from_outcome(outcome: &'a RunOutcomeRecord) -> Result<Self, ReportError> {
18831        Ok(Self {
18832            kind: if outcome.verification_outcome().is_some() {
18833                "verification_outcome"
18834            } else {
18835                "unresolved_observation"
18836            },
18837            action_id: outcome.action_id(),
18838            severity: severity_token(outcome.severity()),
18839            disposition: disposition_token(outcome.disposition()),
18840            confidence_ppm: outcome.confidence().parts_per_million(),
18841            evidence_count: u64::try_from(outcome.evidence_ids().len())
18842                .map_err(|_| ReportError::Serialization)?,
18843            redacted_summary: outcome.redacted_summary(),
18844        })
18845    }
18846}
18847
18848fn run_status_token(status: RunStatus) -> &'static str {
18849    match status {
18850        RunStatus::Complete => "complete",
18851        RunStatus::Partial => "partial",
18852        RunStatus::Cancelled => "cancelled",
18853        RunStatus::Failed => "failed",
18854        _ => "unrecognized",
18855    }
18856}
18857
18858fn stop_code_token(code: RunStopCode) -> &'static str {
18859    match code {
18860        RunStopCode::Completed => "completed",
18861        RunStopCode::NoEligibleAction => "no_eligible_action",
18862        RunStopCode::BudgetExhausted => "budget_exhausted",
18863        RunStopCode::ReportLimitExceeded => "report_limit_exceeded",
18864        RunStopCode::Cancelled => "cancelled",
18865        RunStopCode::StepFailed => "step_failed",
18866        RunStopCode::StepTimedOut => "step_timed_out",
18867        RunStopCode::TaskJoinFailed => "task_join_failed",
18868        RunStopCode::RuntimeFailed => "runtime_failed",
18869        _ => "unrecognized",
18870    }
18871}
18872
18873fn step_status_token(status: RunStepStatus) -> &'static str {
18874    match status {
18875        RunStepStatus::Succeeded => "succeeded",
18876        RunStepStatus::Failed => "failed",
18877        RunStepStatus::TimedOut => "timed_out",
18878        RunStepStatus::Cancelled => "cancelled",
18879        RunStepStatus::Skipped => "skipped",
18880        RunStepStatus::BudgetExhausted => "budget_exhausted",
18881        _ => "unrecognized",
18882    }
18883}
18884
18885fn accounting_mode_token(mode: ResourceAccountingMode) -> &'static str {
18886    match mode {
18887        ResourceAccountingMode::Metered => "metered",
18888        ResourceAccountingMode::Observed => "observed",
18889        ResourceAccountingMode::Unmetered => "unmetered",
18890        _ => "unrecognized",
18891    }
18892}
18893
18894fn disposition_token(disposition: OutcomeStatus) -> &'static str {
18895    match disposition {
18896        OutcomeStatus::Success => "success",
18897        OutcomeStatus::Blocked => "blocked",
18898        OutcomeStatus::Unknown => "unknown",
18899        OutcomeStatus::FalsePositive => "false_positive",
18900        OutcomeStatus::NeedsReview => "needs_review",
18901        OutcomeStatus::ConfirmedNegative => "confirmed_negative",
18902        _ => "unrecognized",
18903    }
18904}
18905
18906fn severity_token(severity: SecuritySeverity) -> &'static str {
18907    match severity {
18908        SecuritySeverity::Info => "info",
18909        SecuritySeverity::Low => "low",
18910        SecuritySeverity::Medium => "medium",
18911        SecuritySeverity::High => "high",
18912        SecuritySeverity::Critical => "critical",
18913        _ => "unrecognized",
18914    }
18915}
18916
18917fn write_visible_codepoint(output: &mut RenderBuffer, character: char) -> Result<(), ReportError> {
18918    output.push_fmt(format_args!("\\u{{{:04X}}}", u32::from(character)))
18919}
18920
18921fn is_bidi_control(character: char) -> bool {
18922    matches!(
18923        character,
18924        '\u{061C}'
18925            | '\u{200E}'
18926            | '\u{200F}'
18927            | '\u{202A}'..='\u{202E}'
18928            | '\u{2066}'..='\u{2069}'
18929    )
18930}
18931
18932#[cfg(test)]
18933mod tests {
18934    use super::*;
18935    use std::collections::BTreeSet;
18936    use termivar_core::{
18937        EntityId, EvidenceId, Outcome, Probability, RunAccounting, RunReportInput, RunStepReport,
18938        RunStopReason, VerificationStage,
18939    };
18940
18941    const PRIVATE_SUBJECT: &str = "private-subject-sentinel";
18942    const PRIVATE_EVIDENCE: &str = "private-evidence-sentinel";
18943    const PRIVATE_RATIONALE: &str = "private-rationale-sentinel";
18944    const PRIVATE_CASE: &str = "private-case-sentinel";
18945    const PRIVATE_RULE: &str = "private-rule-sentinel";
18946    const PRIVATE_HYPOTHESIS: &str = "private-hypothesis-sentinel";
18947    const PRIVATE_STEP_DETAIL: &str = "private-step-detail-sentinel";
18948    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18949    const WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V1: &str = "security.wordpress-discovery-audit/v1";
18950    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
18951    const WORDPRESS_DISCOVERY_POLICY_ID_V1: &str = "termivar.wordpress-metadata-discovery/v1";
18952
18953    #[cfg(all(feature = "scanning", feature = "recon-ct-provider"))]
18954    #[test]
18955    fn certspotter_issuance_reference_contains_no_raw_bidi_input() {
18956        let raw = "opaque/\u{202e}name@example.com";
18957        let (reference, byte_length) = recon_certspotter_issuance_reference(raw).unwrap();
18958
18959        assert_eq!(byte_length, raw.len() as u64);
18960        assert!(valid_recon_certspotter_issuance_reference(&reference));
18961        assert!(reference.is_ascii());
18962        assert!(!reference.contains(raw));
18963        assert!(!reference.contains('\u{202e}'));
18964        assert!(!reference.contains("opaque/"));
18965        assert!(!reference.contains("name@example.com"));
18966    }
18967
18968    fn complete_report(target: &str, summary: &str) -> RunReport {
18969        report_for_status(
18970            RunStatus::Complete,
18971            RunStopCode::Completed,
18972            vec![RunStepReport::new(
18973                1,
18974                "scan.observe",
18975                RunStepStatus::Succeeded,
18976                25,
18977                Some(PRIVATE_STEP_DETAIL.to_string()),
18978            )
18979            .unwrap()],
18980            target,
18981            summary,
18982        )
18983    }
18984
18985    fn report_for_status(
18986        status: RunStatus,
18987        stop_code: RunStopCode,
18988        steps: Vec<RunStepReport>,
18989        target: &str,
18990        summary: &str,
18991    ) -> RunReport {
18992        let outcome = Outcome::verified(
18993            PRIVATE_CASE,
18994            EntityId::new(PRIVATE_SUBJECT).unwrap(),
18995            "scan.observe",
18996            PRIVATE_HYPOTHESIS,
18997            PRIVATE_RULE,
18998            VerificationStage::Passive,
18999            OutcomeStatus::Success,
19000            Probability::from_parts_per_million(812_345).unwrap(),
19001            PRIVATE_RATIONALE,
19002            BTreeSet::from([EvidenceId::parse(PRIVATE_EVIDENCE).unwrap()]),
19003        )
19004        .unwrap();
19005        let outcome = RunOutcomeRecord::from_outcome(outcome, summary).unwrap();
19006        let input = RunReportInput::new(
19007            status,
19008            RunStopReason::new(stop_code, "private-stop-detail-sentinel").unwrap(),
19009            target,
19010            "https://example.test",
19011            "2026-08-20T10:00:00Z".parse().unwrap(),
19012            "2026-08-20T10:00:01Z".parse().unwrap(),
19013        )
19014        .unwrap()
19015        .with_accounting(RunAccounting::new(
19016            ResourceAccounting::metered(10, 4),
19017            ResourceAccounting::observed(900),
19018            ResourceAccounting::unmetered(),
19019            ResourceAccounting::metered(2_000, 1_000),
19020        ))
19021        .with_steps(steps)
19022        .with_outcomes(if status == RunStatus::Failed {
19023            Vec::new()
19024        } else {
19025            vec![outcome]
19026        });
19027        RunReport::new(input).unwrap()
19028    }
19029
19030    fn maximum_accounting_report() -> RunReport {
19031        let outcome = RunOutcomeRecord::unresolved(
19032            EntityId::new(PRIVATE_SUBJECT).unwrap(),
19033            "scan.observe",
19034            PRIVATE_RATIONALE,
19035            "summary",
19036        )
19037        .unwrap();
19038        let input = RunReportInput::new(
19039            RunStatus::Complete,
19040            RunStopReason::new(RunStopCode::Completed, "done").unwrap(),
19041            "target",
19042            "origin",
19043            "2026-08-20T10:00:00Z".parse().unwrap(),
19044            "2026-08-20T10:00:01Z".parse().unwrap(),
19045        )
19046        .unwrap()
19047        .with_accounting(RunAccounting::new(
19048            ResourceAccounting::metered(u64::MAX, u64::MAX),
19049            ResourceAccounting::observed(u64::MAX),
19050            ResourceAccounting::metered(u64::MAX, 0),
19051            ResourceAccounting::metered(u64::MAX, 1),
19052        ))
19053        .with_steps(vec![RunStepReport::new(
19054            1,
19055            "scan.observe",
19056            RunStepStatus::Succeeded,
19057            u64::MAX,
19058            None,
19059        )
19060        .unwrap()])
19061        .with_outcomes(vec![outcome]);
19062        RunReport::new(input).unwrap()
19063    }
19064
19065    fn parse_csv_line(line: &str) -> Vec<String> {
19066        let bytes = line.as_bytes();
19067        let mut index = 0;
19068        let mut cells = Vec::new();
19069        while index < bytes.len() {
19070            assert_eq!(bytes[index], b'"');
19071            index += 1;
19072            let mut cell = Vec::new();
19073            loop {
19074                assert!(index < bytes.len());
19075                if bytes[index] == b'"' {
19076                    if bytes.get(index + 1) == Some(&b'"') {
19077                        cell.push(b'"');
19078                        index += 2;
19079                        continue;
19080                    }
19081                    index += 1;
19082                    break;
19083                }
19084                cell.push(bytes[index]);
19085                index += 1;
19086            }
19087            cells.push(String::from_utf8(cell).unwrap());
19088            if index == bytes.len() {
19089                break;
19090            }
19091            assert_eq!(bytes[index], b',');
19092            index += 1;
19093        }
19094        cells
19095    }
19096
19097    #[cfg(feature = "scanning")]
19098    fn observation_assessment_document(text: &str) -> AssessmentDocument<'_> {
19099        AssessmentDocument {
19100            schema: ASSESSMENT_REPORT_DOCUMENT_SCHEMA,
19101            source_schema: crate::web_runtime::ASSESSMENT_RUN_REPORT_SCHEMA,
19102            run_schema: termivar_core::RUN_REPORT_SCHEMA,
19103            profile_schema: crate::web_runtime::SCAN_PROFILE_V1_SCHEMA,
19104            profile: "web-review",
19105            status: "complete",
19106            subject_count: 1,
19107            item_count: 1,
19108            #[cfg(feature = "supplied-session-review")]
19109            supplied_session: None,
19110            #[cfg(feature = "authorization-review")]
19111            authorization_review: None,
19112            #[cfg(feature = "websocket-review")]
19113            websocket_review: None,
19114            #[cfg(feature = "xml-external-entity-review")]
19115            xml_external_entity_review: None,
19116            #[cfg(feature = "template-evaluation-review")]
19117            template_evaluation_review: None,
19118            #[cfg(feature = "openapi-review")]
19119            openapi_review: None,
19120            #[cfg(feature = "rest-review")]
19121            rest_review: None,
19122            #[cfg(feature = "secret-exposure-review")]
19123            secret_exposure_review: None,
19124            #[cfg(feature = "tls-observation")]
19125            tls_observation: None,
19126            #[cfg(feature = "tls-negotiation-review")]
19127            tls_negotiation_review: None,
19128            #[cfg(feature = "jwt-policy-review")]
19129            jwt_policy_review: None,
19130            #[cfg(feature = "control-reference-mapping")]
19131            control_reference_mapping: None,
19132            #[cfg(feature = "recon-snapshot-import")]
19133            recon_snapshot_import: None,
19134            #[cfg(feature = "recon-ct-provider")]
19135            recon_certspotter: None,
19136            #[cfg(feature = "wordpress-review")]
19137            wordpress_review: None,
19138            #[cfg(feature = "wordpress-review")]
19139            wordpress_discovery: None,
19140            #[cfg(feature = "wordpress-review")]
19141            wordpress_asset_fingerprints: None,
19142            items: vec![AssessmentItemDocument {
19143                schema: crate::web_runtime::ASSESSMENT_ITEM_SCHEMA,
19144                capability_id: text,
19145                subject_reference: "subject-0000".to_owned(),
19146                presentation_target: "assessment_subject",
19147                title: text,
19148                disposition: "informational",
19149                claim_basis: "observation",
19150                severity: None,
19151                confidence_ppm: 750_000,
19152                fingerprint: text,
19153                evidence_count: 1,
19154                redacted_summary: text,
19155                category: text,
19156                cwe: None,
19157                remediation: AssessmentRemediationDocument {
19158                    id: text,
19159                    summary: text,
19160                },
19161                evidence_references: vec!["evidence-0000".to_owned()],
19162                control_evidence_references: Vec::new(),
19163                candidate_evidence_references: Vec::new(),
19164                case_reference: None,
19165                outcome_reference: None,
19166                verification_stage: None,
19167            }],
19168        }
19169    }
19170
19171    #[cfg(all(feature = "scanning", feature = "websocket-review"))]
19172    fn websocket_review_document() -> AssessmentWebSocketReviewAuditDocument {
19173        AssessmentWebSocketReviewAuditDocument {
19174            schema: WEBSOCKET_REVIEW_AUDIT_SCHEMA,
19175            capability_id: WEBSOCKET_REVIEW_CAPABILITY_ID,
19176            selected: true,
19177            policy_reference: format!("websocket-policy-sha256:{}", "a".repeat(64)),
19178            endpoint_reference: format!("websocket-endpoint-sha256:{}", "b".repeat(64)),
19179            context: "anonymous",
19180            #[cfg(feature = "supplied-session-review")]
19181            supplied_session_context: None,
19182            methodology: AssessmentWebSocketReviewMethodologyDocument {
19183                origin_mode: "application_origin",
19184                compression: "disabled",
19185                reconnect: "disabled",
19186                http2_extended_connect: "unsupported",
19187                subprotocol: "present",
19188                physical_frame_count: WEBSOCKET_REVIEW_PHYSICAL_FRAME_COUNT,
19189                max_frame_size: WEBSOCKET_REVIEW_MAX_FRAME_SIZE,
19190                limits: AssessmentWebSocketReviewLimitsDocument {
19191                    max_connections: 1,
19192                    max_outbound_application_bytes: 65_536,
19193                    max_inbound_application_bytes: 65_536,
19194                    max_outbound_message_bytes: 4_096,
19195                    max_inbound_message_bytes: 4_096,
19196                    max_messages: 2,
19197                    max_control_frames: 4,
19198                    max_wall_time_ms: 1_000,
19199                },
19200            },
19201            coverage: AssessmentWebSocketReviewCoverageDocument {
19202                terminal: "completed",
19203                completeness: "configured_exchange_complete",
19204                first_failure: None,
19205                request_attempt_count: 1,
19206                request_admitted_count: 1,
19207                connection_attempt_count: 1,
19208                handshake_completed_count: 1,
19209                outbound_message_count: 2,
19210                inbound_message_count: 2,
19211                outbound_control_frame_count: 1,
19212                inbound_control_frame_count: 0,
19213                outbound_application_bytes: 9,
19214                inbound_application_bytes: 15,
19215                transport_read_bytes: 241,
19216                transport_write_bytes: 198,
19217                accounted_request_body_bytes: 0,
19218                accounted_transport_response_bytes: 241,
19219                transport_byte_scope: WEBSOCKET_REVIEW_TRANSPORT_BYTE_SCOPE,
19220                expected_response_count: 2,
19221                matched_response_count: 2,
19222                mismatched_response_count: 0,
19223            },
19224            messages: vec![
19225                AssessmentWebSocketReviewMessageDocument {
19226                    message_reference: format!(
19227                        "websocket-message-revision-sha256:{}",
19228                        "c".repeat(64)
19229                    ),
19230                    expected_response_reference: format!(
19231                        "websocket-expected-response-revision-sha256:{}",
19232                        "d".repeat(64)
19233                    ),
19234                    outbound_length: 4,
19235                    inbound_length: Some(7),
19236                    status: "response_matched",
19237                },
19238                AssessmentWebSocketReviewMessageDocument {
19239                    message_reference: format!(
19240                        "websocket-message-revision-sha256:{}",
19241                        "e".repeat(64)
19242                    ),
19243                    expected_response_reference: format!(
19244                        "websocket-expected-response-revision-sha256:{}",
19245                        "f".repeat(64)
19246                    ),
19247                    outbound_length: 5,
19248                    inbound_length: Some(8),
19249                    status: "response_matched",
19250                },
19251            ],
19252            claim_limits: WEBSOCKET_REVIEW_CLAIM_LIMITS.to_vec(),
19253        }
19254    }
19255
19256    #[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
19257    fn xml_external_entity_review_document() -> AssessmentXmlExternalEntityReviewAuditDocument {
19258        AssessmentXmlExternalEntityReviewAuditDocument {
19259            schema: XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA,
19260            algorithm: XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM,
19261            policy: AssessmentXmlExternalEntityPolicyDocument {
19262                id: format!("xml-external-entity-policy-sha256:{}", "a".repeat(64)),
19263                method: "post",
19264                media_type: "application/xml; charset=utf-8",
19265                control_model: XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL,
19266                execution_mode: "owned_numeric_loopback_test",
19267            },
19268            outcome: "repeated_external_entity_resolution_observed",
19269            target: AssessmentXmlExternalEntityTargetDocument {
19270                request_count: 3,
19271                request_body_bytes: 768,
19272                response_bytes: 96,
19273                complete: true,
19274                accounting_complete: true,
19275            },
19276            provider: AssessmentXmlExternalEntityProviderDocument {
19277                request_count: 12,
19278                active_verification_count: 1,
19279                preflight_clean: true,
19280                control_callback_observed: false,
19281                candidate_callback_observed: true,
19282                replay_callback_observed: true,
19283                callback_targets_distinct: true,
19284                event_identities_distinct: true,
19285                cleanup_verified: true,
19286                complete: true,
19287            },
19288            item_projected: true,
19289            semantic_effect: "not_performed",
19290            impact_validation: "not_performed",
19291            claim_limits: XML_EXTERNAL_ENTITY_REVIEW_CLAIM_LIMITS.to_vec(),
19292        }
19293    }
19294
19295    #[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
19296    fn xml_external_entity_item_document() -> AssessmentItemDocument<'static> {
19297        AssessmentItemDocument {
19298            schema: crate::web_runtime::ASSESSMENT_ITEM_SCHEMA,
19299            capability_id: XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID,
19300            subject_reference: "subject-0000".to_owned(),
19301            presentation_target: "assessment_subject",
19302            title: XML_EXTERNAL_ENTITY_REVIEW_TITLE,
19303            disposition: "needs_review",
19304            claim_basis: "differential",
19305            severity: None,
19306            confidence_ppm: 900_000,
19307            fingerprint: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
19308            evidence_count: 8,
19309            redacted_summary: XML_EXTERNAL_ENTITY_REVIEW_SUMMARY,
19310            category: XML_EXTERNAL_ENTITY_REVIEW_CATEGORY,
19311            cwe: Some("CWE-611"),
19312            remediation: AssessmentRemediationDocument {
19313                id: XML_EXTERNAL_ENTITY_REVIEW_REMEDIATION_ID,
19314                summary: XML_EXTERNAL_ENTITY_REVIEW_REMEDIATION_SUMMARY,
19315            },
19316            evidence_references: Vec::new(),
19317            control_evidence_references: vec![
19318                "evidence-0000".to_owned(),
19319                "evidence-0001".to_owned(),
19320                "evidence-0002".to_owned(),
19321            ],
19322            candidate_evidence_references: vec![
19323                "evidence-0003".to_owned(),
19324                "evidence-0004".to_owned(),
19325                "evidence-0005".to_owned(),
19326                "evidence-0006".to_owned(),
19327                "evidence-0007".to_owned(),
19328            ],
19329            case_reference: None,
19330            outcome_reference: None,
19331            verification_stage: None,
19332        }
19333    }
19334
19335    #[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
19336    fn xml_external_entity_assessment_document() -> AssessmentDocument<'static> {
19337        let mut document = observation_assessment_document(
19338            "sha256:1111111111111111111111111111111111111111111111111111111111111111",
19339        );
19340        document.subject_count = 2;
19341        document.items[0] = xml_external_entity_item_document();
19342        document.xml_external_entity_review = Some(xml_external_entity_review_document());
19343        document
19344    }
19345
19346    #[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
19347    fn assert_xml_external_entity_item_mutation_rejected(
19348        mutate: impl FnOnce(&mut AssessmentItemDocument<'static>),
19349    ) {
19350        let mut document = xml_external_entity_assessment_document();
19351        mutate(&mut document.items[0]);
19352        assert_eq!(document.validate(), Err(ReportError::Serialization));
19353    }
19354
19355    #[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
19356    fn template_evaluation_review_document(
19357        outcome: &'static str,
19358        projected_item_count: u8,
19359    ) -> AssessmentTemplateEvaluationReviewAuditDocument {
19360        AssessmentTemplateEvaluationReviewAuditDocument {
19361            schema: TEMPLATE_EVALUATION_REVIEW_AUDIT_SCHEMA,
19362            policy_id: TEMPLATE_EVALUATION_REVIEW_POLICY_ID,
19363            family_id: TEMPLATE_EVALUATION_REVIEW_FAMILY_ID,
19364            outcome,
19365            engine_identity: TEMPLATE_EVALUATION_ENGINE_IDENTITY,
19366            coverage: AssessmentTemplateEvaluationCoverageDocument {
19367                selected_case_count: 2,
19368                attempted_request_count: 4,
19369                active_request_count: 2,
19370                completed_response_count: 4,
19371                committed_response_count: 4,
19372                projected_item_count,
19373            },
19374            operations: AssessmentTemplateEvaluationOperationsDocument {
19375                outbound_interaction: TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED,
19376                os_execution: TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED,
19377                file_access: TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED,
19378                impact_validation: TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED,
19379            },
19380            claim_limits: TEMPLATE_EVALUATION_REVIEW_CLAIM_LIMITS.to_vec(),
19381        }
19382    }
19383
19384    #[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
19385    fn template_evaluation_review_item_document() -> AssessmentItemDocument<'static> {
19386        AssessmentItemDocument {
19387            schema: crate::web_runtime::ASSESSMENT_ITEM_SCHEMA,
19388            capability_id: TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID,
19389            subject_reference: "subject-0000".to_owned(),
19390            presentation_target: "query_parameter",
19391            title: TEMPLATE_EVALUATION_REVIEW_TITLE,
19392            disposition: "needs_review",
19393            claim_basis: "differential",
19394            severity: None,
19395            confidence_ppm: TEMPLATE_EVALUATION_REVIEW_CONFIDENCE_PPM,
19396            fingerprint: "sha256:3333333333333333333333333333333333333333333333333333333333333333",
19397            evidence_count: 12,
19398            redacted_summary: TEMPLATE_EVALUATION_REVIEW_SUMMARY,
19399            category: TEMPLATE_EVALUATION_REVIEW_CATEGORY,
19400            cwe: Some("CWE-1336"),
19401            remediation: AssessmentRemediationDocument {
19402                id: TEMPLATE_EVALUATION_REVIEW_REMEDIATION_ID,
19403                summary: TEMPLATE_EVALUATION_REVIEW_REMEDIATION_SUMMARY,
19404            },
19405            evidence_references: Vec::new(),
19406            control_evidence_references: (0..6)
19407                .map(|ordinal| format!("evidence-{ordinal:04}"))
19408                .collect(),
19409            candidate_evidence_references: (6..12)
19410                .map(|ordinal| format!("evidence-{ordinal:04}"))
19411                .collect(),
19412            case_reference: None,
19413            outcome_reference: None,
19414            verification_stage: None,
19415        }
19416    }
19417
19418    #[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
19419    fn template_evaluation_assessment_document() -> AssessmentDocument<'static> {
19420        let mut document = observation_assessment_document(
19421            "sha256:4444444444444444444444444444444444444444444444444444444444444444",
19422        );
19423        document.items[0] = template_evaluation_review_item_document();
19424        document.template_evaluation_review = Some(template_evaluation_review_document(
19425            "candidate_specific_evaluation",
19426            1,
19427        ));
19428        document
19429    }
19430
19431    #[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
19432    fn assert_template_evaluation_item_mutation_rejected(
19433        mutate: impl FnOnce(&mut AssessmentItemDocument<'static>),
19434    ) {
19435        let mut document = template_evaluation_assessment_document();
19436        mutate(&mut document.items[0]);
19437        assert_eq!(document.validate(), Err(ReportError::Serialization));
19438    }
19439
19440    #[cfg(all(
19441        feature = "scanning",
19442        feature = "websocket-review",
19443        feature = "supplied-session-review"
19444    ))]
19445    fn supplied_session_websocket_review_document() -> AssessmentWebSocketReviewAuditDocument {
19446        let mut document = websocket_review_document();
19447        document.schema = WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA;
19448        document.context = "supplied_session";
19449        document.supplied_session_context =
19450            Some(AssessmentWebSocketSuppliedSessionContextDocument {
19451                mode: "health_qualified_authorization_header",
19452                policy_reference: format!("supplied-session-policy-sha256:{}", "1".repeat(64)),
19453                application_reference: format!(
19454                    "supplied-session-application-sha256:{}",
19455                    "2".repeat(64)
19456                ),
19457                principal_reference: "supplied-session-principal-0001".to_owned(),
19458                principal_alias: "fixture-user".to_owned(),
19459                principal_assurance: "operator_declared",
19460                credential_mechanism: "authorization_header",
19461                session_epoch: 1,
19462                startup_health: Some(AssessmentWebSocketSuppliedSessionHealthDocument {
19463                    outcome: "healthy",
19464                    evidence_reference: Some(format!(
19465                        "supplied-session-checkpoint-evidence-sha256:{}",
19466                        "6".repeat(64)
19467                    )),
19468                }),
19469                terminal_health: Some(AssessmentWebSocketSuppliedSessionHealthDocument {
19470                    outcome: "healthy",
19471                    evidence_reference: Some(format!(
19472                        "supplied-session-checkpoint-evidence-sha256:{}",
19473                        "7".repeat(64)
19474                    )),
19475                }),
19476                qualification: "context_qualified",
19477                continuous_authentication_established: false,
19478            });
19479        document
19480            .claim_limits
19481            .push("continuous_authentication_not_established");
19482        document
19483    }
19484
19485    #[cfg(all(
19486        feature = "scanning",
19487        feature = "websocket-review",
19488        feature = "supplied-session-review"
19489    ))]
19490    fn not_established_supplied_session_websocket_review_document(
19491    ) -> AssessmentWebSocketReviewAuditDocument {
19492        let mut document = supplied_session_websocket_review_document();
19493        let context = document
19494            .supplied_session_context
19495            .as_mut()
19496            .expect("V2 fixture has supplied-session context");
19497        context.qualification = "not_established";
19498        document.coverage.terminal = "incomplete";
19499        document.coverage.completeness = "incomplete";
19500        document.coverage.first_failure = Some("parent_authority_unavailable");
19501        document.coverage.request_attempt_count = 1;
19502        document.coverage.request_admitted_count = 0;
19503        document.coverage.connection_attempt_count = 0;
19504        document.coverage.handshake_completed_count = 0;
19505        document.coverage.outbound_message_count = 0;
19506        document.coverage.inbound_message_count = 0;
19507        document.coverage.outbound_control_frame_count = 0;
19508        document.coverage.inbound_control_frame_count = 0;
19509        document.coverage.outbound_application_bytes = 0;
19510        document.coverage.inbound_application_bytes = 0;
19511        document.coverage.transport_read_bytes = 0;
19512        document.coverage.transport_write_bytes = 0;
19513        document.coverage.accounted_request_body_bytes = 0;
19514        document.coverage.accounted_transport_response_bytes = 0;
19515        document.coverage.matched_response_count = 0;
19516        document.coverage.mismatched_response_count = 0;
19517        for message in &mut document.messages {
19518            message.inbound_length = None;
19519            message.status = "not_sent";
19520        }
19521        document
19522    }
19523
19524    #[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
19525    fn control_reference_sources() -> Vec<AssessmentControlReferenceSourceDocument> {
19526        vec![
19527            AssessmentControlReferenceSourceDocument {
19528                source_id: "iso-iec-27001-2022-amd-1-2024",
19529                framework_id: "iso-iec-27001",
19530                edition: "2022",
19531                revision_or_amendment: Some("Amd-1:2024"),
19532                authority_kind: "international_standards_organization",
19533                source_url: "https://www.iso.org/standard/27001",
19534                source_verified_at: "2026-09-20",
19535                mapping_availability: "bibliographic_only_rights_deferred",
19536                rights_basis: "bibliographic_metadata_only",
19537            },
19538            AssessmentControlReferenceSourceDocument {
19539                source_id: "kvkk-guide-72-2025-04",
19540                framework_id: "kvkk-personal-data-security-guide",
19541                edition: "2025-04",
19542                revision_or_amendment: Some("KVKK-Yayinlari-No-72"),
19543                authority_kind: "statutory_authority",
19544                source_url: "https://kvkk.gov.tr/SharedFolderServer/CMSFiles/7512d0d4-f345-41cb-bc5b-8d5cf125e3a1.pdf",
19545                source_verified_at: "2026-09-20",
19546                mapping_availability: "relevant_technical_context_only",
19547                rights_basis: "official_reference_only",
19548            },
19549            AssessmentControlReferenceSourceDocument {
19550                source_id: "kvkk-law-6698-article-12",
19551                framework_id: "kvkk-law-6698",
19552                edition: "6698",
19553                revision_or_amendment: Some("Madde-12"),
19554                authority_kind: "statutory_authority",
19555                source_url: "https://www.kvkk.gov.tr/Icerik/2040/Veri-Guvenligine-Iliskin-Yukumlulukler",
19556                source_verified_at: "2026-09-20",
19557                mapping_availability: "relevant_technical_context_only",
19558                rights_basis: "official_reference_only",
19559            },
19560            AssessmentControlReferenceSourceDocument {
19561                source_id: "owasp-top-10-2025",
19562                framework_id: "owasp-top-10",
19563                edition: "2025",
19564                revision_or_amendment: None,
19565                authority_kind: "community_security_project",
19566                source_url: "https://top10.owasp.org/2025/0x00_2025-Introduction/",
19567                source_verified_at: "2026-09-20",
19568                mapping_availability: "reviewed_identifiers_and_titles",
19569                rights_basis: "identifiers_and_titles_with_attribution",
19570            },
19571            AssessmentControlReferenceSourceDocument {
19572                source_id: "pci-dss-4.0.1",
19573                framework_id: "pci-dss",
19574                edition: "4.0.1",
19575                revision_or_amendment: Some("published-2024-06-11"),
19576                authority_kind: "industry_standards_body",
19577                source_url: "https://blog.pcisecuritystandards.org/just-published-pci-dss-v4-0-1",
19578                source_verified_at: "2026-09-20",
19579                mapping_availability: "bibliographic_only_rights_deferred",
19580                rights_basis: "bibliographic_metadata_only",
19581            },
19582        ]
19583    }
19584
19585    #[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
19586    fn selected_empty_control_reference_mapping_audit(
19587    ) -> AssessmentControlReferenceMappingAuditDocument {
19588        AssessmentControlReferenceMappingAuditDocument {
19589            schema: CONTROL_REFERENCE_MAPPING_AUDIT_SCHEMA,
19590            policy: CONTROL_REFERENCE_MAPPING_POLICY_ID,
19591            selected: true,
19592            catalogue: AssessmentControlReferenceCatalogueDocument {
19593                id: CONTROL_REFERENCE_MAPPING_CATALOGUE_ID,
19594                revision: CONTROL_REFERENCE_MAPPING_CATALOGUE_REVISION,
19595            },
19596            sources: control_reference_sources(),
19597            coverage: AssessmentControlReferenceCoverageDocument {
19598                considered_item_count: 0,
19599                mapped_item_count: 0,
19600                unmapped_item_count: 0,
19601                relationship_count: 0,
19602                reference_link_count: 0,
19603                omitted_item_count: 0,
19604                omitted_reference_link_count: 0,
19605            },
19606            external_activity: AssessmentControlReferenceExternalActivityDocument {
19607                target_request_count: 0,
19608                provider_request_count: 0,
19609                source_retrieval: "not_performed",
19610            },
19611            claim_limits: AssessmentControlReferenceClaimLimitsDocument {
19612                control_assessment: "not_performed",
19613                compliance: "not_established",
19614                certification: "not_established",
19615                legal_conclusion: "not_established",
19616                source_authentication: "not_established",
19617            },
19618            relationships: Vec::new(),
19619        }
19620    }
19621
19622    #[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
19623    fn mapped_control_reference_document() -> AssessmentDocument<'static> {
19624        let mut document = observation_assessment_document("web.passive.csp.missing@1");
19625        document.items[0].fingerprint = "assessment-fingerprint-v1:csp-missing";
19626        let mut audit = selected_empty_control_reference_mapping_audit();
19627        audit.coverage.considered_item_count = 1;
19628        audit.coverage.mapped_item_count = 1;
19629        audit.coverage.relationship_count = 1;
19630        audit.coverage.reference_link_count = 1;
19631        audit.relationships.push(AssessmentControlReferenceRelationshipDocument {
19632            item_fingerprint: "assessment-fingerprint-v1:csp-missing".to_owned(),
19633            capability_id: "web.passive.csp.missing@1".to_owned(),
19634            cwe: None,
19635            item_basis: "observation",
19636            references: vec![AssessmentControlReferenceLinkDocument {
19637                rule_id: "termivar.mapping.csp-missing-to-owasp-a02-2025@1",
19638                source_id: "owasp-top-10-2025",
19639                framework_id: "owasp-top-10",
19640                edition: "2025",
19641                control_reference: "A02:2025",
19642                control_title: Some("Security Misconfiguration"),
19643                mapping_basis: "exact_capability_and_assessment_basis",
19644                applicability: "conditional_technical_context",
19645                applicability_condition:
19646                    "The mapped item is the exact complete-response CSP-missing observation.",
19647                assurance: "reviewed_primary_source_identifier",
19648                original_mapping_rationale: "The exact missing-policy observation is relevant to browser content-policy configuration review; it is not proof of executable injection or a control assessment.",
19649            }],
19650        });
19651        document.control_reference_mapping = Some(audit);
19652        document
19653    }
19654
19655    #[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
19656    #[test]
19657    fn selected_empty_control_reference_mapping_is_distinct_and_rendered_in_every_format() {
19658        let mut document = observation_assessment_document("unmapped.fixture-capability@1");
19659        document.items.clear();
19660        document.item_count = 0;
19661        document.control_reference_mapping = Some(selected_empty_control_reference_mapping_audit());
19662
19663        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
19664        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
19665        let audit = &value["control_reference_mapping"];
19666        assert_eq!(audit["selected"], true);
19667        assert_eq!(audit["coverage"]["considered_item_count"], 0);
19668        assert_eq!(audit["coverage"]["relationship_count"], 0);
19669        assert_eq!(audit["relationships"], serde_json::json!([]));
19670        assert_eq!(audit["external_activity"]["target_request_count"], 0);
19671        assert_eq!(audit["claim_limits"]["compliance"], "not_established");
19672
19673        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
19674        let markdown =
19675            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
19676        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
19677        assert!(html.contains("Versioned control-reference mapping audit"));
19678        assert!(markdown.contains("Versioned control-reference mapping audit"));
19679        assert!(csv.contains("control_reference_mapping_audit"));
19680    }
19681
19682    #[cfg(all(feature = "scanning", feature = "recon-snapshot-import"))]
19683    #[test]
19684    fn selected_empty_recon_snapshot_is_rendered_safely_in_every_format() {
19685        let snapshot = crate::recon_snapshot::parse_recon_snapshot(
19686            br#"{
19687                "schema":"security.recon-snapshot/v1",
19688                "snapshot_id":"synthetic-empty-snapshot",
19689                "snapshot_revision":"revision-1",
19690                "sources":[{
19691                    "source_id":"synthetic-source",
19692                    "namespace":"owned.fixture",
19693                    "revision":"source-r1",
19694                    "provider_time":null,
19695                    "observed_time":"2026-09-20T10:30:00Z",
19696                    "collection_method":"owned-offline-fixture",
19697                    "completeness":"unknown",
19698                    "origin":"</script><script>synthetic_recon_inert()</script>",
19699                    "rights":{"status":"permitted_as_declared","attribution":null,"notice":null}
19700                }],
19701                "records":[]
19702            }"#,
19703        )
19704        .unwrap();
19705        let mut document = observation_assessment_document("unmapped.fixture-capability@1");
19706        document.items.clear();
19707        document.item_count = 0;
19708        document.recon_snapshot_import =
19709            Some(AssessmentReconSnapshotImportAuditDocument::from_audit(&snapshot).unwrap());
19710
19711        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
19712        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
19713        assert_eq!(value["recon_snapshot_import"]["selected"], true);
19714        assert_eq!(
19715            value["recon_snapshot_import"]["records"],
19716            serde_json::json!([])
19717        );
19718        assert_eq!(
19719            value["recon_snapshot_import"]["claim_limits"]["scan_authority"],
19720            "not_granted"
19721        );
19722
19723        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
19724        let markdown =
19725            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
19726        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
19727        assert!(html.contains("Offline reconnaissance snapshot import"));
19728        assert!(markdown.contains("Offline reconnaissance snapshot import"));
19729        assert!(csv.contains("recon_snapshot_import_audit"));
19730        assert!(!html.contains("<script>synthetic_recon_inert()</script>"));
19731        let hostile_markdown_line = markdown
19732            .lines()
19733            .find(|line| line.contains("<script>synthetic_recon_inert()</script>"))
19734            .expect("the declared source remains visible in a Markdown code span");
19735        assert!(hostile_markdown_line.starts_with("- `"));
19736        assert!(hostile_markdown_line.ends_with('`'));
19737    }
19738
19739    #[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
19740    #[test]
19741    fn mapped_control_reference_output_preserves_item_identity_and_claim_limits() {
19742        let document = mapped_control_reference_document();
19743        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
19744        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
19745        let relationship = &value["control_reference_mapping"]["relationships"][0];
19746        assert_eq!(
19747            relationship["item_fingerprint"],
19748            value["items"][0]["fingerprint"]
19749        );
19750        assert_eq!(
19751            relationship["capability_id"],
19752            value["items"][0]["capability_id"]
19753        );
19754        assert_eq!(relationship["item_basis"], value["items"][0]["claim_basis"]);
19755        assert_eq!(
19756            relationship["references"][0]["control_reference"],
19757            "A02:2025"
19758        );
19759        assert_ne!(
19760            relationship["references"][0]["control_reference"],
19761            "A03:2021"
19762        );
19763        assert_eq!(
19764            value["control_reference_mapping"]["claim_limits"]["control_assessment"],
19765            "not_performed"
19766        );
19767        assert_eq!(
19768            value["control_reference_mapping"]["claim_limits"]["legal_conclusion"],
19769            "not_established"
19770        );
19771
19772        for format in [
19773            ReportFormat::Html,
19774            ReportFormat::Markdown,
19775            ReportFormat::Csv,
19776        ] {
19777            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
19778            assert!(rendered.contains("A02:2025"));
19779            assert!(rendered.contains("not_established"));
19780            assert!(!rendered.contains("A03:2021"));
19781        }
19782        for format in [ReportFormat::Html, ReportFormat::Markdown] {
19783            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
19784            assert!(rendered.contains("Security Misconfiguration"));
19785            assert!(rendered.contains("exact_capability_and_assessment_basis"));
19786            assert!(rendered.contains(
19787                "The mapped item is the exact complete-response CSP-missing observation."
19788            ));
19789            assert!(rendered.contains("source_verified_at=2026-09-20"));
19790        }
19791    }
19792
19793    #[cfg(all(feature = "scanning", feature = "control-reference-mapping"))]
19794    #[test]
19795    fn control_reference_mapping_writer_rejects_cross_link_rights_and_accounting_mutations() {
19796        let mut missing_item = mapped_control_reference_document();
19797        missing_item
19798            .control_reference_mapping
19799            .as_mut()
19800            .unwrap()
19801            .relationships[0]
19802            .item_fingerprint = "assessment-fingerprint-v1:substituted".to_owned();
19803        assert_eq!(
19804            render_assessment_with_limit(&missing_item, ReportFormat::Json, usize::MAX),
19805            Err(ReportError::Serialization)
19806        );
19807
19808        let mut false_activity = mapped_control_reference_document();
19809        false_activity
19810            .control_reference_mapping
19811            .as_mut()
19812            .unwrap()
19813            .external_activity
19814            .target_request_count = 1;
19815        assert_eq!(
19816            render_assessment_with_limit(&false_activity, ReportFormat::Json, usize::MAX),
19817            Err(ReportError::Serialization)
19818        );
19819
19820        let mut compliance_claim = mapped_control_reference_document();
19821        compliance_claim
19822            .control_reference_mapping
19823            .as_mut()
19824            .unwrap()
19825            .claim_limits
19826            .compliance = "established";
19827        assert_eq!(
19828            render_assessment_with_limit(&compliance_claim, ReportFormat::Json, usize::MAX),
19829            Err(ReportError::Serialization)
19830        );
19831
19832        let mut rights_deferred_link = mapped_control_reference_document();
19833        let reference = &mut rights_deferred_link
19834            .control_reference_mapping
19835            .as_mut()
19836            .unwrap()
19837            .relationships[0]
19838            .references[0];
19839        reference.source_id = "pci-dss-4.0.1";
19840        reference.framework_id = "pci-dss";
19841        reference.edition = "4.0.1";
19842        reference.control_reference = "requirement-not-retained";
19843        assert_eq!(
19844            render_assessment_with_limit(&rights_deferred_link, ReportFormat::Json, usize::MAX,),
19845            Err(ReportError::Serialization)
19846        );
19847
19848        let mut count_mismatch = mapped_control_reference_document();
19849        count_mismatch
19850            .control_reference_mapping
19851            .as_mut()
19852            .unwrap()
19853            .coverage
19854            .reference_link_count = 2;
19855        assert_eq!(
19856            render_assessment_with_limit(&count_mismatch, ReportFormat::Json, usize::MAX),
19857            Err(ReportError::Serialization)
19858        );
19859    }
19860
19861    #[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
19862    fn empty_secret_exposure_audit_document() -> AssessmentSecretExposureAuditDocument {
19863        AssessmentSecretExposureAuditDocument {
19864            schema: SECRET_EXPOSURE_AUDIT_SCHEMA,
19865            policy: SECRET_EXPOSURE_POLICY_ID,
19866            catalogue_id: SECRET_EXPOSURE_CATALOGUE_ID,
19867            catalogue_revision: SECRET_EXPOSURE_CATALOGUE_REVISION,
19868            representation: SECRET_EXPOSURE_REPRESENTATION,
19869            context: SECRET_EXPOSURE_CONTEXT,
19870            selected: true,
19871            additional_request_count: 0,
19872            response_count: 0,
19873            evaluated_response_count: 0,
19874            not_evaluated_response_count: 0,
19875            body_derived_projection_suppressed_response_count: 0,
19876            interpreted_byte_count: 0,
19877            per_response_byte_limit: u64::try_from(MAX_SECRET_EXPOSURE_BODY_BYTES).unwrap(),
19878            total_byte_limit: MAX_SECRET_EXPOSURE_TOTAL_BODY_BYTES,
19879            observation_count: 0,
19880            omitted_observation_count: 0,
19881            match_occurrence_count: 0,
19882            outcomes: Vec::new(),
19883            observations: Vec::new(),
19884            source_authentication: "not_established",
19885            secret_validity: "not_tested",
19886            provider_validation: "not_performed",
19887            exploit_execution: "not_performed",
19888            impact_validation: "not_performed",
19889            raw_values_retained: false,
19890            public_secret_hashes_retained: false,
19891        }
19892    }
19893
19894    #[cfg(all(feature = "scanning", feature = "tls-observation"))]
19895    fn tls_observation_audit_document() -> AssessmentTlsObservationAuditDocument {
19896        AssessmentTlsObservationAuditDocument {
19897            schema: TLS_OBSERVATION_AUDIT_SCHEMA,
19898            policy: TLS_OBSERVATION_POLICY_ID,
19899            selected: true,
19900            additional_request_count: 0,
19901            assessment_request_count: 2,
19902            target_scheme: "https",
19903            observation_source_scope: TLS_OBSERVATION_SOURCE_SCOPE,
19904            observation_clock_assurance: TLS_OBSERVATION_CLOCK_ASSURANCE,
19905            successful_https_response_count: 2,
19906            plaintext_response_count: 0,
19907            tls_info_unavailable_count: 0,
19908            malformed_certificate_count: 0,
19909            certificate_limit_rejection_count: 0,
19910            unretained_leaf_response_count: 0,
19911            leaf_observation_count: 1,
19912            protocol: TLS_OBSERVATION_BACKEND_LIMIT,
19913            cipher_suite: TLS_OBSERVATION_BACKEND_LIMIT,
19914            alpn_protocol: TLS_OBSERVATION_BACKEND_LIMIT,
19915            full_chain: TLS_OBSERVATION_BACKEND_LIMIT,
19916            connection_reuse: TLS_OBSERVATION_BACKEND_LIMIT,
19917            session_resumption: TLS_OBSERVATION_BACKEND_LIMIT,
19918            handshake_kind: TLS_OBSERVATION_BACKEND_LIMIT,
19919            revocation: TLS_OBSERVATION_REVOCATION_STATUS,
19920            transport_validation_scope: TLS_OBSERVATION_VALIDATION_SCOPE,
19921            active_tls_matrix: "not_performed",
19922            source_authentication: "not_established",
19923            observations: vec![AssessmentTlsLeafObservationDocument {
19924                leaf_certificate_sha256:
19925                    "abababababababababababababababababababababababababababababababab".to_owned(),
19926                leaf_certificate_byte_length: 768,
19927                not_before_epoch_seconds: 100,
19928                not_after_epoch_seconds: 300,
19929                observed_at_epoch_seconds: 200,
19930                dns_san_count: 2,
19931                ip_san_count: 1,
19932                other_san_count: 0,
19933                san_count_truncated: false,
19934                certificate_time_status: "valid_at_observation",
19935                response_occurrence_count: 2,
19936                standard_transport_validation_succeeded: true,
19937            }],
19938        }
19939    }
19940
19941    #[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
19942    fn tls_negotiation_review_audit_document() -> AssessmentTlsNegotiationReviewAuditDocument {
19943        AssessmentTlsNegotiationReviewAuditDocument {
19944            schema: TLS_NEGOTIATION_REVIEW_AUDIT_SCHEMA,
19945            policy: TLS_NEGOTIATION_REVIEW_POLICY_ID,
19946            selected: true,
19947            methodology: AssessmentTlsNegotiationMethodologyDocument {
19948                target_binding: "selected_https_target_exact_host_sni_port",
19949                address_selection: "single_deterministic_address_under_selected_authority",
19950                execution_order: "tls13_then_tls12_then_backend_unsupported_rows",
19951                maximum_in_flight_connections: 1,
19952                network_capable_cell_count: 2,
19953                matrix_cell_count: 5,
19954                retries: "none",
19955                redirects: "none",
19956                proxy: "not_used",
19957                application_data: "not_sent",
19958                credentials: "not_sent",
19959                client_certificate: "not_sent",
19960                alpn: "not_offered",
19961                session_resumption: "disabled",
19962                certificate_validation: "standard_configured_trust",
19963            },
19964            coverage: AssessmentTlsNegotiationCoverageDocument {
19965                terminal: "completed",
19966                connection_attempt_limit: 2,
19967                connection_in_flight_limit: 1,
19968                per_connection_ingress_byte_limit: 128 * 1_024,
19969                total_ingress_byte_limit: 256 * 1_024,
19970                per_connection_egress_byte_limit: 64 * 1_024,
19971                total_egress_byte_limit: 128 * 1_024,
19972                attempted_connection_count: 2,
19973                completed_handshake_count: 2,
19974                accounted_ingress_tls_bytes: 3_948,
19975                observed_egress_tls_bytes: 496,
19976                client_backend_unsupported_cell_count: 3,
19977            },
19978            capability_observations: vec![
19979                AssessmentTlsNegotiationCellDocument {
19980                    cell_id: "tls13_default_provider",
19981                    offered_protocol: "tls1.3",
19982                    client_backend: "rustls-ring",
19983                    dispatch_status: "dispatched",
19984                    outcome: "negotiated",
19985                    support: "observed_supported",
19986                    transport_validation: "succeeded",
19987                    negotiated_protocol: Some("tls1.3"),
19988                    negotiated_cipher_suite: Some("tls13_aes_256_gcm_sha384"),
19989                    accounted_ingress_tls_bytes: 2_048,
19990                    observed_egress_tls_bytes: 256,
19991                },
19992                AssessmentTlsNegotiationCellDocument {
19993                    cell_id: "tls12_default_provider",
19994                    offered_protocol: "tls1.2",
19995                    client_backend: "rustls-ring",
19996                    dispatch_status: "dispatched",
19997                    outcome: "negotiated",
19998                    support: "observed_supported",
19999                    transport_validation: "succeeded",
20000                    negotiated_protocol: Some("tls1.2"),
20001                    negotiated_cipher_suite: Some("tls12_ecdhe_rsa_aes_128_gcm_sha256"),
20002                    accounted_ingress_tls_bytes: 1_900,
20003                    observed_egress_tls_bytes: 240,
20004                },
20005                AssessmentTlsNegotiationCellDocument {
20006                    cell_id: "tls11_legacy_protocol",
20007                    offered_protocol: "tls1.1",
20008                    client_backend: "rustls-ring",
20009                    dispatch_status: "not_dispatched",
20010                    outcome: "client_backend_unsupported",
20011                    support: "not_tested_client_backend_unsupported",
20012                    transport_validation: "not_applicable",
20013                    negotiated_protocol: None,
20014                    negotiated_cipher_suite: None,
20015                    accounted_ingress_tls_bytes: 0,
20016                    observed_egress_tls_bytes: 0,
20017                },
20018                AssessmentTlsNegotiationCellDocument {
20019                    cell_id: "tls10_legacy_protocol",
20020                    offered_protocol: "tls1.0",
20021                    client_backend: "rustls-ring",
20022                    dispatch_status: "not_dispatched",
20023                    outcome: "client_backend_unsupported",
20024                    support: "not_tested_client_backend_unsupported",
20025                    transport_validation: "not_applicable",
20026                    negotiated_protocol: None,
20027                    negotiated_cipher_suite: None,
20028                    accounted_ingress_tls_bytes: 0,
20029                    observed_egress_tls_bytes: 0,
20030                },
20031                AssessmentTlsNegotiationCellDocument {
20032                    cell_id: "legacy_cipher_suites",
20033                    offered_protocol: "legacy_cipher_suites",
20034                    client_backend: "rustls-ring",
20035                    dispatch_status: "not_dispatched",
20036                    outcome: "client_backend_unsupported",
20037                    support: "not_tested_client_backend_unsupported",
20038                    transport_validation: "not_applicable",
20039                    negotiated_protocol: None,
20040                    negotiated_cipher_suite: None,
20041                    accounted_ingress_tls_bytes: 0,
20042                    observed_egress_tls_bytes: 0,
20043                },
20044            ],
20045            claim_limits: TLS_NEGOTIATION_REVIEW_CLAIM_LIMITS,
20046        }
20047    }
20048
20049    #[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
20050    fn jwt_policy_review_audit_document() -> AssessmentJwtPolicyReviewAuditDocument {
20051        AssessmentJwtPolicyReviewAuditDocument {
20052            schema: JWT_POLICY_REVIEW_AUDIT_SCHEMA,
20053            policy: JWT_POLICY_REVIEW_POLICY_ID,
20054            selected: true,
20055            parsing_status: "parsed",
20056            parsing_rejection: None,
20057            policy_status: "consistent",
20058            local_signature_status: "verified",
20059            target_acceptance_status: "not_performed",
20060            methodology: AssessmentJwtPolicyMethodologyDocument {
20061                algorithm: JWT_POLICY_ALGORITHM,
20062                representation: JWT_POLICY_REPRESENTATION,
20063                local_key_source: JWT_POLICY_LOCAL_KEY_SOURCE,
20064                key_source_assurance: JWT_POLICY_KEY_SOURCE_ASSURANCE,
20065                token_carried_key_selection: JWT_POLICY_TOKEN_KEY_SELECTION,
20066                operator_policy_reference: "synthetic-policy".to_owned(),
20067                operator_policy_revision: "synthetic-policy-v1".to_owned(),
20068                local_public_key_sha256: format!("sha256:{}", "a".repeat(64)),
20069                expected_type_selected: true,
20070                expected_issuer_selected: true,
20071                expected_audience_selected: true,
20072                required_claim_count: 2,
20073                require_expiration: true,
20074                allowed_clock_skew_seconds: 30,
20075                evaluation_time_unix_seconds: 1_700_000_000,
20076                clock_assurance: JWT_POLICY_CLOCK_ASSURANCE,
20077            },
20078            external_activity: AssessmentJwtExternalActivityDocument {
20079                target_request_count: 0,
20080                remote_key_retrieval: JWT_POLICY_EXTERNAL_NOT_PERFORMED,
20081                token_forwarding: JWT_POLICY_EXTERNAL_NOT_PERFORMED,
20082            },
20083            policy_violations: Vec::new(),
20084            source_authentication: JWT_POLICY_SOURCE_AUTHENTICATION,
20085            #[cfg(feature = "jwt-target-acceptance-review")]
20086            preparation_reference: None,
20087            #[cfg(feature = "jwt-target-acceptance-review")]
20088            target_acceptance: None,
20089        }
20090    }
20091
20092    #[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
20093    fn jwt_target_policy_review_audit_document() -> AssessmentJwtPolicyReviewAuditDocument {
20094        let policy = crate::jwt_target_acceptance::JwtTargetAcceptancePolicy::new(
20095            ("synthetic-target-policy", "synthetic-target-v1"),
20096            url::Url::parse("https://owned.invalid/app/").unwrap(),
20097            url::Url::parse("https://owned.invalid/app/canary").unwrap(),
20098            "protected-canary",
20099            "private-marker-sentinel",
20100        )
20101        .unwrap();
20102        let markers = [
20103            JwtTargetAcceptanceMarkerStatus::Observed,
20104            JwtTargetAcceptanceMarkerStatus::NotObserved,
20105            JwtTargetAcceptanceMarkerStatus::Observed,
20106            JwtTargetAcceptanceMarkerStatus::Observed,
20107            JwtTargetAcceptanceMarkerStatus::NotObserved,
20108            JwtTargetAcceptanceMarkerStatus::Observed,
20109        ];
20110        let legs = JwtTargetAcceptanceLegRole::ORDERED
20111            .into_iter()
20112            .zip(markers)
20113            .enumerate()
20114            .map(|(index, (role, marker))| {
20115                let retained_response_bytes = u64::try_from(index + 101).unwrap();
20116                let accounted_transport_response_bytes = if index == 0 {
20117                    70_000
20118                } else {
20119                    retained_response_bytes
20120                };
20121                crate::jwt_target_acceptance::JwtTargetAcceptanceLegFact::new_with_transport_accounting(
20122                    role,
20123                    JwtTargetAcceptanceDispatchStatus::Dispatched,
20124                    JwtTargetAcceptanceCommitStatus::Committed,
20125                    JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
20126                    marker,
20127                    retained_response_bytes,
20128                    accounted_transport_response_bytes,
20129                    Some(format!("jwt-target-leg:{:064x}", index + 1)),
20130                )
20131                .unwrap()
20132            })
20133            .collect();
20134        let preparation_binding =
20135            crate::jwt_target_acceptance::JwtTargetAcceptanceBinding::synthetic_for_test(0x5A);
20136        let target = JwtTargetAcceptanceAudit::from_legs(&policy, legs)
20137            .unwrap()
20138            .bind_to_preparation(preparation_binding.clone());
20139        let target = AssessmentJwtTargetAcceptanceAuditDocument::from_audit(&target).unwrap();
20140        let mut document = jwt_policy_review_audit_document();
20141        document.schema = JWT_POLICY_REVIEW_AUDIT_SCHEMA_V2;
20142        document.target_acceptance_status = target.status;
20143        document.external_activity.target_request_count =
20144            target.accounting.dispatched_request_count;
20145        document.external_activity.token_forwarding = "performed";
20146        document.preparation_reference = Some(preparation_binding.reference());
20147        document.target_acceptance = Some(target);
20148        document
20149    }
20150
20151    #[cfg(all(feature = "scanning", feature = "authorization-review"))]
20152    #[test]
20153    fn positive_authorization_audit_requires_all_three_relational_proofs() {
20154        let document = observation_assessment_document(RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID);
20155        let valid = AssessmentAuthorizationAuditDocument {
20156            schema: "security.authorization-review-audit/v1",
20157            capability_id: RESOURCE_AUTHORIZATION_REVIEW_CAPABILITY_ID,
20158            policy_id: format!("authorization-policy-sha256:{}", "a".repeat(64)),
20159            selected_path_count: 1,
20160            ignored_path_count: 0,
20161            request_count: 4,
20162            outcome: "stable_cross_principal_equivalence",
20163            primary_stable: Some(true),
20164            peer_stable: Some(true),
20165            cross_resources_equivalent: Some(true),
20166            item_projected: true,
20167        };
20168        assert_eq!(valid.validate(&document.items), Ok(()));
20169
20170        for (primary, peer, cross) in [
20171            (None, Some(true), Some(true)),
20172            (Some(false), Some(true), Some(true)),
20173            (Some(true), None, Some(true)),
20174            (Some(true), Some(false), Some(true)),
20175            (Some(true), Some(true), None),
20176            (Some(true), Some(true), Some(false)),
20177        ] {
20178            let invalid = AssessmentAuthorizationAuditDocument {
20179                primary_stable: primary,
20180                peer_stable: peer,
20181                cross_resources_equivalent: cross,
20182                ..AssessmentAuthorizationAuditDocument {
20183                    schema: valid.schema,
20184                    capability_id: valid.capability_id,
20185                    policy_id: valid.policy_id.clone(),
20186                    selected_path_count: valid.selected_path_count,
20187                    ignored_path_count: valid.ignored_path_count,
20188                    request_count: valid.request_count,
20189                    outcome: valid.outcome,
20190                    primary_stable: valid.primary_stable,
20191                    peer_stable: valid.peer_stable,
20192                    cross_resources_equivalent: valid.cross_resources_equivalent,
20193                    item_projected: valid.item_projected,
20194                }
20195            };
20196            assert_eq!(
20197                invalid.validate(&document.items),
20198                Err(ReportError::Serialization)
20199            );
20200        }
20201    }
20202
20203    #[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
20204    #[test]
20205    fn jwt_policy_review_audit_is_value_free_and_rendered_in_every_format() {
20206        let mut document = observation_assessment_document("unrelated.observation@1");
20207        document.jwt_policy_review = Some(jwt_policy_review_audit_document());
20208
20209        for format in [
20210            ReportFormat::Json,
20211            ReportFormat::Csv,
20212            ReportFormat::Html,
20213            ReportFormat::Markdown,
20214        ] {
20215            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
20216            assert!(rendered.contains("security.jwt-policy-review-audit/v1"));
20217            assert!(rendered.contains("termivar.jwt-local-policy/es256-v1"));
20218            assert!(rendered.contains("explicit_operator_supplied_public_jwk"));
20219            assert!(rendered.contains("operator_supplied_not_authenticated"));
20220            assert!(rendered.contains("not_performed"));
20221            assert!(!rendered.contains("synthetic-secret-claim-value"));
20222            assert!(!rendered.contains("BEGIN PRIVATE KEY"));
20223        }
20224
20225        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
20226        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
20227        let audit = &value["jwt_policy_review"];
20228        assert_eq!(audit["parsing_status"], "parsed");
20229        assert_eq!(audit["policy_status"], "consistent");
20230        assert_eq!(audit["local_signature_status"], "verified");
20231        assert_eq!(audit["target_acceptance_status"], "not_performed");
20232        assert_eq!(audit["external_activity"]["target_request_count"], 0);
20233        assert_eq!(audit["policy_violations"], serde_json::json!([]));
20234        assert!(audit.get("parsing_rejection").is_none());
20235        assert!(audit.get("raw_token").is_none());
20236        assert!(audit.get("claims").is_none());
20237        assert!(audit.get("verification_key").is_none());
20238
20239        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
20240        let rows: Vec<Vec<String>> = csv.lines().map(parse_csv_line).collect();
20241        let headers = &rows[0];
20242        let column = |name: &str| headers.iter().position(|header| header == name).unwrap();
20243        let audit_row = rows
20244            .iter()
20245            .find(|row| row[column("record_type")] == "jwt_policy_review_audit")
20246            .unwrap();
20247        assert_eq!(audit_row[column("evidence_count")], "0");
20248    }
20249
20250    #[cfg(all(feature = "scanning", feature = "jwt-policy-review"))]
20251    #[test]
20252    fn jwt_policy_review_writer_rejects_boundary_and_state_mutations() {
20253        let mut document = observation_assessment_document("unrelated.observation@1");
20254        document.jwt_policy_review = Some(jwt_policy_review_audit_document());
20255        assert!(render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).is_ok());
20256
20257        document
20258            .jwt_policy_review
20259            .as_mut()
20260            .unwrap()
20261            .methodology
20262            .algorithm = "none";
20263        assert_eq!(
20264            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20265            Err(ReportError::Serialization)
20266        );
20267        document
20268            .jwt_policy_review
20269            .as_mut()
20270            .unwrap()
20271            .methodology
20272            .algorithm = JWT_POLICY_ALGORITHM;
20273        document
20274            .jwt_policy_review
20275            .as_mut()
20276            .unwrap()
20277            .methodology
20278            .expected_type_selected = false;
20279        assert_eq!(
20280            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20281            Err(ReportError::Serialization)
20282        );
20283        document
20284            .jwt_policy_review
20285            .as_mut()
20286            .unwrap()
20287            .methodology
20288            .expected_type_selected = true;
20289        document
20290            .jwt_policy_review
20291            .as_mut()
20292            .unwrap()
20293            .methodology
20294            .local_public_key_sha256 = "sha256:NOT-HEX".to_owned();
20295        assert_eq!(
20296            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20297            Err(ReportError::Serialization)
20298        );
20299        document
20300            .jwt_policy_review
20301            .as_mut()
20302            .unwrap()
20303            .methodology
20304            .local_public_key_sha256 = format!("sha256:{}", "a".repeat(64));
20305        document
20306            .jwt_policy_review
20307            .as_mut()
20308            .unwrap()
20309            .external_activity
20310            .target_request_count = 1;
20311        assert_eq!(
20312            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20313            Err(ReportError::Serialization)
20314        );
20315        let audit = document.jwt_policy_review.as_mut().unwrap();
20316        audit.external_activity.target_request_count = 0;
20317        audit.policy_status = "inconsistent";
20318        assert_eq!(
20319            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20320            Err(ReportError::Serialization)
20321        );
20322        let audit = document.jwt_policy_review.as_mut().unwrap();
20323        audit.policy_violations = vec![AssessmentJwtPolicyViolationDocument {
20324            kind: "missing_required_claim",
20325            ordinal: Some(1),
20326        }];
20327        assert!(render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).is_ok());
20328        document
20329            .jwt_policy_review
20330            .as_mut()
20331            .unwrap()
20332            .policy_violations
20333            .push(AssessmentJwtPolicyViolationDocument {
20334                kind: "missing_required_claim",
20335                ordinal: Some(1),
20336            });
20337        assert_eq!(
20338            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20339            Err(ReportError::Serialization)
20340        );
20341    }
20342
20343    #[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
20344    #[test]
20345    fn jwt_target_selected_writer_is_strict_value_free_and_preserves_transport_accounting() {
20346        let mut document = observation_assessment_document("unrelated.observation@1");
20347        document.jwt_policy_review = Some(jwt_target_policy_review_audit_document());
20348
20349        for format in [
20350            ReportFormat::Json,
20351            ReportFormat::Csv,
20352            ReportFormat::Html,
20353            ReportFormat::Markdown,
20354        ] {
20355            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
20356            assert!(rendered.contains("security.jwt-policy-review-audit/v2"));
20357            assert!(rendered
20358                .contains("invalid_signature_control_marker_observed_with_anonymous_control"));
20359            assert!(!rendered.contains("private-marker-sentinel"));
20360            assert!(!rendered.contains("https://owned.invalid"));
20361        }
20362
20363        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
20364        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
20365        let target = &value["jwt_policy_review"]["target_acceptance"];
20366        assert_eq!(target["accounting"]["retained_response_bytes"], 621);
20367        assert_eq!(
20368            target["accounting"]["accounted_transport_response_bytes"],
20369            70_520
20370        );
20371        assert_eq!(target["legs"][0]["retained_response_bytes"], 101);
20372        assert_eq!(
20373            target["legs"][0]["accounted_transport_response_bytes"],
20374            70_000
20375        );
20376        assert_eq!(target["legs"].as_array().unwrap().len(), 6);
20377
20378        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
20379        let rows: Vec<Vec<String>> = csv.lines().map(parse_csv_line).collect();
20380        let headers = &rows[0];
20381        let column = |name: &str| headers.iter().position(|header| header == name).unwrap();
20382        let audit_row = rows
20383            .iter()
20384            .find(|row| row[column("record_type")] == "jwt_policy_review_audit")
20385            .unwrap();
20386        assert_eq!(audit_row[column("evidence_count")], "6");
20387
20388        let mut cancelled_document = observation_assessment_document("unrelated.observation@1");
20389        let mut cancelled = jwt_target_policy_review_audit_document();
20390        cancelled.target_acceptance_status = "not_performed";
20391        cancelled.external_activity.target_request_count = 0;
20392        cancelled.external_activity.token_forwarding = JWT_POLICY_EXTERNAL_NOT_PERFORMED;
20393        let cancelled_target = cancelled.target_acceptance.as_mut().unwrap();
20394        cancelled_target.status = "not_performed";
20395        cancelled_target.accounting.dispatched_request_count = 0;
20396        cancelled_target.accounting.dispatched_passive_request_count = 0;
20397        cancelled_target.accounting.dispatched_active_request_count = 0;
20398        cancelled_target.accounting.committed_response_count = 0;
20399        cancelled_target.accounting.retained_response_bytes = 0;
20400        cancelled_target
20401            .accounting
20402            .accounted_transport_response_bytes = 0;
20403        cancelled_target.dimensions.valid_marker = "not_evaluated";
20404        cancelled_target.dimensions.anonymous_marker = "not_evaluated";
20405        cancelled_target.dimensions.invalid_marker = "not_evaluated";
20406        cancelled_target.conclusion = AssessmentJwtTargetAcceptanceConclusionDocument {
20407            kind: "incomplete",
20408            incomplete_reason: Some(AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
20409                kind: "leg_not_dispatched",
20410                role: Some("valid_candidate"),
20411                not_eligible_reason: None,
20412            }),
20413        };
20414        for leg in &mut cancelled_target.legs {
20415            leg.dispatch_status = "not_dispatched";
20416            leg.commit_status = "not_committed";
20417            leg.response_status = "not_classified";
20418            leg.marker_status = "not_evaluated";
20419            leg.retained_response_bytes = 0;
20420            leg.accounted_transport_response_bytes = 0;
20421            leg.evidence_reference = None;
20422        }
20423        cancelled_document.jwt_policy_review = Some(cancelled);
20424        assert!(
20425            render_assessment_with_limit(&cancelled_document, ReportFormat::Json, usize::MAX,)
20426                .is_ok()
20427        );
20428        let cancelled_csv =
20429            render_assessment_with_limit(&cancelled_document, ReportFormat::Csv, usize::MAX)
20430                .unwrap();
20431        let cancelled_rows: Vec<Vec<String>> = cancelled_csv.lines().map(parse_csv_line).collect();
20432        let cancelled_headers = &cancelled_rows[0];
20433        let cancelled_column = |name: &str| {
20434            cancelled_headers
20435                .iter()
20436                .position(|header| header == name)
20437                .unwrap()
20438        };
20439        let cancelled_audit_row = cancelled_rows
20440            .iter()
20441            .find(|row| row[cancelled_column("record_type")] == "jwt_policy_review_audit")
20442            .unwrap();
20443        assert_eq!(cancelled_audit_row[cancelled_column("evidence_count")], "0");
20444        cancelled_document
20445            .jwt_policy_review
20446            .as_mut()
20447            .unwrap()
20448            .local_signature_status = "invalid";
20449        assert_eq!(
20450            render_assessment_with_limit(&cancelled_document, ReportFormat::Json, usize::MAX,),
20451            Err(ReportError::Serialization)
20452        );
20453
20454        let audit = document.jwt_policy_review.as_mut().unwrap();
20455        audit.local_signature_status = "invalid";
20456        assert_eq!(
20457            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20458            Err(ReportError::Serialization)
20459        );
20460        let audit = document.jwt_policy_review.as_mut().unwrap();
20461        audit.local_signature_status = "verified";
20462        audit
20463            .target_acceptance
20464            .as_mut()
20465            .unwrap()
20466            .accounting
20467            .committed_response_count = 5;
20468        assert_eq!(
20469            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20470            Err(ReportError::Serialization)
20471        );
20472    }
20473
20474    #[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
20475    #[test]
20476    fn jwt_target_human_renderers_preserve_value_free_incomplete_reasons() {
20477        for (reason, other_reason) in [
20478            (
20479                "runtime_authority_unavailable",
20480                "request_budget_unavailable",
20481            ),
20482            (
20483                "request_budget_unavailable",
20484                "runtime_authority_unavailable",
20485            ),
20486        ] {
20487            let mut document = observation_assessment_document("unrelated.observation@1");
20488            let mut audit = jwt_target_policy_review_audit_document();
20489            audit.target_acceptance_status = "not_performed";
20490            audit.external_activity.target_request_count = 0;
20491            audit.external_activity.token_forwarding = JWT_POLICY_EXTERNAL_NOT_PERFORMED;
20492            let target = audit.target_acceptance.as_mut().unwrap();
20493            target.status = "not_performed";
20494            target.accounting.dispatched_request_count = 0;
20495            target.accounting.dispatched_passive_request_count = 0;
20496            target.accounting.dispatched_active_request_count = 0;
20497            target.accounting.committed_response_count = 0;
20498            target.accounting.retained_response_bytes = 0;
20499            target.accounting.accounted_transport_response_bytes = 0;
20500            target.dimensions.valid_marker = "not_evaluated";
20501            target.dimensions.anonymous_marker = "not_evaluated";
20502            target.dimensions.invalid_marker = "not_evaluated";
20503            target.legs.clear();
20504            target.conclusion = AssessmentJwtTargetAcceptanceConclusionDocument {
20505                kind: "incomplete",
20506                incomplete_reason: Some(AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
20507                    kind: "not_eligible",
20508                    role: None,
20509                    not_eligible_reason: Some(reason),
20510                }),
20511            };
20512            document.jwt_policy_review = Some(audit);
20513
20514            let expected = format!("kind=not_eligible;not_eligible_reason={reason}");
20515            for format in [ReportFormat::Html, ReportFormat::Markdown] {
20516                let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
20517                assert!(rendered.contains("Target incomplete reason"));
20518                assert!(rendered.contains(&expected));
20519                assert!(!rendered.contains(other_reason));
20520                assert!(!rendered.contains("private-marker-sentinel"));
20521                assert!(!rendered.contains("https://owned.invalid"));
20522            }
20523        }
20524
20525        let mut document = observation_assessment_document("unrelated.observation@1");
20526        let mut audit = jwt_target_policy_review_audit_document();
20527        audit.target_acceptance_status = "not_performed";
20528        audit.external_activity.target_request_count = 0;
20529        audit.external_activity.token_forwarding = JWT_POLICY_EXTERNAL_NOT_PERFORMED;
20530        let target = audit.target_acceptance.as_mut().unwrap();
20531        target.status = "not_performed";
20532        target.accounting.dispatched_request_count = 0;
20533        target.accounting.dispatched_passive_request_count = 0;
20534        target.accounting.dispatched_active_request_count = 0;
20535        target.accounting.committed_response_count = 0;
20536        target.accounting.retained_response_bytes = 0;
20537        target.accounting.accounted_transport_response_bytes = 0;
20538        target.dimensions.valid_marker = "not_evaluated";
20539        target.dimensions.anonymous_marker = "not_evaluated";
20540        target.dimensions.invalid_marker = "not_evaluated";
20541        target.conclusion = AssessmentJwtTargetAcceptanceConclusionDocument {
20542            kind: "incomplete",
20543            incomplete_reason: Some(AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
20544                kind: "leg_not_dispatched",
20545                role: Some("valid_candidate"),
20546                not_eligible_reason: None,
20547            }),
20548        };
20549        for leg in &mut target.legs {
20550            leg.dispatch_status = "not_dispatched";
20551            leg.commit_status = "not_committed";
20552            leg.response_status = "not_classified";
20553            leg.marker_status = "not_evaluated";
20554            leg.retained_response_bytes = 0;
20555            leg.accounted_transport_response_bytes = 0;
20556            leg.evidence_reference = None;
20557        }
20558        document.jwt_policy_review = Some(audit);
20559        for format in [ReportFormat::Html, ReportFormat::Markdown] {
20560            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
20561            assert!(rendered.contains("kind=leg_not_dispatched;role=valid_candidate"));
20562            assert!(!rendered.contains("private-marker-sentinel"));
20563            assert!(!rendered.contains("https://owned.invalid"));
20564        }
20565    }
20566
20567    #[cfg(all(feature = "scanning", feature = "jwt-target-acceptance-review"))]
20568    #[test]
20569    fn jwt_target_writer_rejects_binding_and_derived_state_mutations() {
20570        let render = |audit: AssessmentJwtPolicyReviewAuditDocument| {
20571            let mut document = observation_assessment_document("unrelated.observation@1");
20572            document.jwt_policy_review = Some(audit);
20573            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX)
20574        };
20575
20576        let mut mismatched_binding = jwt_target_policy_review_audit_document();
20577        mismatched_binding.preparation_reference =
20578            Some(format!("jwt-target-preparation:{}", "b".repeat(64)));
20579        assert_eq!(render(mismatched_binding), Err(ReportError::Serialization));
20580
20581        let mut invalid_dimension = jwt_target_policy_review_audit_document();
20582        invalid_dimension
20583            .target_acceptance
20584            .as_mut()
20585            .unwrap()
20586            .dimensions
20587            .invalid_marker = "not_observed_stable";
20588        assert_eq!(render(invalid_dimension), Err(ReportError::Serialization));
20589
20590        let mut invalid_conclusion = jwt_target_policy_review_audit_document();
20591        invalid_conclusion
20592            .target_acceptance
20593            .as_mut()
20594            .unwrap()
20595            .conclusion = AssessmentJwtTargetAcceptanceConclusionDocument {
20596            kind: "inconclusive",
20597            incomplete_reason: None,
20598        };
20599        assert_eq!(render(invalid_conclusion), Err(ReportError::Serialization));
20600
20601        let mut post_stop_dispatch = jwt_target_policy_review_audit_document();
20602        let target = post_stop_dispatch.target_acceptance.as_mut().unwrap();
20603        let first = &mut target.legs[0];
20604        first.commit_status = "not_committed";
20605        first.response_status = "incomplete";
20606        first.marker_status = "not_evaluated";
20607        first.evidence_reference = None;
20608        target.accounting.committed_response_count -= 1;
20609        assert_eq!(render(post_stop_dispatch), Err(ReportError::Serialization));
20610
20611        let mut empty = jwt_target_policy_review_audit_document()
20612            .target_acceptance
20613            .take()
20614            .unwrap();
20615        empty.status = "not_performed";
20616        empty.accounting.dispatched_request_count = 0;
20617        empty.accounting.dispatched_passive_request_count = 0;
20618        empty.accounting.dispatched_active_request_count = 0;
20619        empty.accounting.committed_response_count = 0;
20620        empty.accounting.retained_response_bytes = 0;
20621        empty.accounting.accounted_transport_response_bytes = 0;
20622        empty.dimensions.valid_marker = "not_evaluated";
20623        empty.dimensions.anonymous_marker = "not_evaluated";
20624        empty.dimensions.invalid_marker = "not_evaluated";
20625        empty.legs.clear();
20626        empty.conclusion = AssessmentJwtTargetAcceptanceConclusionDocument {
20627            kind: "incomplete",
20628            incomplete_reason: Some(AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
20629                kind: "not_eligible",
20630                role: None,
20631                not_eligible_reason: Some("local_signature_not_established"),
20632            }),
20633        };
20634        assert!(empty.validate().is_ok());
20635        empty.conclusion = AssessmentJwtTargetAcceptanceConclusionDocument {
20636            kind: "incomplete",
20637            incomplete_reason: Some(AssessmentJwtTargetAcceptanceIncompleteReasonDocument {
20638                kind: "leg_not_dispatched",
20639                role: Some("valid_candidate"),
20640                not_eligible_reason: None,
20641            }),
20642        };
20643        assert_eq!(empty.validate(), Err(ReportError::Serialization));
20644    }
20645
20646    #[cfg(all(feature = "scanning", feature = "tls-observation"))]
20647    #[test]
20648    fn tls_observation_audit_is_rendered_honestly_in_every_format() {
20649        let mut document = observation_assessment_document("unrelated.observation@1");
20650        document.tls_observation = Some(tls_observation_audit_document());
20651
20652        for format in [
20653            ReportFormat::Json,
20654            ReportFormat::Csv,
20655            ReportFormat::Html,
20656            ReportFormat::Markdown,
20657        ] {
20658            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
20659            assert!(rendered.contains("security.tls-observation-audit/v1"));
20660            assert!(rendered.contains("termivar.existing-connection-tls-observation/v1"));
20661            assert!(rendered.contains("not_exposed_by_backend"));
20662            assert!(rendered.contains("not_checked"));
20663            assert!(rendered.contains("not_established"));
20664            assert!(rendered
20665                .contains("abababababababababababababababababababababababababababababababab"));
20666            assert!(!rendered.contains("certificate.example.test"));
20667            assert!(!rendered.contains("BEGIN CERTIFICATE"));
20668        }
20669
20670        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
20671        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
20672        let audit = &value["tls_observation"];
20673        assert_eq!(audit["additional_request_count"], 0);
20674        assert_eq!(audit["assessment_request_count"], 2);
20675        assert_eq!(
20676            audit["observation_source_scope"],
20677            "assessment_exact_origin_existing_connections/v1"
20678        );
20679        assert_eq!(
20680            audit["observation_clock_assurance"],
20681            "local_system_clock_not_independently_verified"
20682        );
20683        assert_eq!(audit["leaf_observation_count"], 1);
20684        assert_eq!(audit["alpn_protocol"], "not_exposed_by_backend");
20685        assert_eq!(audit["session_resumption"], "not_exposed_by_backend");
20686        assert_eq!(audit["active_tls_matrix"], "not_performed");
20687        assert_eq!(
20688            audit["observations"][0]["standard_transport_validation_succeeded"],
20689            true
20690        );
20691    }
20692
20693    #[cfg(all(feature = "scanning", feature = "tls-observation"))]
20694    #[test]
20695    fn tls_observation_writer_rejects_accounting_and_claim_mutations() {
20696        let mut document = observation_assessment_document("unrelated.observation@1");
20697        document.tls_observation = Some(tls_observation_audit_document());
20698        assert!(render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).is_ok());
20699
20700        document
20701            .tls_observation
20702            .as_mut()
20703            .unwrap()
20704            .observation_source_scope = "unscoped";
20705        assert_eq!(
20706            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20707            Err(ReportError::Serialization)
20708        );
20709        document
20710            .tls_observation
20711            .as_mut()
20712            .unwrap()
20713            .observation_source_scope = TLS_OBSERVATION_SOURCE_SCOPE;
20714        document
20715            .tls_observation
20716            .as_mut()
20717            .unwrap()
20718            .observation_clock_assurance = "verified";
20719        assert_eq!(
20720            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20721            Err(ReportError::Serialization)
20722        );
20723        document
20724            .tls_observation
20725            .as_mut()
20726            .unwrap()
20727            .observation_clock_assurance = TLS_OBSERVATION_CLOCK_ASSURANCE;
20728
20729        document
20730            .tls_observation
20731            .as_mut()
20732            .unwrap()
20733            .successful_https_response_count = 1;
20734        assert_eq!(
20735            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20736            Err(ReportError::Serialization)
20737        );
20738        document
20739            .tls_observation
20740            .as_mut()
20741            .unwrap()
20742            .successful_https_response_count = 2;
20743        document
20744            .tls_observation
20745            .as_mut()
20746            .unwrap()
20747            .assessment_request_count = 1;
20748        assert_eq!(
20749            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20750            Err(ReportError::Serialization)
20751        );
20752        document
20753            .tls_observation
20754            .as_mut()
20755            .unwrap()
20756            .assessment_request_count = 2;
20757        document.tls_observation.as_mut().unwrap().alpn_protocol = "h2";
20758        assert_eq!(
20759            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20760            Err(ReportError::Serialization)
20761        );
20762        let audit = document.tls_observation.as_mut().unwrap();
20763        audit.alpn_protocol = TLS_OBSERVATION_BACKEND_LIMIT;
20764        audit.observations[0].san_count_truncated = true;
20765        assert_eq!(
20766            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX),
20767            Err(ReportError::Serialization)
20768        );
20769    }
20770
20771    #[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
20772    #[test]
20773    fn tls_negotiation_review_audit_is_rendered_honestly_in_every_format() {
20774        // This test also sends the rendered document through the strict saved-
20775        // report reader. The generic renderer fixture reuses this literal for
20776        // every item string, including `fingerprint`, so use an independently
20777        // valid digest rather than a display-only capability label.
20778        let mut document = observation_assessment_document(concat!(
20779            "sha256:",
20780            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
20781        ));
20782        #[cfg(feature = "tls-observation")]
20783        {
20784            document.tls_observation = Some(tls_observation_audit_document());
20785        }
20786        document.tls_negotiation_review = Some(tls_negotiation_review_audit_document());
20787
20788        for format in [
20789            ReportFormat::Json,
20790            ReportFormat::Csv,
20791            ReportFormat::Html,
20792            ReportFormat::Markdown,
20793        ] {
20794            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
20795            assert!(rendered.contains("security.tls-negotiation-review-audit/v1"));
20796            assert!(rendered.contains("termivar.active-tls-negotiation-review/v1"));
20797            assert!(rendered.contains("tls13_default_provider"));
20798            assert!(rendered.contains("tls12_default_provider"));
20799            assert!(rendered.contains("client_backend_unsupported"));
20800            assert!(rendered.contains("finite_matrix_not_complete_server_capability_enumeration"));
20801            assert!(!rendered.contains("owned-tls.private.invalid"));
20802            assert!(!rendered.contains("BEGIN CERTIFICATE"));
20803            assert!(!rendered.contains("Authorization: Bearer"));
20804        }
20805
20806        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
20807        comparison::import_assessment_summary(json.as_bytes()).unwrap();
20808        let self_comparison = comparison::compare_reports(
20809            json.as_bytes(),
20810            json.as_bytes(),
20811            comparison::ComparisonFormat::Json,
20812        )
20813        .unwrap();
20814        let self_comparison: serde_json::Value = serde_json::from_str(&self_comparison).unwrap();
20815        assert_eq!(
20816            self_comparison["tls_negotiation_review_comparison"]["status"],
20817            "compared"
20818        );
20819        for facet in ["methodology", "coverage", "capability_observations"] {
20820            assert_eq!(
20821                self_comparison["tls_negotiation_review_comparison"][facet]["status"],
20822                "unchanged"
20823            );
20824        }
20825        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
20826        let audit = &value["tls_negotiation_review"];
20827        assert_eq!(audit["selected"], true);
20828        assert_eq!(audit["coverage"]["terminal"], "completed");
20829        assert_eq!(audit["coverage"]["attempted_connection_count"], 2);
20830        assert_eq!(audit["coverage"]["completed_handshake_count"], 2);
20831        assert_eq!(
20832            audit["capability_observations"].as_array().unwrap().len(),
20833            5
20834        );
20835        assert_eq!(
20836            audit["capability_observations"][0]["negotiated_protocol"],
20837            "tls1.3"
20838        );
20839        assert_eq!(
20840            audit["capability_observations"][1]["negotiated_protocol"],
20841            "tls1.2"
20842        );
20843        assert_eq!(
20844            audit["capability_observations"][2]["support"],
20845            "not_tested_client_backend_unsupported"
20846        );
20847        assert!(audit.get("host").is_none());
20848        assert!(audit.get("resolved_address").is_none());
20849        assert!(audit.get("certificate").is_none());
20850        assert!(audit.get("error").is_none());
20851        #[cfg(feature = "tls-observation")]
20852        assert_eq!(
20853            value["tls_observation"]["active_tls_matrix"],
20854            "not_performed"
20855        );
20856
20857        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
20858        let rows: Vec<Vec<String>> = csv.lines().map(parse_csv_line).collect();
20859        assert!(rows
20860            .iter()
20861            .all(|row| row.len() == ASSESSMENT_CSV_HEADERS.len()));
20862        assert!(rows.iter().any(|row| row
20863            .first()
20864            .is_some_and(|cell| cell == "tls_negotiation_review_audit")));
20865    }
20866
20867    #[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
20868    #[test]
20869    fn tls_negotiation_review_writer_rejects_incomplete_terminal_shapes() {
20870        let render = |audit: AssessmentTlsNegotiationReviewAuditDocument| {
20871            let mut document = observation_assessment_document("unrelated.observation@1");
20872            document.tls_negotiation_review = Some(audit);
20873            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX)
20874        };
20875
20876        let producer_parent_unavailable =
20877            WebAssessmentTlsNegotiationReviewAudit::parent_authority_unavailable();
20878        assert_eq!(
20879            AssessmentTlsNegotiationReviewAuditDocument::from_audit(&producer_parent_unavailable)
20880                .map(|_| ()),
20881            Err(ReportError::Serialization)
20882        );
20883
20884        let mut parent_unavailable = tls_negotiation_review_audit_document();
20885        for cell in parent_unavailable
20886            .capability_observations
20887            .iter_mut()
20888            .take(2)
20889        {
20890            cell.dispatch_status = "not_dispatched";
20891            cell.outcome = "parent_authority_unavailable";
20892            cell.support = "not_established";
20893            cell.transport_validation = "not_established";
20894            cell.negotiated_protocol = None;
20895            cell.negotiated_cipher_suite = None;
20896            cell.accounted_ingress_tls_bytes = 0;
20897            cell.observed_egress_tls_bytes = 0;
20898        }
20899        parent_unavailable.coverage.terminal = "parent_authority_unavailable";
20900        parent_unavailable.coverage.attempted_connection_count = 0;
20901        parent_unavailable.coverage.completed_handshake_count = 0;
20902        parent_unavailable.coverage.accounted_ingress_tls_bytes = 0;
20903        parent_unavailable.coverage.observed_egress_tls_bytes = 0;
20904        assert_eq!(render(parent_unavailable), Err(ReportError::Serialization));
20905
20906        let mut dispatched_budget = tls_negotiation_review_audit_document();
20907        let second = &mut dispatched_budget.capability_observations[1];
20908        second.outcome = "budget_exhausted";
20909        second.support = "not_established";
20910        second.transport_validation = "not_established";
20911        second.negotiated_protocol = None;
20912        second.negotiated_cipher_suite = None;
20913        dispatched_budget.coverage.terminal = "budget_exhausted";
20914        dispatched_budget.coverage.completed_handshake_count = 1;
20915        assert_eq!(render(dispatched_budget), Err(ReportError::Serialization));
20916
20917        for (terminal, first_outcome) in [
20918            ("budget_exhausted", "budget_exhausted"),
20919            ("cancelled", "cancelled"),
20920            ("deadline_reached", "deadline_reached"),
20921            ("deadline_reached", "budget_exhausted"),
20922        ] {
20923            let mut stopped = tls_negotiation_review_audit_document();
20924            for (index, cell) in stopped
20925                .capability_observations
20926                .iter_mut()
20927                .take(2)
20928                .enumerate()
20929            {
20930                cell.dispatch_status = "not_dispatched";
20931                cell.outcome = if index == 0 {
20932                    first_outcome
20933                } else {
20934                    "not_reached"
20935                };
20936                cell.support = "not_established";
20937                cell.transport_validation = "not_established";
20938                cell.negotiated_protocol = None;
20939                cell.negotiated_cipher_suite = None;
20940                cell.accounted_ingress_tls_bytes = 0;
20941                cell.observed_egress_tls_bytes = 0;
20942            }
20943            stopped.coverage.terminal = terminal;
20944            stopped.coverage.attempted_connection_count = 0;
20945            stopped.coverage.completed_handshake_count = 0;
20946            stopped.coverage.accounted_ingress_tls_bytes = 0;
20947            stopped.coverage.observed_egress_tls_bytes = 0;
20948            assert_eq!(
20949                render(stopped),
20950                Err(ReportError::Serialization),
20951                "terminal={terminal};outcome={first_outcome}"
20952            );
20953        }
20954    }
20955
20956    #[cfg(all(feature = "scanning", feature = "tls-negotiation-review"))]
20957    #[test]
20958    fn tls_negotiation_review_writer_rejects_shape_and_accounting_mutations() {
20959        let render = |audit: AssessmentTlsNegotiationReviewAuditDocument| {
20960            let mut document = observation_assessment_document("unrelated.observation@1");
20961            document.tls_negotiation_review = Some(audit);
20962            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX)
20963        };
20964        assert!(render(tls_negotiation_review_audit_document()).is_ok());
20965
20966        let mut wrong_schema = tls_negotiation_review_audit_document();
20967        wrong_schema.schema = "security.tls-negotiation-review-audit/v2";
20968        assert_eq!(render(wrong_schema), Err(ReportError::Serialization));
20969
20970        let mut unselected = tls_negotiation_review_audit_document();
20971        unselected.selected = false;
20972        assert_eq!(render(unselected), Err(ReportError::Serialization));
20973
20974        let mut wrong_order = tls_negotiation_review_audit_document();
20975        wrong_order.capability_observations.swap(0, 1);
20976        assert_eq!(render(wrong_order), Err(ReportError::Serialization));
20977
20978        let mut aggregate_mismatch = tls_negotiation_review_audit_document();
20979        aggregate_mismatch.coverage.accounted_ingress_tls_bytes -= 1;
20980        assert_eq!(render(aggregate_mismatch), Err(ReportError::Serialization));
20981
20982        let mut fake_legacy_dispatch = tls_negotiation_review_audit_document();
20983        fake_legacy_dispatch.capability_observations[2].dispatch_status = "dispatched";
20984        assert_eq!(
20985            render(fake_legacy_dispatch),
20986            Err(ReportError::Serialization)
20987        );
20988
20989        let mut stale_negotiated_fields = tls_negotiation_review_audit_document();
20990        stale_negotiated_fields.capability_observations[0].outcome = "handshake_failed";
20991        assert_eq!(
20992            render(stale_negotiated_fields),
20993            Err(ReportError::Serialization)
20994        );
20995
20996        let mut later_dispatch_after_stop = tls_negotiation_review_audit_document();
20997        let first = &mut later_dispatch_after_stop.capability_observations[0];
20998        first.dispatch_status = "not_dispatched";
20999        first.outcome = "budget_exhausted";
21000        first.support = "not_established";
21001        first.transport_validation = "not_established";
21002        first.negotiated_protocol = None;
21003        first.negotiated_cipher_suite = None;
21004        first.accounted_ingress_tls_bytes = 0;
21005        first.observed_egress_tls_bytes = 0;
21006        later_dispatch_after_stop.coverage.terminal = "budget_exhausted";
21007        later_dispatch_after_stop
21008            .coverage
21009            .attempted_connection_count = 1;
21010        later_dispatch_after_stop.coverage.completed_handshake_count = 1;
21011        later_dispatch_after_stop
21012            .coverage
21013            .accounted_ingress_tls_bytes = 1_900;
21014        later_dispatch_after_stop.coverage.observed_egress_tls_bytes = 240;
21015        assert_eq!(
21016            render(later_dispatch_after_stop),
21017            Err(ReportError::Serialization)
21018        );
21019
21020        let mut completed_after_budget = tls_negotiation_review_audit_document();
21021        let second = &mut completed_after_budget.capability_observations[1];
21022        second.outcome = "budget_exhausted";
21023        second.support = "not_established";
21024        second.transport_validation = "not_established";
21025        second.negotiated_protocol = None;
21026        second.negotiated_cipher_suite = None;
21027        completed_after_budget.coverage.completed_handshake_count = 1;
21028        assert_eq!(
21029            render(completed_after_budget),
21030            Err(ReportError::Serialization)
21031        );
21032
21033        let mut incomplete_claimed_complete = tls_negotiation_review_audit_document();
21034        let first = &mut incomplete_claimed_complete.capability_observations[0];
21035        first.outcome = "transport_failed";
21036        first.support = "not_established";
21037        first.transport_validation = "not_established";
21038        first.negotiated_protocol = None;
21039        first.negotiated_cipher_suite = None;
21040        let second = &mut incomplete_claimed_complete.capability_observations[1];
21041        second.dispatch_status = "not_dispatched";
21042        second.outcome = "not_reached";
21043        second.support = "not_established";
21044        second.transport_validation = "not_established";
21045        second.negotiated_protocol = None;
21046        second.negotiated_cipher_suite = None;
21047        second.accounted_ingress_tls_bytes = 0;
21048        second.observed_egress_tls_bytes = 0;
21049        incomplete_claimed_complete
21050            .coverage
21051            .attempted_connection_count = 1;
21052        incomplete_claimed_complete
21053            .coverage
21054            .completed_handshake_count = 0;
21055        incomplete_claimed_complete
21056            .coverage
21057            .accounted_ingress_tls_bytes = 2_048;
21058        incomplete_claimed_complete
21059            .coverage
21060            .observed_egress_tls_bytes = 256;
21061        assert_eq!(
21062            render(incomplete_claimed_complete),
21063            Err(ReportError::Serialization)
21064        );
21065    }
21066
21067    #[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
21068    fn positive_secret_exposure_assessment_document() -> AssessmentDocument<'static> {
21069        let mut document =
21070            observation_assessment_document("exposure.response-stripe-live-secret@1");
21071        document.items[0].title = "A live-mode provider-secret shape was observed";
21072        document.items[0].redacted_summary =
21073            "A complete response contained a redacted provider-secret shape.";
21074        document.items[0].category = "passive-secret-exposure";
21075        document.items[0].remediation.id = "secret-exposure.review-response@1";
21076        document.items[0].remediation.summary =
21077            "Review the response source and rotate affected credentials when independently confirmed.";
21078        document.items[0].evidence_references = vec!["evidence-0007".to_owned()];
21079        let mut audit = empty_secret_exposure_audit_document();
21080        audit.response_count = 1;
21081        audit.evaluated_response_count = 1;
21082        audit.body_derived_projection_suppressed_response_count = 1;
21083        audit.interpreted_byte_count = 96;
21084        audit.observation_count = 1;
21085        audit.match_occurrence_count = 2;
21086        audit.outcomes = vec![AssessmentSecretExposureOutcomeDocument {
21087            outcome: "evaluated",
21088            count: 1,
21089        }];
21090        audit.observations = vec![AssessmentSecretExposureObservationDocument {
21091            detector_class: "stripe_live_secret",
21092            capability_id: "exposure.response-stripe-live-secret@1",
21093            occurrence_count: 2,
21094            evidence_references: vec!["evidence-0007".to_owned()],
21095        }];
21096        document.secret_exposure_review = Some(audit);
21097        document
21098    }
21099
21100    #[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
21101    fn retained_secret_exposure_limit_document() -> AssessmentDocument<'static> {
21102        let mut document = observation_assessment_document(SECRET_EXPOSURE_CAPABILITIES[0].1);
21103        document.items.clear();
21104        let mut observations = Vec::with_capacity(MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS);
21105        for index in 0..MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS {
21106            let (detector_class, capability_id) =
21107                SECRET_EXPOSURE_CAPABILITIES[index % SECRET_EXPOSURE_CAPABILITIES.len()];
21108            let evidence_reference = format!("evidence-{:04}", index + 100);
21109            let mut item = observation_assessment_document(capability_id)
21110                .items
21111                .pop()
21112                .unwrap();
21113            item.fingerprint = Box::leak(format!("sha256:{:064x}", index + 1).into_boxed_str());
21114            item.evidence_references = vec![evidence_reference.clone()];
21115            document.items.push(item);
21116            observations.push(AssessmentSecretExposureObservationDocument {
21117                detector_class,
21118                capability_id,
21119                occurrence_count: 1,
21120                evidence_references: vec![evidence_reference],
21121            });
21122        }
21123        document.item_count = u64::try_from(document.items.len()).unwrap();
21124        let mut audit = empty_secret_exposure_audit_document();
21125        audit.response_count = 9;
21126        audit.evaluated_response_count = 9;
21127        audit.body_derived_projection_suppressed_response_count = 9;
21128        audit.interpreted_byte_count = 4_096;
21129        audit.observation_count = u64::try_from(MAX_SECRET_EXPOSURE_RETAINED_OBSERVATIONS).unwrap();
21130        audit.omitted_observation_count = 1;
21131        audit.match_occurrence_count = 33;
21132        audit.outcomes = vec![AssessmentSecretExposureOutcomeDocument {
21133            outcome: "evaluated",
21134            count: 9,
21135        }];
21136        audit.observations = observations;
21137        document.secret_exposure_review = Some(audit);
21138        document
21139    }
21140
21141    #[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
21142    #[test]
21143    fn selected_empty_secret_exposure_audit_is_rendered_without_inventing_an_item() {
21144        let mut document = observation_assessment_document("unrelated.observation@1");
21145        document.secret_exposure_review = Some(empty_secret_exposure_audit_document());
21146
21147        for format in [
21148            ReportFormat::Json,
21149            ReportFormat::Csv,
21150            ReportFormat::Html,
21151            ReportFormat::Markdown,
21152        ] {
21153            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
21154            assert!(rendered.contains("security.passive-secret-exposure-audit/v1"));
21155            assert!(rendered.contains("termivar.passive-secret-exposure/v1"));
21156            assert!(!rendered.contains("sk_live_private-render-sentinel"));
21157        }
21158        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
21159        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
21160        assert_eq!(value["secret_exposure_review"]["selected"], true);
21161        assert_eq!(value["secret_exposure_review"]["response_count"], 0);
21162        assert_eq!(
21163            value["secret_exposure_review"]["body_derived_projection_suppressed_response_count"],
21164            0
21165        );
21166        assert_eq!(
21167            value["secret_exposure_review"]["observations"],
21168            serde_json::json!([])
21169        );
21170        assert_eq!(
21171            value["secret_exposure_review"]["additional_request_count"],
21172            0
21173        );
21174    }
21175
21176    #[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
21177    #[test]
21178    fn secret_exposure_audit_and_item_are_cross_linked_and_value_free_in_every_renderer() {
21179        let document = positive_secret_exposure_assessment_document();
21180        for format in [
21181            ReportFormat::Json,
21182            ReportFormat::Csv,
21183            ReportFormat::Html,
21184            ReportFormat::Markdown,
21185        ] {
21186            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
21187            assert!(rendered.contains("stripe_live_secret"));
21188            assert!(rendered.contains("exposure.response-stripe-live-secret@1"));
21189            assert!(rendered.contains("evidence-0007"));
21190            assert!(rendered.contains("not_performed"));
21191            assert!(rendered.contains("not_established"));
21192            assert!(!rendered.contains("sk_live_private-render-sentinel"));
21193        }
21194        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
21195        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
21196        assert_eq!(value["secret_exposure_review"]["observation_count"], 1);
21197        assert_eq!(value["secret_exposure_review"]["match_occurrence_count"], 2);
21198        assert_eq!(
21199            value["secret_exposure_review"]["body_derived_projection_suppressed_response_count"],
21200            1
21201        );
21202        assert_eq!(
21203            value["secret_exposure_review"]["observations"][0]["evidence_references"],
21204            serde_json::json!(["evidence-0007"]),
21205        );
21206        assert_eq!(
21207            value["secret_exposure_review"]["raw_values_retained"],
21208            false
21209        );
21210        assert_eq!(
21211            value["secret_exposure_review"]["public_secret_hashes_retained"],
21212            false,
21213        );
21214    }
21215
21216    #[cfg(all(feature = "scanning", feature = "secret-exposure-review"))]
21217    #[test]
21218    fn secret_exposure_writer_rejects_missing_or_inconsistent_cross_links_and_accounting() {
21219        assert!(render_assessment_with_limit(
21220            &retained_secret_exposure_limit_document(),
21221            ReportFormat::Json,
21222            usize::MAX,
21223        )
21224        .is_ok());
21225
21226        let mut missing_audit = positive_secret_exposure_assessment_document();
21227        missing_audit.secret_exposure_review = None;
21228        assert_eq!(
21229            render_assessment_with_limit(&missing_audit, ReportFormat::Json, usize::MAX),
21230            Err(ReportError::Serialization),
21231        );
21232
21233        let mut wrong_reference = positive_secret_exposure_assessment_document();
21234        wrong_reference
21235            .secret_exposure_review
21236            .as_mut()
21237            .unwrap()
21238            .observations[0]
21239            .evidence_references[0] = "evidence-0008".to_owned();
21240        assert_eq!(
21241            render_assessment_with_limit(&wrong_reference, ReportFormat::Json, usize::MAX),
21242            Err(ReportError::Serialization),
21243        );
21244
21245        let mut wrong_partition = positive_secret_exposure_assessment_document();
21246        wrong_partition
21247            .secret_exposure_review
21248            .as_mut()
21249            .unwrap()
21250            .not_evaluated_response_count = 1;
21251        assert_eq!(
21252            render_assessment_with_limit(&wrong_partition, ReportFormat::Json, usize::MAX),
21253            Err(ReportError::Serialization),
21254        );
21255
21256        let mut raw_retention = positive_secret_exposure_assessment_document();
21257        raw_retention
21258            .secret_exposure_review
21259            .as_mut()
21260            .unwrap()
21261            .raw_values_retained = true;
21262        assert_eq!(
21263            render_assessment_with_limit(&raw_retention, ReportFormat::Json, usize::MAX),
21264            Err(ReportError::Serialization),
21265        );
21266
21267        let mut missing_match_suppression = positive_secret_exposure_assessment_document();
21268        missing_match_suppression
21269            .secret_exposure_review
21270            .as_mut()
21271            .unwrap()
21272            .body_derived_projection_suppressed_response_count = 0;
21273        assert_eq!(
21274            render_assessment_with_limit(
21275                &missing_match_suppression,
21276                ReportFormat::Json,
21277                usize::MAX,
21278            ),
21279            Err(ReportError::Serialization),
21280        );
21281
21282        let mut excessive_suppression = positive_secret_exposure_assessment_document();
21283        excessive_suppression
21284            .secret_exposure_review
21285            .as_mut()
21286            .unwrap()
21287            .body_derived_projection_suppressed_response_count = 2;
21288        assert_eq!(
21289            render_assessment_with_limit(&excessive_suppression, ReportFormat::Json, usize::MAX),
21290            Err(ReportError::Serialization),
21291        );
21292
21293        let mut clean_response_suppression =
21294            observation_assessment_document("unrelated.observation@1");
21295        let mut clean_audit = empty_secret_exposure_audit_document();
21296        clean_audit.response_count = 1;
21297        clean_audit.evaluated_response_count = 1;
21298        clean_audit.body_derived_projection_suppressed_response_count = 1;
21299        clean_audit.outcomes = vec![AssessmentSecretExposureOutcomeDocument {
21300            outcome: "evaluated",
21301            count: 1,
21302        }];
21303        clean_response_suppression.secret_exposure_review = Some(clean_audit);
21304        assert!(render_assessment_with_limit(
21305            &clean_response_suppression,
21306            ReportFormat::Json,
21307            usize::MAX,
21308        )
21309        .is_ok());
21310        clean_response_suppression
21311            .secret_exposure_review
21312            .as_mut()
21313            .unwrap()
21314            .body_derived_projection_suppressed_response_count = 0;
21315        assert_eq!(
21316            render_assessment_with_limit(
21317                &clean_response_suppression,
21318                ReportFormat::Json,
21319                usize::MAX,
21320            ),
21321            Err(ReportError::Serialization),
21322        );
21323
21324        let mut missing_refusal_suppression =
21325            observation_assessment_document("unrelated.observation@1");
21326        let mut refused_audit = empty_secret_exposure_audit_document();
21327        refused_audit.response_count = 1;
21328        refused_audit.not_evaluated_response_count = 1;
21329        refused_audit.outcomes = vec![AssessmentSecretExposureOutcomeDocument {
21330            outcome: "invalid_utf8",
21331            count: 1,
21332        }];
21333        missing_refusal_suppression.secret_exposure_review = Some(refused_audit);
21334        assert_eq!(
21335            render_assessment_with_limit(
21336                &missing_refusal_suppression,
21337                ReportFormat::Json,
21338                usize::MAX,
21339            ),
21340            Err(ReportError::Serialization),
21341        );
21342
21343        let mut missing_item = positive_secret_exposure_assessment_document();
21344        missing_item.items[0].capability_id = "unrelated.observation@1";
21345        assert_eq!(
21346            render_assessment_with_limit(&missing_item, ReportFormat::Json, usize::MAX),
21347            Err(ReportError::Serialization),
21348        );
21349
21350        let mut shared_reference = positive_secret_exposure_assessment_document();
21351        let mut second_item =
21352            observation_assessment_document("exposure.response-aws-access-key-pair@1")
21353                .items
21354                .pop()
21355                .unwrap();
21356        second_item.evidence_references = vec!["evidence-0007".to_owned()];
21357        shared_reference.items.push(second_item);
21358        shared_reference.item_count = 2;
21359        let audit = shared_reference.secret_exposure_review.as_mut().unwrap();
21360        audit.observation_count = 2;
21361        audit.match_occurrence_count = 3;
21362        audit
21363            .observations
21364            .push(AssessmentSecretExposureObservationDocument {
21365                detector_class: "aws_access_key_pair",
21366                capability_id: "exposure.response-aws-access-key-pair@1",
21367                occurrence_count: 1,
21368                evidence_references: vec!["evidence-0007".to_owned()],
21369            });
21370        assert_eq!(
21371            render_assessment_with_limit(&shared_reference, ReportFormat::Json, usize::MAX),
21372            Err(ReportError::Serialization),
21373        );
21374
21375        let mut retained_equals_total = retained_secret_exposure_limit_document();
21376        retained_equals_total
21377            .secret_exposure_review
21378            .as_mut()
21379            .unwrap()
21380            .match_occurrence_count = 32;
21381        assert_eq!(
21382            render_assessment_with_limit(&retained_equals_total, ReportFormat::Json, usize::MAX,),
21383            Err(ReportError::Serialization),
21384        );
21385
21386        let mut impossible_below_cap_omission = positive_secret_exposure_assessment_document();
21387        let below_cap_audit = impossible_below_cap_omission
21388            .secret_exposure_review
21389            .as_mut()
21390            .unwrap();
21391        below_cap_audit.omitted_observation_count = 1;
21392        below_cap_audit.match_occurrence_count = 3;
21393        assert_eq!(
21394            render_assessment_with_limit(
21395                &impossible_below_cap_omission,
21396                ReportFormat::Json,
21397                usize::MAX,
21398            ),
21399            Err(ReportError::Serialization),
21400        );
21401
21402        let mut omitted_rows_exceed_missing_occurrences = retained_secret_exposure_limit_document();
21403        omitted_rows_exceed_missing_occurrences
21404            .secret_exposure_review
21405            .as_mut()
21406            .unwrap()
21407            .omitted_observation_count = 2;
21408        assert_eq!(
21409            render_assessment_with_limit(
21410                &omitted_rows_exceed_missing_occurrences,
21411                ReportFormat::Json,
21412                usize::MAX,
21413            ),
21414            Err(ReportError::Serialization),
21415        );
21416
21417        let mut missing_occurrences_exceed_omitted_rows = retained_secret_exposure_limit_document();
21418        let missing_upper_audit = missing_occurrences_exceed_omitted_rows
21419            .secret_exposure_review
21420            .as_mut()
21421            .unwrap();
21422        missing_upper_audit.match_occurrence_count = 97;
21423        assert_eq!(
21424            render_assessment_with_limit(
21425                &missing_occurrences_exceed_omitted_rows,
21426                ReportFormat::Json,
21427                usize::MAX,
21428            ),
21429            Err(ReportError::Serialization),
21430        );
21431
21432        let mut many_suppressed_responses_one_match =
21433            positive_secret_exposure_assessment_document();
21434        let positive_count_audit = many_suppressed_responses_one_match
21435            .secret_exposure_review
21436            .as_mut()
21437            .unwrap();
21438        positive_count_audit.response_count = 10;
21439        positive_count_audit.evaluated_response_count = 10;
21440        positive_count_audit.body_derived_projection_suppressed_response_count = 10;
21441        positive_count_audit.match_occurrence_count = 1;
21442        positive_count_audit.outcomes[0].count = 10;
21443        positive_count_audit.observations[0].occurrence_count = 1;
21444        assert!(render_assessment_with_limit(
21445            &many_suppressed_responses_one_match,
21446            ReportFormat::Json,
21447            usize::MAX,
21448        )
21449        .is_ok());
21450        many_suppressed_responses_one_match
21451            .secret_exposure_review
21452            .as_mut()
21453            .unwrap()
21454            .body_derived_projection_suppressed_response_count = 9;
21455        assert_eq!(
21456            render_assessment_with_limit(
21457                &many_suppressed_responses_one_match,
21458                ReportFormat::Json,
21459                usize::MAX,
21460            ),
21461            Err(ReportError::Serialization),
21462        );
21463
21464        let mut observations_exceed_evaluated_capacity = retained_secret_exposure_limit_document();
21465        let evaluated_capacity_audit = observations_exceed_evaluated_capacity
21466            .secret_exposure_review
21467            .as_mut()
21468            .unwrap();
21469        evaluated_capacity_audit.evaluated_response_count = 7;
21470        evaluated_capacity_audit.not_evaluated_response_count = 2;
21471        evaluated_capacity_audit.body_derived_projection_suppressed_response_count = 9;
21472        evaluated_capacity_audit.outcomes = vec![
21473            AssessmentSecretExposureOutcomeDocument {
21474                outcome: "evaluated",
21475                count: 7,
21476            },
21477            AssessmentSecretExposureOutcomeDocument {
21478                outcome: "unsupported_media_type",
21479                count: 2,
21480            },
21481        ];
21482        assert_eq!(
21483            render_assessment_with_limit(
21484                &observations_exceed_evaluated_capacity,
21485                ReportFormat::Json,
21486                usize::MAX,
21487            ),
21488            Err(ReportError::Serialization),
21489        );
21490
21491        let mut zero_total_with_omission =
21492            observation_assessment_document("unrelated.observation@1");
21493        let mut zero_total_audit = empty_secret_exposure_audit_document();
21494        zero_total_audit.response_count = 1;
21495        zero_total_audit.evaluated_response_count = 1;
21496        zero_total_audit.omitted_observation_count = 1;
21497        zero_total_audit.outcomes = vec![AssessmentSecretExposureOutcomeDocument {
21498            outcome: "evaluated",
21499            count: 1,
21500        }];
21501        zero_total_with_omission.secret_exposure_review = Some(zero_total_audit);
21502        assert_eq!(
21503            render_assessment_with_limit(&zero_total_with_omission, ReportFormat::Json, usize::MAX,),
21504            Err(ReportError::Serialization),
21505        );
21506    }
21507
21508    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
21509    fn complete_supplied_session_audit_document() -> AssessmentSuppliedSessionAuditDocument {
21510        AssessmentSuppliedSessionAuditDocument {
21511            schema: SUPPLIED_SESSION_AUDIT_SCHEMA,
21512            capability_id: SUPPLIED_SESSION_CAPABILITY_ID,
21513            policy_reference: format!("supplied-session-policy-sha256:{}", "1".repeat(64)),
21514            application_reference: format!(
21515                "supplied-session-application-sha256:{}",
21516                "2".repeat(64)
21517            ),
21518            principal_reference: "supplied-session-principal-0001".to_owned(),
21519            principal_alias: "fixture-user".to_owned(),
21520            principal_assurance: "operator_declared",
21521            credential_mechanism: "authorization_header",
21522            credential_acquisition: None,
21523            cookie_policy: None,
21524            cookie_lifecycle: None,
21525            login: None,
21526            renewal: None,
21527            oauth_code: None,
21528            oauth_refresh: None,
21529            oauth_authorization: None,
21530            oauth_token_exchange: None,
21531            health_oracle: AssessmentSuppliedSessionHealthOracleDocument {
21532                kind: "json_boolean_true",
21533                field_reference: format!("supplied-session-health-field-sha256:{}", "3".repeat(64)),
21534            },
21535            outcome: "complete",
21536            coverage: "complete",
21537            checkpoints: vec![
21538                AssessmentSuppliedSessionCheckpointDocument {
21539                    sequence: 0,
21540                    phase: "startup",
21541                    after_subject_count: 0,
21542                    evidence_reference: Some(format!(
21543                        "supplied-session-checkpoint-evidence-sha256:{}",
21544                        "6".repeat(64)
21545                    )),
21546                    outcome: "healthy",
21547                    status: Some(200),
21548                    body_state: "complete",
21549                    predicate: "matched",
21550                    response_bytes: 17,
21551                    epoch: None,
21552                },
21553                AssessmentSuppliedSessionCheckpointDocument {
21554                    sequence: 1,
21555                    phase: "terminal",
21556                    after_subject_count: 1,
21557                    evidence_reference: Some(format!(
21558                        "supplied-session-checkpoint-evidence-sha256:{}",
21559                        "7".repeat(64)
21560                    )),
21561                    outcome: "healthy",
21562                    status: Some(200),
21563                    body_state: "complete",
21564                    predicate: "matched",
21565                    response_bytes: 19,
21566                    epoch: None,
21567                },
21568            ],
21569            resources: vec![AssessmentSuppliedSessionResourceDocument {
21570                sequence: 0,
21571                resource_reference: format!("supplied-session-resource-sha256:{}", "4".repeat(64)),
21572                evidence_reference: Some(format!(
21573                    "supplied-session-resource-evidence-sha256:{}",
21574                    "5".repeat(64)
21575                )),
21576                outcome: "committed",
21577                status: Some(200),
21578                response_bytes: 23,
21579                epoch: 1,
21580            }],
21581            selected_resource_count: 1,
21582            dispatched_resource_count: 1,
21583            committed_resource_count: 1,
21584            dispatched_request_count: 3,
21585            response_bytes: 59,
21586            response_byte_limit: 65_536,
21587            response_byte_limit_exceeded: false,
21588            refresh_performed: false,
21589            anonymous_fallback_performed: false,
21590            continuous_authentication_established: false,
21591            exploit_execution: "not_performed",
21592            impact_validation: "not_performed",
21593        }
21594    }
21595
21596    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
21597    #[test]
21598    fn oauth_renewal_checkpoint_display_preserves_legacy_and_separate_epoch() {
21599        let health = AssessmentSuppliedSessionCheckpointDocument {
21600            sequence: 2,
21601            phase: "renewal",
21602            after_subject_count: 1,
21603            evidence_reference: Some(format!(
21604                "supplied-session-checkpoint-evidence-sha256:{}",
21605                "e".repeat(64)
21606            )),
21607            outcome: "healthy",
21608            status: Some(200),
21609            body_state: "complete",
21610            predicate: "matched",
21611            response_bytes: 29,
21612            epoch: Some(2),
21613        };
21614        assert_eq!(health.human_summary(true), format!(
21615            "sequence=2;phase=renewal;after_subject_count=1;evidence_reference=supplied-session-checkpoint-evidence-sha256:{};outcome=healthy;status=200;body_state=complete;predicate=matched;response_bytes=29;epoch=2",
21616            "e".repeat(64)
21617        ));
21618        assert!(!health.human_summary(false).contains(";epoch="));
21619        let legacy = complete_supplied_session_audit_document();
21620        assert!(serde_json::to_value(&legacy)
21621            .unwrap()
21622            .get("oauth_refresh")
21623            .is_none());
21624        assert_eq!(legacy.checkpoints[0].human_summary(false), format!(
21625            "sequence=0;phase=startup;after_subject_count=0;evidence_reference=supplied-session-checkpoint-evidence-sha256:{};outcome=healthy;status=200;body_state=complete;predicate=matched;response_bytes=17",
21626            "6".repeat(64)
21627        ));
21628    }
21629
21630    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
21631    fn complete_cookie_supplied_session_audit_document() -> AssessmentSuppliedSessionAuditDocument {
21632        let mut document = complete_supplied_session_audit_document();
21633        document.schema = SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA;
21634        document.credential_mechanism = "cookie_jar";
21635        document.cookie_policy = Some(AssessmentSuppliedSessionCookiePolicyDocument {
21636            declared_count: 2,
21637            host_only_count: 1,
21638            domain_count: 1,
21639            secure_count: 2,
21640            http_only_count: 1,
21641            session_count: 1,
21642            persistent_count: 1,
21643            same_site_missing_count: 0,
21644            same_site_strict_count: 1,
21645            same_site_lax_count: 0,
21646            same_site_none_count: 1,
21647            update_policy: "stop_on_selected_cookie",
21648            browser_semantics: "attributes_preserved_not_browser_csrf_emulation",
21649        });
21650        document.cookie_lifecycle = Some(AssessmentSuppliedSessionCookieLifecycleDocument {
21651            initial_epoch: 1,
21652            final_epoch: 1,
21653            selected_update_response_count: 0,
21654            unselected_update_response_count: 1,
21655            update_classification_failure_count: 0,
21656            updates_applied: 0,
21657        });
21658        document
21659    }
21660
21661    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
21662    fn complete_form_login_supplied_session_audit_document(
21663    ) -> AssessmentSuppliedSessionAuditDocument {
21664        let mut document = complete_cookie_supplied_session_audit_document();
21665        document.schema = SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA;
21666        document.credential_acquisition = Some("bounded_form_login");
21667        document.cookie_policy = Some(AssessmentSuppliedSessionCookiePolicyDocument {
21668            declared_count: 1,
21669            host_only_count: 1,
21670            domain_count: 0,
21671            secure_count: 0,
21672            http_only_count: 1,
21673            session_count: 1,
21674            persistent_count: 0,
21675            same_site_missing_count: 0,
21676            same_site_strict_count: 0,
21677            same_site_lax_count: 1,
21678            same_site_none_count: 0,
21679            update_policy: "stop_on_selected_cookie",
21680            browser_semantics: "attributes_preserved_not_browser_csrf_emulation",
21681        });
21682        document.cookie_lifecycle = Some(AssessmentSuppliedSessionCookieLifecycleDocument {
21683            initial_epoch: 0,
21684            final_epoch: 1,
21685            selected_update_response_count: 0,
21686            unselected_update_response_count: 0,
21687            update_classification_failure_count: 0,
21688            updates_applied: 0,
21689        });
21690        document.login = Some(AssessmentSuppliedSessionLoginDocument {
21691            login_reference: format!("supplied-session-login-sha256:{}", "9".repeat(64)),
21692            max_attempts: 1,
21693            attempt_count: 1,
21694            page_dispatched: true,
21695            page_status: Some(200),
21696            page_body_state: "complete",
21697            form_outcome: "matched",
21698            form_error_code: None,
21699            submit_dispatched: true,
21700            submit_status: Some(200),
21701            submit_body_state: "complete",
21702            submit_outcome: "cookie_acquired",
21703            acquired_cookie_count: 1,
21704            response_bytes: 54,
21705            initial_epoch: 0,
21706            final_epoch: 1,
21707            pre_session_cookie_applied: false,
21708        });
21709        document.dispatched_request_count = 5;
21710        document.response_bytes = 113;
21711        document
21712    }
21713
21714    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
21715    fn unavailable_form_login_supplied_session_audit_document(
21716    ) -> AssessmentSuppliedSessionAuditDocument {
21717        let mut document = complete_form_login_supplied_session_audit_document();
21718        document.outcome = "login_form_unavailable";
21719        document.coverage = "none";
21720        document.checkpoints.clear();
21721        document.resources[0].evidence_reference = None;
21722        document.resources[0].outcome = "not_dispatched";
21723        document.resources[0].status = None;
21724        document.resources[0].response_bytes = 0;
21725        document.dispatched_resource_count = 0;
21726        document.committed_resource_count = 0;
21727        document.dispatched_request_count = 1;
21728        document.response_bytes = 41;
21729        document.cookie_lifecycle.as_mut().unwrap().final_epoch = 0;
21730        document.login = Some(AssessmentSuppliedSessionLoginDocument {
21731            login_reference: format!("supplied-session-login-sha256:{}", "9".repeat(64)),
21732            max_attempts: 1,
21733            attempt_count: 0,
21734            page_dispatched: true,
21735            page_status: Some(200),
21736            page_body_state: "complete",
21737            form_outcome: "missing",
21738            form_error_code: Some("missing_csrf"),
21739            submit_dispatched: false,
21740            submit_status: None,
21741            submit_body_state: "unavailable",
21742            submit_outcome: "not_dispatched",
21743            acquired_cookie_count: 0,
21744            response_bytes: 41,
21745            initial_epoch: 0,
21746            final_epoch: 0,
21747            pre_session_cookie_applied: false,
21748        });
21749        document
21750    }
21751
21752    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
21753    fn unavailable_form_login_cookie_supplied_session_audit_document(
21754    ) -> AssessmentSuppliedSessionAuditDocument {
21755        let mut document = complete_form_login_supplied_session_audit_document();
21756        document.outcome = "login_cookie_unavailable";
21757        document.coverage = "none";
21758        document.checkpoints.clear();
21759        document.resources[0].evidence_reference = None;
21760        document.resources[0].outcome = "not_dispatched";
21761        document.resources[0].status = None;
21762        document.resources[0].response_bytes = 0;
21763        document.dispatched_resource_count = 0;
21764        document.committed_resource_count = 0;
21765        document.dispatched_request_count = 2;
21766        document.response_bytes = 54;
21767        document.cookie_lifecycle.as_mut().unwrap().final_epoch = 0;
21768        let login = document.login.as_mut().unwrap();
21769        login.submit_outcome = "cookie_unavailable";
21770        login.acquired_cookie_count = 0;
21771        login.final_epoch = 0;
21772        document
21773    }
21774
21775    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
21776    fn stopped_cookie_supplied_session_audit_document(
21777        outcome: &'static str,
21778    ) -> AssessmentSuppliedSessionAuditDocument {
21779        let mut document = complete_cookie_supplied_session_audit_document();
21780        document.outcome = outcome;
21781        document.coverage = "none";
21782        document.checkpoints.truncate(1);
21783        document.resources[0].evidence_reference = None;
21784        document.resources[0].outcome = "not_dispatched";
21785        document.resources[0].status = None;
21786        document.resources[0].response_bytes = 0;
21787        document.dispatched_resource_count = 0;
21788        document.committed_resource_count = 0;
21789        document.dispatched_request_count = 1;
21790        document.response_bytes = document.checkpoints[0].response_bytes;
21791        document
21792            .cookie_lifecycle
21793            .as_mut()
21794            .unwrap()
21795            .unselected_update_response_count = 0;
21796        document
21797    }
21798
21799    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
21800    #[test]
21801    fn supplied_session_v1_wire_bytes_remain_frozen() {
21802        let expected = concat!(
21803            r#"{"schema":"security.supplied-session-audit/v1","capability_id":"session.supplied-context-assessment@1","policy_reference":"supplied-session-policy-sha256:1111111111111111111111111111111111111111111111111111111111111111","application_reference":"supplied-session-application-sha256:2222222222222222222222222222222222222222222222222222222222222222","principal_reference":"supplied-session-principal-0001","principal_alias":"fixture-user","principal_assurance":"operator_declared","credential_mechanism":"authorization_header","health_oracle":{"kind":"json_boolean_true","field_reference":"supplied-session-health-field-sha256:3333333333333333333333333333333333333333333333333333333333333333"},"outcome":"complete","coverage":"complete","checkpoints":[{"sequence":0,"phase":"startup","after_subject_count":0,"evidence_reference":"supplied-session-checkpoint-evidence-sha256:6666666666666666666666666666666666666666666666666666666666666666","outcome":"healthy","status":200,"body_state":"complete","predicate":"matched","response_bytes":17},"#,
21804            r#"{"sequence":1,"phase":"terminal","after_subject_count":1,"evidence_reference":"supplied-session-checkpoint-evidence-sha256:7777777777777777777777777777777777777777777777777777777777777777","outcome":"healthy","status":200,"body_state":"complete","predicate":"matched","response_bytes":19}],"resources":[{"sequence":0,"resource_reference":"supplied-session-resource-sha256:4444444444444444444444444444444444444444444444444444444444444444","evidence_reference":"supplied-session-resource-evidence-sha256:5555555555555555555555555555555555555555555555555555555555555555","outcome":"committed","status":200,"response_bytes":23,"epoch":1}],"selected_resource_count":1,"dispatched_resource_count":1,"committed_resource_count":1,"dispatched_request_count":3,"response_bytes":59,"response_byte_limit":65536,"response_byte_limit_exceeded":false,"refresh_performed":false,"anonymous_fallback_performed":false,"continuous_authentication_established":false,"exploit_execution":"not_performed","impact_validation":"not_performed"}"#,
21805        );
21806        assert_eq!(
21807            complete_supplied_session_audit_document()
21808                .wire_json()
21809                .unwrap(),
21810            expected
21811        );
21812    }
21813
21814    #[cfg(feature = "scanning")]
21815    fn complete_assessment_document() -> AssessmentDocument<'static> {
21816        let mut document = observation_assessment_document("passive.header.hsts.missing@1");
21817        document.items[0].title = "Strict transport policy was not observed";
21818        document.items[0].fingerprint = "assessment-fingerprint-v1:0001";
21819        document.items[0].redacted_summary = "Bounded response metadata did not include HSTS.";
21820        document.items[0].category = "transport-policy";
21821        document.items[0].remediation = AssessmentRemediationDocument {
21822            id: "remediation.transport.hsts@1",
21823            summary: "Review whether this HTTPS response should declare HSTS.",
21824        };
21825        document.items.push(AssessmentItemDocument {
21826            schema: crate::web_runtime::ASSESSMENT_ITEM_SCHEMA,
21827            capability_id: "cors.policy.relationship@1",
21828            subject_reference: "subject-0000".to_owned(),
21829            presentation_target: "assessment_subject",
21830            title: "CORS policy relationship warrants review",
21831            disposition: "needs_review",
21832            claim_basis: "differential",
21833            severity: Some("low"),
21834            confidence_ppm: 825_000,
21835            fingerprint: "assessment-fingerprint-v1:0002",
21836            evidence_count: 2,
21837            redacted_summary: "A matched control and candidate differed under review policy.",
21838            category: "cross-origin-policy",
21839            cwe: Some("CWE-942"),
21840            remediation: AssessmentRemediationDocument {
21841                id: "remediation.cors.policy@1",
21842                summary: "Review the intended origin and credential relationship.",
21843            },
21844            evidence_references: Vec::new(),
21845            control_evidence_references: vec!["evidence-0001".to_owned()],
21846            candidate_evidence_references: vec!["evidence-0002".to_owned()],
21847            case_reference: None,
21848            outcome_reference: None,
21849            verification_stage: None,
21850        });
21851        document.items.push(AssessmentItemDocument {
21852            schema: crate::web_runtime::ASSESSMENT_ITEM_SCHEMA,
21853            capability_id: "review.confirmed.test-boundary@1",
21854            subject_reference: "subject-0000".to_owned(),
21855            presentation_target: "assessment_subject",
21856            title: "Verifier-authorized transition",
21857            disposition: "confirmed",
21858            claim_basis: "verifier_transition",
21859            severity: Some("high"),
21860            confidence_ppm: 990_000,
21861            fingerprint: "assessment-fingerprint-v1:0003",
21862            evidence_count: 2,
21863            redacted_summary: "A case-correlated verifier transition satisfied claim policy.",
21864            category: "verification-boundary",
21865            cwe: Some("CWE-20"),
21866            remediation: AssessmentRemediationDocument {
21867                id: "remediation.verified.test-boundary@1",
21868                summary: "Apply the capability-owned remediation and verify the correction.",
21869            },
21870            evidence_references: vec!["evidence-0003".to_owned(), "evidence-0004".to_owned()],
21871            control_evidence_references: Vec::new(),
21872            candidate_evidence_references: Vec::new(),
21873            case_reference: Some("case-0000".to_owned()),
21874            outcome_reference: Some("outcome-0000".to_owned()),
21875            verification_stage: Some("active"),
21876        });
21877        document.item_count = 3;
21878        document
21879    }
21880
21881    #[cfg(all(feature = "scanning", feature = "openapi-review"))]
21882    fn observed_openapi_assessment_document() -> AssessmentDocument<'static> {
21883        let mut document = observation_assessment_document(OPENAPI_REVIEW_CAPABILITY_ID);
21884        document.items[0].title = "OpenAPI contract observed";
21885        document.items[0].fingerprint = "assessment-openapi-fingerprint-v1:0001";
21886        document.items[0].redacted_summary =
21887            "A bounded OpenAPI contract was reproduced by one anonymous GET replay.";
21888        document.items[0].category = "api-surface";
21889        document.items[0].remediation = AssessmentRemediationDocument {
21890            id: "api.openapi-contract-review@1",
21891            summary: "Confirm that publishing this OpenAPI contract matches deployment policy.",
21892        };
21893        document.openapi_review = Some(AssessmentOpenApiAuditDocument {
21894            schema: "security.openapi-review-audit/v1",
21895            capability_id: OPENAPI_REVIEW_CAPABILITY_ID,
21896            outcome: openapi_outcome(OpenApiRuntimeOutcome::DocumentObserved),
21897            candidate_source: crate::web_runtime::OpenApiCandidateSource::DiscoveredOpenApiJson,
21898            request_count: 2,
21899            active_verification_count: 1,
21900            version: Some("3.1.0"),
21901            semantic_digest: Some(
21902                "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(),
21903            ),
21904            path_count: 3,
21905            operation_count: 5,
21906            get_operation_count: 2,
21907            write_operation_count: 3,
21908            path_parameter_count: 1,
21909            query_parameter_count: 2,
21910            explicit_auth_operation_count: 3,
21911            anonymous_operation_count: 2,
21912            url_like_operation_count: 1,
21913            multipart_operation_count: 1,
21914            deprecated_operation_count: 1,
21915            replay_matched: true,
21916            item_projected: true,
21917        });
21918        document
21919    }
21920
21921    #[cfg(all(feature = "scanning", feature = "rest-review"))]
21922    fn observed_rest_assessment_document() -> AssessmentDocument<'static> {
21923        let mut document = observation_assessment_document(REST_REVIEW_CAPABILITY_ID);
21924        document.items[0].title = "REST operation surface observed";
21925        document.items[0].fingerprint = "assessment-rest-fingerprint-v1:0001";
21926        document.items[0].redacted_summary =
21927            "Two anonymous exact-origin GET requests reproduced the same bounded JSON resource structure.";
21928        document.items[0].category = "api-surface";
21929        document.items[0].remediation = AssessmentRemediationDocument {
21930            id: "api.rest-readonly-surface-review@1",
21931            summary: "Confirm that anonymously exposing this documented read-only operation matches deployment policy.",
21932        };
21933        document.rest_review = Some(AssessmentRestAuditDocument {
21934            schema: "security.rest-readonly-review-audit/v1",
21935            capability_id: REST_REVIEW_CAPABILITY_ID,
21936            enabled: true,
21937            method: "get",
21938            outcome: rest_outcome(RestRuntimeOutcome::SurfaceObserved),
21939            request_count: 2,
21940            active_verification_count: 1,
21941            eligible_operation_count: 3,
21942            selected_operation_identity: Some(
21943                "openapi-operation-sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
21944                    .to_owned(),
21945            ),
21946            documented_response: Some(rest_documented_response(
21947                RestDocumentedResponseClass::JsonCompatible,
21948            )),
21949            observed_media: rest_observed_media(RestObservedMediaClass::JsonCompatible),
21950            status_class: Some(2),
21951            replay_stable: true,
21952            item_projected: true,
21953        });
21954        document
21955    }
21956
21957    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
21958    fn observed_wordpress_assessment_document() -> AssessmentDocument<'static> {
21959        let mut document = observation_assessment_document(WORDPRESS_REVIEW_CAPABILITY_ID);
21960        document.items[0].title = "WordPress surface hints observed";
21961        document.items[0].fingerprint = "assessment-wordpress-fingerprint-v1:0001";
21962        document.items[0].redacted_summary =
21963            "The root response contained bounded structured WordPress hints; installation authenticity and advisory impact were not established.";
21964        document.items[0].category = "technology-observation";
21965        document.items[0].remediation = AssessmentRemediationDocument {
21966            id: "wordpress-inventory-review",
21967            summary: "Review supplied WordPress context and advisory applicability independently.",
21968        };
21969        document.wordpress_review = Some(AssessmentWordPressAuditDocument {
21970            schema: "security.wordpress-review-audit/v1",
21971            review_basis_schema: None,
21972            capability_id: WORDPRESS_REVIEW_CAPABILITY_ID,
21973            catalog_status: "evaluated",
21974            catalog_schema: None,
21975            catalog: Some(WordPressCatalogDocument {
21976                id: "termivar-synthetic-wordpress-reporting".to_owned(),
21977                revision: "synthetic-v1".to_owned(),
21978                retrieved_on: "2026-09-06".to_owned(),
21979            }),
21980            inventory_import: None,
21981            external_review: None,
21982            signal_count: 1,
21983            evidence_reference_count: 1,
21984            additional_request_count: 0,
21985            item_projected: true,
21986            component_count: 1,
21987            advisory_count: 1,
21988            components: vec![WordPressComponentDocument {
21989                identity: WordPressComponentIdentityDocument {
21990                    kind: "core",
21991                    slug: "wordpress".to_owned(),
21992                },
21993                evidence_class: "observed_hint",
21994                identity_sources: vec!["generator_metadata"],
21995                confidence_classes: vec!["public_declaration"],
21996                versions: vec![WordPressVersionEvidenceDocument {
21997                    value: "6.9.4".to_owned(),
21998                    source: "generator_metadata",
21999                    confidence: "public_declaration",
22000                }],
22001                activation: None,
22002                inventory_status: None,
22003            }],
22004            advisories: vec![WordPressAdvisoryDocument {
22005                id: "SYNTHETIC-REPORTING-WORDPRESS-0001".to_owned(),
22006                component: WordPressComponentIdentityDocument {
22007                    kind: "core",
22008                    slug: "wordpress".to_owned(),
22009                },
22010                source: WordPressAdvisorySourceDocument {
22011                    reference: "https://example.invalid/termivar/synthetic/reporting".to_owned(),
22012                    revision: "synthetic-v1".to_owned(),
22013                    retrieved_on: "2026-09-06".to_owned(),
22014                    usage_basis:
22015                        "Fictional test data created for Termivar reporting; not a real advisory."
22016                            .to_owned(),
22017                },
22018                cve: None,
22019                summary: "Synthetic bounded summary <script>inert()</script>".to_owned(),
22020                affected_ranges: vec![WordPressAffectedRangeDocument {
22021                    lower: Some(WordPressVersionEndpointDocument {
22022                        declared: "6.9.0".to_owned(),
22023                        inclusive: true,
22024                    }),
22025                    upper: Some(WordPressVersionEndpointDocument {
22026                        declared: "6.9.5".to_owned(),
22027                        inclusive: false,
22028                    }),
22029                }],
22030                fixed_versions: vec!["6.9.5".to_owned()],
22031                prerequisites: vec![WordPressPrerequisiteDocument {
22032                    kind: "hosting_os",
22033                    expected: "linux".to_owned(),
22034                    patch_id: None,
22035                    outcome: "unknown",
22036                }],
22037                remediation: Some("Review the supplied upstream remediation guidance.".to_owned()),
22038                comparison_profile: None,
22039                version_resolution: None,
22040                version_resolution_reason: None,
22041                component_evidence: "observed_hint",
22042                version_relation: "within_declared_range",
22043                applicability: "indeterminate_missing_evidence",
22044                exploit_execution: "not_performed",
22045                impact_validation: "not_performed",
22046            }],
22047        });
22048        document
22049    }
22050
22051    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22052    fn discovered_wordpress_assessment_document() -> AssessmentDocument<'static> {
22053        let mut document = observed_wordpress_assessment_document();
22054        document.items[0].fingerprint =
22055            "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
22056        document.items.push(AssessmentItemDocument {
22057            schema: crate::web_runtime::ASSESSMENT_ITEM_SCHEMA,
22058            capability_id: WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID,
22059            subject_reference: "subject-0000".to_owned(),
22060            presentation_target: "assessment_subject",
22061            title: "WordPress metadata-source response outcome observed",
22062            disposition: "informational",
22063            claim_basis: "observation",
22064            severity: None,
22065            confidence_ppm: 550_000,
22066            fingerprint:
22067                "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
22068            evidence_count: 3,
22069            redacted_summary: "Bounded response evidence from selected public WordPress metadata sources was collected; usable metadata, installation authenticity, vulnerable-code reachability, and advisory impact were not established.",
22070            category: "wordpress-metadata-source-response",
22071            cwe: None,
22072            remediation: AssessmentRemediationDocument {
22073                id: "wordpress-metadata-review",
22074                summary: "Confirm the installation inventory and source-qualified metadata before making a security or remediation decision.",
22075            },
22076            evidence_references: vec![
22077                "evidence-0001".to_owned(),
22078                "evidence-0002".to_owned(),
22079                "evidence-0003".to_owned(),
22080            ],
22081            control_evidence_references: Vec::new(),
22082            candidate_evidence_references: Vec::new(),
22083            case_reference: None,
22084            outcome_reference: None,
22085            verification_stage: None,
22086        });
22087        document.item_count = document.items.len() as u64;
22088        let review = document.wordpress_review.as_mut().unwrap();
22089        review.schema = "security.wordpress-review-audit/v7";
22090        review.review_basis_schema = Some("security.wordpress-review-audit/v1");
22091        review.additional_request_count = 3;
22092        review.components.push(WordPressComponentDocument {
22093            identity: WordPressComponentIdentityDocument {
22094                kind: "theme",
22095                slug: "termivar-child".to_owned(),
22096            },
22097            evidence_class: "observed_hint",
22098            identity_sources: vec!["same_origin_asset_path", "theme_stylesheet_declaration"],
22099            confidence_classes: vec!["structural_hint", "public_declaration"],
22100            versions: vec![WordPressVersionEvidenceDocument {
22101                value: "1.2.3".to_owned(),
22102                source: "theme_stylesheet_declaration",
22103                confidence: "public_declaration",
22104            }],
22105            activation: None,
22106            inventory_status: None,
22107        });
22108        review.components.push(WordPressComponentDocument {
22109            identity: WordPressComponentIdentityDocument {
22110                kind: "plugin",
22111                slug: "termivar-metadata-lab".to_owned(),
22112            },
22113            evidence_class: "observed_hint",
22114            identity_sources: vec!["same_origin_asset_path"],
22115            confidence_classes: vec!["structural_hint"],
22116            versions: Vec::new(),
22117            activation: None,
22118            inventory_status: None,
22119        });
22120        review.component_count = review.components.len();
22121        document.wordpress_discovery = Some(AssessmentWordPressDiscoveryAuditDocument {
22122            schema: WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V2,
22123            capability_id: WORDPRESS_DISCOVERY_CAPABILITY_ID,
22124            policy_id: WORDPRESS_DISCOVERY_POLICY_ID,
22125            selected: true,
22126            method: "get",
22127            credential_mode: "anonymous",
22128            seed_count: 3,
22129            candidate_count: 3,
22130            candidate_limit_reached: false,
22131            omitted_candidate_count: 0,
22132            attempted_request_count: 3,
22133            completed_response_count: 3,
22134            committed_response_count: 3,
22135            response_bytes: 768,
22136            source_count: 3,
22137            layout: WordPressDiscoveryLayoutDocument {
22138                application_reference: format!("sha256:{}", "a".repeat(64)),
22139                declaration: None,
22140                roles: vec![
22141                    WordPressDiscoveryLayoutRoleDocument {
22142                        role: "core",
22143                        status: "unresolved",
22144                        basis: "none",
22145                        reference: None,
22146                        candidate_count: 0,
22147                    },
22148                    WordPressDiscoveryLayoutRoleDocument {
22149                        role: "themes",
22150                        status: "exact",
22151                        basis: "conventional_asset",
22152                        reference: Some(format!("sha256:{}", "d".repeat(64))),
22153                        candidate_count: 1,
22154                    },
22155                    WordPressDiscoveryLayoutRoleDocument {
22156                        role: "plugins",
22157                        status: "exact",
22158                        basis: "conventional_asset",
22159                        reference: Some(format!("sha256:{}", "e".repeat(64))),
22160                        candidate_count: 1,
22161                    },
22162                    WordPressDiscoveryLayoutRoleDocument {
22163                        role: "rest_index",
22164                        status: "exact",
22165                        basis: "structured_advertisement",
22166                        reference: Some(format!("sha256:{}", "f".repeat(64))),
22167                        candidate_count: 1,
22168                    },
22169                ],
22170                skipped_foreign_origin_count: 0,
22171                skipped_sibling_application_count: 0,
22172                conflicting_association_count: 0,
22173            },
22174            page_collection: None,
22175            supplied_session_pages: None,
22176            sources: vec![
22177                WordPressDiscoverySourceDocument {
22178                    kind: "rest_index",
22179                    association: "structured_advertisement",
22180                    resource_reference: format!("sha256:{}", "1".repeat(64)),
22181                    role_reference: Some(format!("sha256:{}", "f".repeat(64))),
22182                    component: None,
22183                    parent_depth: 0,
22184                    outcome: "observed",
22185                    request_attempted: true,
22186                    response_bytes: 256,
22187                    evidence_reference_count: 1,
22188                    evidence_references: vec!["evidence-0001".to_owned()],
22189                    source_page_references: Vec::new(),
22190                    source_supplied_session_page_references: None,
22191                    namespaces: vec!["wp/v2".to_owned(), "termivar-lab/v1".to_owned()],
22192                    theme: None,
22193                    plugin: None,
22194                },
22195                WordPressDiscoverySourceDocument {
22196                    kind: "theme_stylesheet",
22197                    association: "observed_conventional",
22198                    resource_reference: format!("sha256:{}", "2".repeat(64)),
22199                    role_reference: Some(format!("sha256:{}", "d".repeat(64))),
22200                    component: Some(WordPressComponentIdentityDocument {
22201                        kind: "theme",
22202                        slug: "termivar-child".to_owned(),
22203                    }),
22204                    parent_depth: 0,
22205                    outcome: "observed",
22206                    request_attempted: true,
22207                    response_bytes: 256,
22208                    evidence_reference_count: 1,
22209                    evidence_references: vec!["evidence-0002".to_owned()],
22210                    source_page_references: Vec::new(),
22211                    source_supplied_session_page_references: None,
22212                    namespaces: Vec::new(),
22213                    theme: Some(WordPressThemeDiscoveryDocument {
22214                        name: Some("Termivar child".to_owned()),
22215                        version: Some("1.2.3".to_owned()),
22216                        template: Some("termivar-parent".to_owned()),
22217                        requires_wordpress: Some("6.8".to_owned()),
22218                        requires_php: Some("8.1".to_owned()),
22219                        tested_up_to: Some("6.9".to_owned()),
22220                    }),
22221                    plugin: None,
22222                },
22223                WordPressDiscoverySourceDocument {
22224                    kind: "plugin_readme",
22225                    association: "observed_conventional",
22226                    resource_reference: format!("sha256:{}", "3".repeat(64)),
22227                    role_reference: Some(format!("sha256:{}", "e".repeat(64))),
22228                    component: Some(WordPressComponentIdentityDocument {
22229                        kind: "plugin",
22230                        slug: "termivar-metadata-lab".to_owned(),
22231                    }),
22232                    parent_depth: 0,
22233                    outcome: "observed",
22234                    request_attempted: true,
22235                    response_bytes: 256,
22236                    evidence_reference_count: 1,
22237                    evidence_references: vec!["evidence-0003".to_owned()],
22238                    source_page_references: Vec::new(),
22239                    source_supplied_session_page_references: None,
22240                    namespaces: Vec::new(),
22241                    theme: None,
22242                    plugin: Some(WordPressPluginDiscoveryDocument {
22243                        name: Some("Termivar metadata lab".to_owned()),
22244                        stable_tag: Some("9.9.9".to_owned()),
22245                        requires_wordpress: Some("6.8".to_owned()),
22246                        requires_php: Some("8.1".to_owned()),
22247                        tested_up_to: Some("6.9".to_owned()),
22248                    }),
22249                },
22250            ],
22251        });
22252        document
22253    }
22254
22255    #[cfg(all(
22256        feature = "scanning",
22257        feature = "wordpress-review",
22258        feature = "supplied-session-review"
22259    ))]
22260    fn supplied_session_wordpress_assessment_document() -> AssessmentDocument<'static> {
22261        let mut document = discovered_wordpress_assessment_document();
22262        let session = complete_supplied_session_audit_document();
22263        let page_reference = format!("sha256:{}", "b".repeat(64));
22264        let resource_reference = session.resources[0].resource_reference.clone();
22265        let resource_evidence_reference = session.resources[0].evidence_reference.clone();
22266        let discovery = document.wordpress_discovery.as_mut().unwrap();
22267        discovery.schema = WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V4;
22268        discovery.policy_id = WORDPRESS_SUPPLIED_SESSION_DISCOVERY_POLICY_ID;
22269        discovery.supplied_session_pages = Some(WordPressSuppliedSessionPageCollectionDocument {
22270            mode: "committed_supplied_session_resources",
22271            policy_reference: session.policy_reference.clone(),
22272            application_reference: session.application_reference.clone(),
22273            principal_reference: session.principal_reference.clone(),
22274            credential_mechanism: session.credential_mechanism,
22275            session_epoch: 1,
22276            selected_count: 1,
22277            committed_count: 1,
22278            accepted_association_count: 1,
22279            rejected_association_count: 0,
22280            not_established_association_count: 0,
22281            not_evaluated_count: 0,
22282            interpreted_response_bytes: 23,
22283            pages: vec![WordPressSuppliedSessionPageDocument {
22284                page_reference: page_reference.clone(),
22285                resource_reference,
22286                resource_evidence_reference,
22287                acquisition: "reused_supplied_session_response",
22288                association: "accepted",
22289                outcome: "accepted",
22290                interpreted_response_bytes: 23,
22291                evidence_reference_count: 1,
22292                evidence_references: vec!["evidence-0004".to_owned()],
22293                fingerprint_evaluation: "not_selected_in_v1",
22294            }],
22295        });
22296        for source in &mut discovery.sources {
22297            source.source_supplied_session_page_references =
22298                Some(if source.kind == "plugin_readme" {
22299                    vec![page_reference.clone()]
22300                } else {
22301                    Vec::new()
22302                });
22303        }
22304        let item = document
22305            .items
22306            .iter_mut()
22307            .find(|item| item.capability_id == WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID)
22308            .unwrap();
22309        item.evidence_count = 4;
22310        item.evidence_references.push("evidence-0004".to_owned());
22311        document.supplied_session = Some(session);
22312        document
22313    }
22314
22315    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22316    fn page_scoped_wordpress_assessment_document() -> AssessmentDocument<'static> {
22317        let mut document = discovered_wordpress_assessment_document();
22318        let entry_reference = format!("sha256:{}", "8".repeat(64));
22319        let page_reference = format!("sha256:{}", "9".repeat(64));
22320        let discovery = document.wordpress_discovery.as_mut().unwrap();
22321        discovery.schema = WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V3;
22322        discovery.policy_id = WORDPRESS_PAGE_SCOPED_DISCOVERY_POLICY_ID;
22323        discovery.page_collection = Some(WordPressPageCollectionDocument {
22324            mode: "observed",
22325            entry_page_reference: entry_reference.clone(),
22326            candidate_count: 1,
22327            selected_count: 1,
22328            omitted_candidate_count: 0,
22329            reused_response_count: 1,
22330            fetched_response_count: 0,
22331            not_observed_count: 0,
22332            rejected_response_count: 0,
22333            accepted_association_count: 1,
22334            rejected_association_count: 0,
22335            attempted_request_count: 0,
22336            completed_response_count: 1,
22337            committed_response_count: 1,
22338            interpreted_response_bytes: 128,
22339            response_bytes: 0,
22340            pages: vec![WordPressPageSourceDocument {
22341                page_reference: page_reference.clone(),
22342                acquisition: "reused",
22343                association: "accepted",
22344                outcome: "accepted",
22345                request_attempted: false,
22346                interpreted_response_bytes: 128,
22347                response_bytes: 0,
22348                evidence_reference_count: 1,
22349                evidence_references: vec!["evidence-0001".to_owned()],
22350            }],
22351        });
22352        for (ordinal, source) in discovery.sources.iter_mut().enumerate() {
22353            source.evidence_references = vec![format!("evidence-{:04}", ordinal + 2)];
22354            source.source_page_references = if source.kind == "rest_index" {
22355                vec![entry_reference.clone()]
22356            } else {
22357                vec![page_reference.clone()]
22358            };
22359        }
22360        let item = document
22361            .items
22362            .iter_mut()
22363            .find(|item| item.capability_id == WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID)
22364            .unwrap();
22365        item.evidence_count = 4;
22366        item.evidence_references = (1..=4)
22367            .map(|ordinal| format!("evidence-{ordinal:04}"))
22368            .collect();
22369        document
22370    }
22371
22372    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22373    fn profiled_wordpress_assessment_document() -> AssessmentDocument<'static> {
22374        let mut document = observed_wordpress_assessment_document();
22375        let audit = document.wordpress_review.as_mut().unwrap();
22376        audit.schema = "security.wordpress-review-audit/v2";
22377        audit.catalog.as_mut().unwrap().revision = "synthetic-v2".to_owned();
22378        audit.components[0].versions[0].value = "2.4.0-beta1".to_owned();
22379        let advisory = &mut audit.advisories[0];
22380        advisory.affected_ranges = vec![WordPressAffectedRangeDocument {
22381            lower: Some(WordPressVersionEndpointDocument {
22382                declared: "2.4.0-beta0".to_owned(),
22383                inclusive: true,
22384            }),
22385            upper: Some(WordPressVersionEndpointDocument {
22386                declared: "2.4.0".to_owned(),
22387                inclusive: false,
22388            }),
22389        }];
22390        advisory.fixed_versions = vec!["2.4.0".to_owned()];
22391        advisory.comparison_profile = Some("php-release-subset/v1");
22392        advisory.version_resolution = Some("supported_equivalent");
22393        advisory.version_resolution_reason = Some("single_supported_version");
22394        advisory.version_relation = "within_declared_range";
22395        advisory.applicability = "candidate_match_on_declared_facts";
22396        document
22397    }
22398
22399    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22400    fn saved_inventory_wordpress_assessment_document() -> AssessmentDocument<'static> {
22401        let mut document = observed_wordpress_assessment_document();
22402        document.items[0].fingerprint =
22403            "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
22404        let audit = document.wordpress_review.as_mut().unwrap();
22405        audit.schema = "security.wordpress-review-audit/v3";
22406        audit.catalog_schema = Some(WORDPRESS_ADVISORY_CATALOG_SCHEMA);
22407        audit.inventory_import = Some(WordPressInventoryImportDocument {
22408            coverage: WordPressInventoryCoverageDocument {
22409                core: "not_supplied",
22410                plugins: "supplied",
22411                themes: "not_supplied",
22412            },
22413            component_count: 1,
22414            limitations: Vec::new(),
22415            inputs: vec![WordPressInventoryInputDocument {
22416                class: "wp_cli_plugins_json",
22417                byte_length: 71,
22418                sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
22419                    .to_owned(),
22420            }],
22421        });
22422        audit.components.push(WordPressComponentDocument {
22423            identity: WordPressComponentIdentityDocument {
22424                kind: "plugin",
22425                slug: "synthetic-plugin".to_owned(),
22426            },
22427            evidence_class: "operator_supplied",
22428            identity_sources: vec!["operator_context"],
22429            confidence_classes: vec!["operator_assertion"],
22430            versions: vec![WordPressVersionEvidenceDocument {
22431                value: "1.2.3-vendor".to_owned(),
22432                source: "operator_context",
22433                confidence: "operator_assertion",
22434            }],
22435            activation: Some("active"),
22436            inventory_status: Some("active"),
22437        });
22438        audit.component_count = audit.components.len();
22439        document
22440    }
22441
22442    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22443    fn external_wordpress_assessment_document() -> AssessmentDocument<'static> {
22444        external_wordpress_assessment_document_from_bytes(include_bytes!(
22445            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
22446        ))
22447    }
22448
22449    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22450    fn external_wordpress_assessment_document_from_bytes(
22451        bytes: &[u8],
22452    ) -> AssessmentDocument<'static> {
22453        let catalog = crate::wordpress_review::parse_wordfence_v3_production(bytes).unwrap();
22454        let inputs = crate::wordpress_review::WordPressReviewInputs::new(None, None)
22455            .with_wordfence_v3_catalog(catalog)
22456            .unwrap();
22457        let signals = [
22458            crate::wordpress_review::WordPressComponentSignal::same_origin_asset(
22459                WordPressComponentKind::Plugin,
22460                "termivar-fixture-component",
22461            )
22462            .unwrap(),
22463        ];
22464        let result = crate::wordpress_review::evaluate_wordpress_review(&signals, &inputs).unwrap();
22465
22466        let mut document = observed_wordpress_assessment_document();
22467        document.items[0].fingerprint =
22468            "sha256:1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
22469        let audit = document.wordpress_review.as_mut().unwrap();
22470        audit.catalog = None;
22471        audit.catalog_schema = None;
22472        audit.inventory_import = None;
22473        audit.advisories.clear();
22474        audit.advisory_count = 0;
22475        audit.components[0] = WordPressComponentDocument {
22476            identity: WordPressComponentIdentityDocument {
22477                kind: "plugin",
22478                slug: "termivar-fixture-component".to_owned(),
22479            },
22480            evidence_class: "observed_hint",
22481            identity_sources: vec!["same_origin_asset_path"],
22482            confidence_classes: vec!["structural_hint"],
22483            versions: Vec::new(),
22484            activation: None,
22485            inventory_status: None,
22486        };
22487        let external_review = wordpress_external_review_document(result.external_review().unwrap());
22488        audit.schema = if external_review.resource_policy.is_some() {
22489            "security.wordpress-review-audit/v6"
22490        } else {
22491            "security.wordpress-review-audit/v4"
22492        };
22493        audit.external_review = Some(external_review);
22494        document
22495    }
22496
22497    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22498    fn external_fixture_source_association_fingerprint() -> String {
22499        let catalog = crate::wordpress_review::parse_wordfence_v3_production(
22500            &include_bytes!(
22501                "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
22502            )[..],
22503        )
22504        .unwrap();
22505        let inputs = crate::wordpress_review::WordPressReviewInputs::new(None, None)
22506            .with_wordfence_v3_catalog(catalog)
22507            .unwrap();
22508        let signals = [
22509            crate::wordpress_review::WordPressComponentSignal::same_origin_asset(
22510                WordPressComponentKind::Plugin,
22511                "termivar-fixture-component",
22512            )
22513            .unwrap(),
22514        ];
22515        let result = crate::wordpress_review::evaluate_wordpress_review(&signals, &inputs).unwrap();
22516        let evaluation = result
22517            .external_review()
22518            .unwrap()
22519            .evaluations()
22520            .first()
22521            .unwrap();
22522        format!(
22523            "wordfence-source-association-sha256:{}",
22524            lowercase_hex(evaluation.key().source_association_sha256())
22525        )
22526    }
22527
22528    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22529    fn explicitly_profiled_external_wordpress_assessment_document(
22530        profile: WordPressComparisonProfile,
22531    ) -> AssessmentDocument<'static> {
22532        let catalog = crate::wordpress_review::parse_wordfence_v3_production(
22533            &include_bytes!(
22534                "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
22535            )[..],
22536        )
22537        .unwrap();
22538        let context = crate::wordpress_review::parse_wordpress_context(
22539            br#"{
22540              "schema":"security.wordpress-context/v1",
22541              "root":"https://example.test/",
22542              "components":[
22543                {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0"}
22544              ]
22545            }"#,
22546        )
22547        .unwrap();
22548        let inputs = crate::wordpress_review::WordPressReviewInputs::new(Some(context), None)
22549            .with_wordfence_v3_catalog(catalog)
22550            .unwrap()
22551            .with_external_version_profile(profile)
22552            .unwrap();
22553        let signals = [
22554            crate::wordpress_review::WordPressComponentSignal::same_origin_asset(
22555                WordPressComponentKind::Plugin,
22556                "termivar-fixture-component",
22557            )
22558            .unwrap(),
22559        ];
22560        let result = crate::wordpress_review::evaluate_wordpress_review(&signals, &inputs).unwrap();
22561
22562        let mut document = observed_wordpress_assessment_document();
22563        document.items[0].fingerprint =
22564            "sha256:2123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
22565        let audit = document.wordpress_review.as_mut().unwrap();
22566        audit.catalog = None;
22567        audit.catalog_schema = None;
22568        audit.inventory_import = None;
22569        audit.advisories.clear();
22570        audit.advisory_count = 0;
22571        audit.components = result
22572            .components()
22573            .iter()
22574            .map(|component| WordPressComponentDocument {
22575                identity: wordpress_component_identity(component.identity()),
22576                evidence_class: wordpress_evidence_class(component.evidence_class()),
22577                identity_sources: component
22578                    .identity_sources()
22579                    .iter()
22580                    .copied()
22581                    .map(wordpress_evidence_source)
22582                    .collect(),
22583                confidence_classes: component
22584                    .confidence_classes()
22585                    .iter()
22586                    .copied()
22587                    .map(wordpress_confidence)
22588                    .collect(),
22589                versions: component
22590                    .versions()
22591                    .iter()
22592                    .map(|version| WordPressVersionEvidenceDocument {
22593                        value: version.value().to_owned(),
22594                        source: wordpress_evidence_source(version.source()),
22595                        confidence: wordpress_confidence(version.confidence()),
22596                    })
22597                    .collect(),
22598                activation: component.activation().map(wordpress_activation),
22599                inventory_status: component
22600                    .inventory_status()
22601                    .map(wordpress_inventory_entry_status),
22602            })
22603            .collect();
22604        audit.component_count = audit.components.len();
22605        let external_review = wordpress_external_review_document(result.external_review().unwrap());
22606        audit.schema = if external_review.resource_policy.is_some() {
22607            "security.wordpress-review-audit/v6"
22608        } else {
22609            "security.wordpress-review-audit/v5"
22610        };
22611        audit.external_review = Some(external_review);
22612        document
22613    }
22614
22615    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22616    fn source_identity_external_wordpress_assessment_document(
22617        profile: Option<WordPressComparisonProfile>,
22618        collision: bool,
22619    ) -> AssessmentDocument<'static> {
22620        const FIRST_ID: &str = "00000000-0000-4000-8000-000000000001";
22621        let mut source: serde_json::Value = serde_json::from_slice(include_bytes!(
22622            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
22623        ))
22624        .unwrap();
22625        let software = source[FIRST_ID]["software"].as_array_mut().unwrap();
22626        if collision {
22627            let mut alternate = software[0].clone();
22628            alternate["slug"] = serde_json::Value::String("TERMIVAR-FIXTURE-COMPONENT".to_owned());
22629            software.push(alternate);
22630        } else {
22631            software[0]["slug"] =
22632                serde_json::Value::String("Termivar-Fixture-Component".to_owned());
22633        }
22634        let encoded = serde_json::to_vec(&source).unwrap();
22635        let catalog =
22636            crate::wordpress_review::parse_wordfence_v3_production(encoded.as_slice()).unwrap();
22637        let context = profile.map(|_| {
22638            crate::wordpress_review::parse_wordpress_context(
22639                br#"{
22640                  "schema":"security.wordpress-context/v1",
22641                  "root":"https://example.test/",
22642                  "components":[
22643                    {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0"}
22644                  ]
22645                }"#,
22646            )
22647            .unwrap()
22648        });
22649        let inputs = crate::wordpress_review::WordPressReviewInputs::new(context, None)
22650            .with_wordfence_v3_catalog(catalog)
22651            .unwrap();
22652        let inputs = if let Some(profile) = profile {
22653            inputs.with_external_version_profile(profile).unwrap()
22654        } else {
22655            inputs
22656        };
22657        let signals = [
22658            crate::wordpress_review::WordPressComponentSignal::same_origin_asset(
22659                WordPressComponentKind::Plugin,
22660                "termivar-fixture-component",
22661            )
22662            .unwrap(),
22663        ];
22664        let result = crate::wordpress_review::evaluate_wordpress_review(&signals, &inputs).unwrap();
22665
22666        let mut document = profile.map_or_else(external_wordpress_assessment_document, |profile| {
22667            explicitly_profiled_external_wordpress_assessment_document(profile)
22668        });
22669        let audit = document.wordpress_review.as_mut().unwrap();
22670        audit.schema = "security.wordpress-review-audit/v6";
22671        audit.external_review = Some(wordpress_external_review_document(
22672            result.external_review().unwrap(),
22673        ));
22674        document
22675    }
22676
22677    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22678    fn unresolved_identity_external_wordpress_assessment_document(
22679        profile: Option<WordPressComparisonProfile>,
22680    ) -> AssessmentDocument<'static> {
22681        unresolved_identity_external_wordpress_assessment_document_with_slug(
22682            profile,
22683            "vendor/plugin.php",
22684        )
22685    }
22686
22687    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
22688    fn unresolved_identity_external_wordpress_assessment_document_with_slug(
22689        profile: Option<WordPressComparisonProfile>,
22690        source_slug: &str,
22691    ) -> AssessmentDocument<'static> {
22692        const FIRST_ID: &str = "00000000-0000-4000-8000-000000000001";
22693        let mut source: serde_json::Value = serde_json::from_slice(include_bytes!(
22694            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
22695        ))
22696        .unwrap();
22697        source[FIRST_ID]["software"][0]["slug"] = serde_json::Value::String(source_slug.to_owned());
22698        let encoded = serde_json::to_vec(&source).unwrap();
22699        let catalog =
22700            crate::wordpress_review::parse_wordfence_v3_production(encoded.as_slice()).unwrap();
22701        let context = profile.map(|_| {
22702            crate::wordpress_review::parse_wordpress_context(
22703                br#"{
22704                  "schema":"security.wordpress-context/v1",
22705                  "root":"https://example.test/",
22706                  "components":[
22707                    {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0"}
22708                  ]
22709                }"#,
22710            )
22711            .unwrap()
22712        });
22713        let inputs = crate::wordpress_review::WordPressReviewInputs::new(context, None)
22714            .with_wordfence_v3_catalog(catalog)
22715            .unwrap();
22716        let inputs = if let Some(profile) = profile {
22717            inputs.with_external_version_profile(profile).unwrap()
22718        } else {
22719            inputs
22720        };
22721        let signals = [
22722            crate::wordpress_review::WordPressComponentSignal::same_origin_asset(
22723                WordPressComponentKind::Plugin,
22724                "termivar-fixture-component",
22725            )
22726            .unwrap(),
22727        ];
22728        let result = crate::wordpress_review::evaluate_wordpress_review(&signals, &inputs).unwrap();
22729
22730        let mut document = profile.map_or_else(external_wordpress_assessment_document, |profile| {
22731            explicitly_profiled_external_wordpress_assessment_document(profile)
22732        });
22733        let audit = document.wordpress_review.as_mut().unwrap();
22734        audit.schema = "security.wordpress-review-audit/v6";
22735        audit.external_review = Some(wordpress_external_review_document(
22736            result.external_review().unwrap(),
22737        ));
22738        document
22739    }
22740
22741    #[cfg(feature = "scanning")]
22742    #[test]
22743    fn assessment_json_schema_is_additive_minimized_and_linkage_preserving() {
22744        let document = complete_assessment_document();
22745        let rendered =
22746            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
22747        let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
22748        assert_eq!(value["schema"], ASSESSMENT_REPORT_DOCUMENT_SCHEMA);
22749        assert_eq!(
22750            value["source_schema"],
22751            crate::web_runtime::ASSESSMENT_RUN_REPORT_SCHEMA
22752        );
22753        assert_eq!(value["run_schema"], termivar_core::RUN_REPORT_SCHEMA);
22754        assert_eq!(value["profile_schema"], "venom.scan-profile/v1");
22755        assert_eq!(value["profile"], "web-review");
22756        assert_eq!(value["status"], "complete");
22757        assert_eq!(value["subject_count"], 1);
22758        assert_eq!(value["item_count"], 3);
22759        let item_keys = value["items"][0]
22760            .as_object()
22761            .unwrap()
22762            .keys()
22763            .map(String::as_str)
22764            .collect::<BTreeSet<_>>();
22765        assert_eq!(
22766            item_keys,
22767            BTreeSet::from([
22768                "candidate_evidence_references",
22769                "capability_id",
22770                "case_reference",
22771                "category",
22772                "claim_basis",
22773                "confidence_ppm",
22774                "control_evidence_references",
22775                "cwe",
22776                "disposition",
22777                "evidence_count",
22778                "evidence_references",
22779                "fingerprint",
22780                "outcome_reference",
22781                "redacted_summary",
22782                "remediation",
22783                "schema",
22784                "severity",
22785                "subject_reference",
22786                "title",
22787                "verification_stage",
22788            ])
22789        );
22790        assert!(value["items"][0].get("presentation_target").is_none());
22791        assert_eq!(value["items"][0]["disposition"], "informational");
22792        assert_eq!(value["items"][0]["claim_basis"], "observation");
22793        assert_eq!(
22794            value["items"][0]["evidence_references"],
22795            serde_json::json!(["evidence-0000"])
22796        );
22797        assert_eq!(value["items"][1]["disposition"], "needs_review");
22798        assert_eq!(value["items"][1]["claim_basis"], "differential");
22799        assert_eq!(
22800            value["items"][1]["control_evidence_references"],
22801            serde_json::json!(["evidence-0001"])
22802        );
22803        assert_eq!(
22804            value["items"][1]["candidate_evidence_references"],
22805            serde_json::json!(["evidence-0002"])
22806        );
22807        assert_eq!(value["items"][2]["disposition"], "confirmed");
22808        assert_eq!(value["items"][2]["claim_basis"], "verifier_transition");
22809        assert_eq!(value["items"][2]["case_reference"], "case-0000");
22810        assert_eq!(value["items"][2]["outcome_reference"], "outcome-0000");
22811        assert_eq!(value["items"][2]["verification_stage"], "active");
22812        assert!(!rendered.contains(REPORT_DOCUMENT_SCHEMA));
22813        for forbidden_key in [
22814            "target",
22815            "authorized_origin",
22816            "body",
22817            "headers",
22818            "cookie",
22819            "authorization",
22820            "case_id",
22821            "outcome_id",
22822            "verifier_rule_id",
22823        ] {
22824            assert!(value.get(forbidden_key).is_none());
22825        }
22826    }
22827
22828    #[cfg(feature = "scanning")]
22829    #[test]
22830    fn assessment_dispositions_claim_bases_and_opaque_links_are_visible_in_every_format() {
22831        let document = complete_assessment_document();
22832        for format in ReportGenerator::available_formats() {
22833            let rendered = render_assessment_with_limit(&document, *format, usize::MAX).unwrap();
22834            for token in [
22835                "informational",
22836                "needs_review",
22837                "confirmed",
22838                "observation",
22839                "differential",
22840                "verifier_transition",
22841                "evidence-0000",
22842                "evidence-0001",
22843                "evidence-0002",
22844                "evidence-0003",
22845                "evidence-0004",
22846                "case-0000",
22847                "outcome-0000",
22848                "active",
22849            ] {
22850                assert!(rendered.contains(token), "{format:?} omitted {token}");
22851            }
22852        }
22853    }
22854
22855    #[cfg(feature = "scanning")]
22856    #[test]
22857    fn assessment_human_reports_lead_with_bounded_decisions_and_actionable_items() {
22858        let document = complete_assessment_document();
22859        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
22860        let markdown =
22861            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
22862        assert_eq!(
22863            render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap(),
22864            html
22865        );
22866        assert_eq!(
22867            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap(),
22868            markdown
22869        );
22870
22871        for (rendered, headings) in [
22872            (
22873                html.as_str(),
22874                [
22875                    "<h2>Decision overview</h2>",
22876                    "<h2>Actionable items</h2>",
22877                    "<h2>Technical audit appendix</h2>",
22878                ],
22879            ),
22880            (
22881                markdown.as_str(),
22882                [
22883                    "## Decision overview",
22884                    "## Actionable items",
22885                    "## Technical audit appendix",
22886                ],
22887            ),
22888        ] {
22889            let positions = headings.map(|heading| {
22890                rendered
22891                    .find(heading)
22892                    .unwrap_or_else(|| panic!("missing human-report heading {heading}"))
22893            });
22894            assert!(positions[0] < positions[1] && positions[1] < positions[2]);
22895            for literal in [
22896                "These counts describe typed assessment items, not a count of confirmed vulnerabilities.",
22897                "Verifier-bound confirmed items",
22898                "Needs-review candidates",
22899                "Informational observations",
22900                "What was observed",
22901                "Opaque assessment subject reference (not proof of affectedness or location)",
22902                "Assessment target kind (resource location withheld)",
22903                "Collection and principal context",
22904                "What was not established",
22905                "Recommended action (not a verified fix)",
22906                "Safe verification guidance",
22907                "Item schema",
22908                "Evidence reference count",
22909                "Recommendation ID",
22910                "report integrity does not establish target truth or remediation",
22911            ] {
22912                assert!(rendered.contains(literal), "human report omitted {literal}");
22913            }
22914            for title in [
22915                "Verifier-authorized transition",
22916                "CORS policy relationship warrants review",
22917                "Strict transport policy was not observed",
22918            ] {
22919                assert_eq!(rendered.matches(title).count(), 1, "{title}");
22920            }
22921            let confirmed = rendered.find("Verifier-authorized transition").unwrap();
22922            let needs_review = rendered
22923                .find("CORS policy relationship warrants review")
22924                .unwrap();
22925            let informational = rendered
22926                .find("Strict transport policy was not observed")
22927                .unwrap();
22928            assert!(confirmed < needs_review && needs_review < informational);
22929            assert!(rendered.contains(
22930                "This observation alone does not establish a vulnerability, exploitability, impact, or remediation."
22931            ));
22932            assert!(rendered.contains(
22933                "This review candidate is not a confirmed vulnerability; exploitability, impact, and remediation were not established."
22934            ));
22935            assert!(rendered.contains(
22936                "Confirmation applies only to the cited verifier transition; broader impact, root cause, and remediation remain unestablished unless separately evidenced."
22937            ));
22938            assert!(rendered.contains("report integrity alone does not verify remediation"));
22939            assert!(rendered.contains(
22940                "only if the exact target, context, case, and separate current authorization are established"
22941            ));
22942            assert!(rendered.contains(
22943                "This is evidence-assurance order, not severity, impact, or remediation priority."
22944            ));
22945            assert!(
22946                rendered.contains("Unassigned severity does not mean low or zero")
22947                    || rendered.contains("unassigned does not mean low or zero")
22948            );
22949            assert_eq!(
22950                rendered
22951                    .matches("Assessment target kind (resource location withheld)")
22952                    .count(),
22953                3
22954            );
22955            assert_eq!(rendered.matches("assessment_subject").count(), 3);
22956        }
22957
22958        for (label, count) in [
22959            ("Verifier-bound confirmed items", 1),
22960            ("Needs-review candidates", 1),
22961            ("Informational observations", 1),
22962        ] {
22963            assert!(html.contains(&format!("<strong>{count}</strong><span>{label}</span>")));
22964            assert!(markdown.contains(&format!("- {label}: `{count}`")));
22965        }
22966
22967        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
22968        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
22969        for machine in [&json, &csv] {
22970            for presentation_only in [
22971                "Decision overview",
22972                "Actionable items",
22973                "Assessment target kind (resource location withheld)",
22974                "presentation_target",
22975                "assessment_subject",
22976                "What was not established",
22977                "Safe verification guidance",
22978                "Technical audit appendix",
22979            ] {
22980                assert!(!machine.contains(presentation_only));
22981            }
22982        }
22983    }
22984
22985    #[cfg(feature = "scanning")]
22986    #[test]
22987    fn empty_assessment_human_reports_do_not_claim_security_or_complete_coverage() {
22988        let mut document = observation_assessment_document("test.observation@1");
22989        document.items.clear();
22990        document.item_count = 0;
22991        for format in [ReportFormat::Html, ReportFormat::Markdown] {
22992            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
22993            assert!(rendered.contains(
22994                "No assessment item was projected. This does not establish that the application is secure or that coverage was exhaustive."
22995            ));
22996            assert!(rendered.contains(
22997                "No assessment items were projected. This is not evidence that the application is secure or that assessment coverage was complete."
22998            ));
22999            assert!(rendered.contains("Verifier-bound confirmed items"));
23000            assert!(rendered.contains("Needs-review candidates"));
23001            assert!(rendered.contains("Informational observations"));
23002            assert!(!rendered.contains("Result: secure"));
23003            assert!(!rendered.contains("Coverage: complete"));
23004        }
23005    }
23006
23007    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23008    #[test]
23009    fn actionable_items_do_not_invent_item_level_principal_attribution() {
23010        let mut document = complete_assessment_document();
23011        document.supplied_session = Some(complete_supplied_session_audit_document());
23012        for format in [ReportFormat::Html, ReportFormat::Markdown] {
23013            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
23014            let limitation = "The run includes a supplied-session audit, but this item is not assigned to a principal by the current report contract.";
23015            assert_eq!(rendered.matches(limitation).count(), 3);
23016            assert!(rendered.contains("operator_declared"));
23017            assert!(rendered.contains("Continuous authentication established"));
23018            assert!(!rendered.contains("observed by fixture-user"));
23019            assert!(!rendered.contains("authenticated finding"));
23020        }
23021    }
23022
23023    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23024    #[test]
23025    fn absent_supplied_session_audit_preserves_every_rendered_contract() {
23026        let document = complete_assessment_document();
23027        assert!(document.supplied_session.is_none());
23028
23029        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23030        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23031        assert!(parsed.get("supplied_session").is_none());
23032
23033        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
23034        assert!(!csv.contains("supplied_session_audit"));
23035
23036        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
23037        assert!(!html.contains("Supplied session assessment audit"));
23038
23039        let markdown =
23040            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
23041        assert!(!markdown.contains("Supplied session assessment audit"));
23042    }
23043
23044    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23045    #[test]
23046    fn supplied_session_audit_is_value_free_audit_only_and_visible_in_every_format() {
23047        let mut document = observation_assessment_document("test.observation@1");
23048        document.supplied_session = Some(complete_supplied_session_audit_document());
23049        document.validate().unwrap();
23050
23051        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23052        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23053        let audit = &parsed["supplied_session"];
23054        assert_eq!(audit["schema"], SUPPLIED_SESSION_AUDIT_SCHEMA);
23055        assert_eq!(audit["capability_id"], SUPPLIED_SESSION_CAPABILITY_ID);
23056        assert_eq!(
23057            audit["principal_reference"],
23058            "supplied-session-principal-0001"
23059        );
23060        assert_eq!(audit["principal_alias"], "fixture-user");
23061        assert_eq!(audit["principal_assurance"], "operator_declared");
23062        assert_eq!(audit["credential_mechanism"], "authorization_header");
23063        assert_eq!(audit["outcome"], "complete");
23064        assert_eq!(audit["coverage"], "complete");
23065        assert_eq!(audit["response_byte_limit"], 65_536);
23066        assert_eq!(audit["response_byte_limit_exceeded"], false);
23067        assert_eq!(audit["checkpoints"].as_array().unwrap().len(), 2);
23068        assert!(audit["checkpoints"][0]["evidence_reference"]
23069            .as_str()
23070            .unwrap()
23071            .starts_with("supplied-session-checkpoint-evidence-sha256:"));
23072        assert_eq!(audit["resources"].as_array().unwrap().len(), 1);
23073        assert!(audit["resources"][0]["evidence_reference"]
23074            .as_str()
23075            .unwrap()
23076            .starts_with("supplied-session-resource-evidence-sha256:"));
23077        assert_eq!(audit["exploit_execution"], "not_performed");
23078        assert_eq!(audit["impact_validation"], "not_performed");
23079        assert!(json.contains("supplied-session-resource-sha256:"));
23080        for forbidden in ["Authorization", "Bearer ", "https://", "cookie"] {
23081            assert!(!json.contains(forbidden));
23082        }
23083
23084        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
23085        assert!(csv.contains("supplied_session_audit"));
23086        assert!(csv.contains("subject_boundary") || csv.contains("terminal"));
23087        assert!(csv.contains("supplied-session-resource-sha256:"));
23088        assert!(csv.contains("supplied-session-resource-evidence-sha256:"));
23089        assert!(csv.contains("supplied-session-checkpoint-evidence-sha256:"));
23090
23091        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
23092        assert!(html.contains("Supplied session assessment audit"));
23093        assert!(html.contains("Health oracle"));
23094        assert!(html.contains("json_boolean_true"));
23095        assert!(html.contains("supplied-session-health-field-sha256:"));
23096        assert!(html.contains("Health checkpoints"));
23097        assert!(html.contains("Protected resources"));
23098        assert!(html.contains("supplied-session-resource-evidence-sha256:"));
23099        assert!(html.contains("supplied-session-checkpoint-evidence-sha256:"));
23100
23101        let markdown =
23102            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
23103        assert!(markdown.contains("\n### Supplied session assessment audit\n"));
23104        assert!(!markdown.contains("\n## Supplied session assessment audit\n"));
23105        assert!(markdown.contains("Health oracle"));
23106        assert!(markdown.contains("json_boolean_true"));
23107        assert!(markdown.contains("supplied-session-health-field-sha256:"));
23108        assert!(markdown.contains("\n#### Health checkpoints\n"));
23109        assert!(markdown.contains("\n#### Protected resources\n"));
23110        assert!(!markdown.contains("\n### Health checkpoints\n"));
23111        assert!(!markdown.contains("\n### Protected resources\n"));
23112        assert!(markdown.contains("supplied-session-resource-evidence-sha256:"));
23113        assert!(markdown.contains("supplied-session-checkpoint-evidence-sha256:"));
23114    }
23115
23116    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23117    #[test]
23118    fn cookie_supplied_session_audit_is_strict_value_free_and_visible_in_every_format() {
23119        let mut document = observation_assessment_document("test.observation@1");
23120        document.supplied_session = Some(complete_cookie_supplied_session_audit_document());
23121        document
23122            .supplied_session
23123            .as_ref()
23124            .unwrap()
23125            .validate(&document.items)
23126            .unwrap();
23127        document.validate().unwrap();
23128
23129        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23130        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23131        let audit = &parsed["supplied_session"];
23132        assert_eq!(audit["schema"], SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA);
23133        assert_eq!(audit["credential_mechanism"], "cookie_jar");
23134        assert_eq!(audit["cookie_policy"]["declared_count"], 2);
23135        assert_eq!(audit["cookie_policy"]["host_only_count"], 1);
23136        assert_eq!(audit["cookie_policy"]["domain_count"], 1);
23137        assert_eq!(
23138            audit["cookie_policy"]["browser_semantics"],
23139            "attributes_preserved_not_browser_csrf_emulation"
23140        );
23141        assert_eq!(
23142            audit["cookie_lifecycle"]["unselected_update_response_count"],
23143            1
23144        );
23145        assert_eq!(audit["cookie_lifecycle"]["updates_applied"], 0);
23146        for forbidden in [
23147            "cookie-session-secret-canary",
23148            "selected-cookie-name",
23149            "example.test/private",
23150            "session-cookie.tsv",
23151        ] {
23152            assert!(!json.contains(forbidden));
23153        }
23154
23155        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
23156        assert!(csv.contains("attributes_preserved_not_browser_csrf_emulation"));
23157        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
23158        assert!(html.contains("Declared cookies"));
23159        assert!(html.contains("Cookie updates applied"));
23160        let markdown =
23161            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
23162        assert!(markdown.contains("Declared cookies"));
23163        assert!(markdown.contains("Cookie updates applied"));
23164    }
23165
23166    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23167    #[test]
23168    fn form_login_supplied_session_audit_is_value_free_and_keeps_the_epoch_transition() {
23169        let mut document = observation_assessment_document("test.observation@1");
23170        document.supplied_session = Some(complete_form_login_supplied_session_audit_document());
23171        document.validate().unwrap();
23172
23173        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23174        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23175        let audit = &parsed["supplied_session"];
23176        assert_eq!(audit["schema"], SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA);
23177        assert_eq!(audit["credential_mechanism"], "cookie_jar");
23178        assert_eq!(audit["credential_acquisition"], "bounded_form_login");
23179        assert_eq!(audit["login"]["max_attempts"], 1);
23180        assert_eq!(audit["login"]["attempt_count"], 1);
23181        assert_eq!(audit["login"]["form_outcome"], "matched");
23182        assert_eq!(audit["login"]["submit_outcome"], "cookie_acquired");
23183        assert_eq!(audit["login"]["initial_epoch"], 0);
23184        assert_eq!(audit["login"]["final_epoch"], 1);
23185        assert_eq!(audit["cookie_lifecycle"]["initial_epoch"], 0);
23186        assert_eq!(audit["cookie_lifecycle"]["final_epoch"], 1);
23187        assert_eq!(audit["dispatched_request_count"], 5);
23188        assert_eq!(audit["response_bytes"], 113);
23189        for forbidden in [
23190            "S07-username-secret",
23191            "S07-password-secret",
23192            "S07-csrf-secret",
23193            "session-cookie-secret",
23194            "username_field",
23195            "password_field",
23196            "csrf_field",
23197        ] {
23198            assert!(!json.contains(forbidden));
23199        }
23200
23201        for format in [
23202            ReportFormat::Csv,
23203            ReportFormat::Html,
23204            ReportFormat::Markdown,
23205        ] {
23206            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
23207            assert!(rendered.contains("bounded_form_login"));
23208            assert!(rendered.contains("cookie_acquired"));
23209            assert!(rendered.contains("supplied-session-login-sha256:"));
23210            assert!(!rendered.contains("S07-password-secret"));
23211        }
23212
23213        let mut invalid = complete_form_login_supplied_session_audit_document();
23214        invalid.login.as_mut().unwrap().initial_epoch = 1;
23215        assert_eq!(invalid.validate(&[]), Err(ReportError::Serialization));
23216        let mut invalid = complete_form_login_supplied_session_audit_document();
23217        invalid.login.as_mut().unwrap().acquired_cookie_count = 0;
23218        assert_eq!(invalid.validate(&[]), Err(ReportError::Serialization));
23219        let mut invalid = complete_form_login_supplied_session_audit_document();
23220        invalid.login.as_mut().unwrap().form_error_code = Some("S07-password-secret");
23221        assert_eq!(invalid.validate(&[]), Err(ReportError::Serialization));
23222        let mut invalid = complete_form_login_supplied_session_audit_document();
23223        invalid.login = None;
23224        assert_eq!(invalid.validate(&[]), Err(ReportError::Serialization));
23225        let mut invalid = complete_form_login_supplied_session_audit_document();
23226        invalid
23227            .cookie_lifecycle
23228            .as_mut()
23229            .unwrap()
23230            .unselected_update_response_count = 4;
23231        assert_eq!(invalid.validate(&[]), Err(ReportError::Serialization));
23232
23233        let failed_login = unavailable_form_login_supplied_session_audit_document();
23234        failed_login.validate(&[]).unwrap();
23235        let failure_json = failed_login.wire_json().unwrap();
23236        let failure: serde_json::Value = serde_json::from_str(&failure_json).unwrap();
23237        assert_eq!(failure["outcome"], "login_form_unavailable");
23238        assert_eq!(failure["coverage"], "none");
23239        assert_eq!(failure["login"]["form_outcome"], "missing");
23240        assert_eq!(failure["login"]["form_error_code"], "missing_csrf");
23241        assert_eq!(failure["login"]["submit_outcome"], "not_dispatched");
23242        assert_eq!(failure["login"]["final_epoch"], 0);
23243        assert_eq!(failure["cookie_lifecycle"]["final_epoch"], 0);
23244        assert_eq!(failure["dispatched_request_count"], 1);
23245        assert_eq!(failure["response_bytes"], 41);
23246        for forbidden in [
23247            "S07-username-secret",
23248            "S07-password-secret",
23249            "S07-csrf-secret",
23250            "session-cookie-secret",
23251        ] {
23252            assert!(!failure_json.contains(forbidden));
23253        }
23254
23255        for forged_outcome in ["runtime_limit", "cancelled"] {
23256            let mut invalid = unavailable_form_login_supplied_session_audit_document();
23257            invalid.outcome = forged_outcome;
23258            assert_eq!(
23259                invalid.validate(&[]),
23260                Err(ReportError::Serialization),
23261                "missing form state was accepted as {forged_outcome}"
23262            );
23263        }
23264
23265        let mut unavailable_body = unavailable_form_login_supplied_session_audit_document();
23266        let login = unavailable_body.login.as_mut().unwrap();
23267        login.form_outcome = "ineligible_response";
23268        login.form_error_code = Some("inexact_final_target");
23269        login.page_body_state = "unavailable";
23270        assert_eq!(
23271            unavailable_body.validate(&[]),
23272            Err(ReportError::Serialization)
23273        );
23274
23275        let unavailable_cookie = unavailable_form_login_cookie_supplied_session_audit_document();
23276        unavailable_cookie.validate(&[]).unwrap();
23277        let unavailable_cookie_json = unavailable_cookie.wire_json().unwrap();
23278        let unavailable_cookie_value: serde_json::Value =
23279            serde_json::from_str(&unavailable_cookie_json).unwrap();
23280        assert_eq!(
23281            unavailable_cookie_value["outcome"],
23282            "login_cookie_unavailable"
23283        );
23284        assert_eq!(
23285            unavailable_cookie_value["login"]["submit_outcome"],
23286            "cookie_unavailable"
23287        );
23288        assert_eq!(
23289            unavailable_cookie_value["checkpoints"],
23290            serde_json::json!([])
23291        );
23292        assert_eq!(
23293            unavailable_cookie_value["resources"][0]["outcome"],
23294            "not_dispatched"
23295        );
23296
23297        let mut unavailable_redirect =
23298            unavailable_form_login_cookie_supplied_session_audit_document();
23299        unavailable_redirect.outcome = "login_submit_unavailable";
23300        let login = unavailable_redirect.login.as_mut().unwrap();
23301        login.submit_outcome = "redirect_refused";
23302        login.submit_status = Some(302);
23303        login.submit_body_state = "unavailable";
23304        assert_eq!(
23305            unavailable_redirect.validate(&[]),
23306            Err(ReportError::Serialization)
23307        );
23308
23309        let mut forged_epoch = complete_form_login_supplied_session_audit_document();
23310        forged_epoch.login.as_mut().unwrap().final_epoch = 0;
23311        forged_epoch.cookie_lifecycle.as_mut().unwrap().final_epoch = 0;
23312        assert_eq!(forged_epoch.validate(&[]), Err(ReportError::Serialization));
23313    }
23314
23315    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23316    #[test]
23317    fn supplied_session_writer_keeps_v1_and_v2_cookie_contracts_disjoint() {
23318        let mut v1_with_cookie_fields = complete_supplied_session_audit_document();
23319        let v2 = complete_cookie_supplied_session_audit_document();
23320        v1_with_cookie_fields.cookie_policy = v2.cookie_policy;
23321        v1_with_cookie_fields.cookie_lifecycle = v2.cookie_lifecycle;
23322        assert_eq!(
23323            v1_with_cookie_fields.validate(&[]),
23324            Err(ReportError::Serialization)
23325        );
23326
23327        let mut v2_missing_policy = complete_cookie_supplied_session_audit_document();
23328        v2_missing_policy.cookie_policy = None;
23329        assert_eq!(
23330            v2_missing_policy.validate(&[]),
23331            Err(ReportError::Serialization)
23332        );
23333
23334        let mut wrong_partition = complete_cookie_supplied_session_audit_document();
23335        wrong_partition
23336            .cookie_policy
23337            .as_mut()
23338            .unwrap()
23339            .host_only_count = 2;
23340        assert_eq!(
23341            wrong_partition.validate(&[]),
23342            Err(ReportError::Serialization)
23343        );
23344
23345        let mut insecure_same_site_none = complete_cookie_supplied_session_audit_document();
23346        insecure_same_site_none
23347            .cookie_policy
23348            .as_mut()
23349            .unwrap()
23350            .secure_count = 0;
23351        assert_eq!(
23352            insecure_same_site_none.validate(&[]),
23353            Err(ReportError::Serialization)
23354        );
23355
23356        let mut false_update_outcome = complete_cookie_supplied_session_audit_document();
23357        false_update_outcome.outcome = "credential_update_required";
23358        false_update_outcome.coverage = "partial";
23359        assert_eq!(
23360            false_update_outcome.validate(&[]),
23361            Err(ReportError::Serialization)
23362        );
23363
23364        for login_outcome in [
23365            "login_form_unavailable",
23366            "login_submit_unavailable",
23367            "login_cookie_unavailable",
23368        ] {
23369            let mut v1 = complete_supplied_session_audit_document();
23370            let mut v2 = complete_cookie_supplied_session_audit_document();
23371            for document in [&mut v1, &mut v2] {
23372                document.outcome = login_outcome;
23373                document.coverage = "none";
23374                document.checkpoints.clear();
23375                document.resources[0].evidence_reference = None;
23376                document.resources[0].outcome = "not_dispatched";
23377                document.resources[0].status = None;
23378                document.resources[0].response_bytes = 0;
23379                document.dispatched_resource_count = 0;
23380                document.committed_resource_count = 0;
23381                document.dispatched_request_count = 0;
23382                document.response_bytes = 0;
23383                if let Some(lifecycle) = document.cookie_lifecycle.as_mut() {
23384                    lifecycle.unselected_update_response_count = 0;
23385                }
23386                assert_eq!(
23387                    document.validate(&[]),
23388                    Err(ReportError::Serialization),
23389                    "legacy schema accepted V3-only outcome {login_outcome}"
23390                );
23391            }
23392        }
23393    }
23394
23395    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23396    #[test]
23397    fn supplied_session_writer_rejects_v1_cookie_stop_outcomes_but_accepts_v2_states() {
23398        for (outcome, coverage) in [
23399            ("credential_update_required", "partial"),
23400            ("credential_update_unusable", "none"),
23401        ] {
23402            let mut v1 = complete_supplied_session_audit_document();
23403            v1.outcome = outcome;
23404            v1.coverage = coverage;
23405            assert_eq!(
23406                v1.validate(&[]),
23407                Err(ReportError::Serialization),
23408                "V1 accepted cookie-only outcome {outcome}"
23409            );
23410        }
23411
23412        let mut required =
23413            stopped_cookie_supplied_session_audit_document("credential_update_required");
23414        required
23415            .cookie_lifecycle
23416            .as_mut()
23417            .unwrap()
23418            .selected_update_response_count = 1;
23419        required.validate(&[]).unwrap();
23420
23421        let mut unusable =
23422            stopped_cookie_supplied_session_audit_document("credential_update_unusable");
23423        unusable
23424            .cookie_lifecycle
23425            .as_mut()
23426            .unwrap()
23427            .update_classification_failure_count = 1;
23428        unusable.validate(&[]).unwrap();
23429
23430        unusable
23431            .cookie_lifecycle
23432            .as_mut()
23433            .unwrap()
23434            .unselected_update_response_count = 1;
23435        assert_eq!(unusable.validate(&[]), Err(ReportError::Serialization));
23436    }
23437
23438    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23439    #[test]
23440    fn supplied_session_writer_rejects_duplicate_activity_evidence_references() {
23441        let mut duplicate_checkpoint = complete_supplied_session_audit_document();
23442        duplicate_checkpoint.checkpoints[1].evidence_reference = duplicate_checkpoint.checkpoints
23443            [0]
23444        .evidence_reference
23445        .clone();
23446        assert_eq!(
23447            duplicate_checkpoint.validate(&[]),
23448            Err(ReportError::Serialization)
23449        );
23450
23451        let mut duplicate_resource = complete_supplied_session_audit_document();
23452        duplicate_resource.checkpoints[1].phase = "subject_boundary";
23453        duplicate_resource
23454            .checkpoints
23455            .push(AssessmentSuppliedSessionCheckpointDocument {
23456                sequence: 2,
23457                phase: "terminal",
23458                after_subject_count: 2,
23459                evidence_reference: Some(format!(
23460                    "supplied-session-checkpoint-evidence-sha256:{}",
23461                    "8".repeat(64)
23462                )),
23463                outcome: "healthy",
23464                status: Some(200),
23465                body_state: "complete",
23466                predicate: "matched",
23467                response_bytes: 31,
23468                epoch: None,
23469            });
23470        duplicate_resource
23471            .resources
23472            .push(AssessmentSuppliedSessionResourceDocument {
23473                sequence: 1,
23474                resource_reference: format!("supplied-session-resource-sha256:{}", "9".repeat(64)),
23475                evidence_reference: duplicate_resource.resources[0].evidence_reference.clone(),
23476                outcome: "committed",
23477                status: Some(200),
23478                response_bytes: 29,
23479                epoch: 1,
23480            });
23481        duplicate_resource.selected_resource_count = 2;
23482        duplicate_resource.dispatched_resource_count = 2;
23483        duplicate_resource.committed_resource_count = 2;
23484        duplicate_resource.dispatched_request_count = 5;
23485        duplicate_resource.response_bytes = 119;
23486        assert_eq!(
23487            duplicate_resource.validate(&[]),
23488            Err(ReportError::Serialization)
23489        );
23490    }
23491
23492    #[cfg(all(feature = "scanning", feature = "supplied-session-review"))]
23493    #[test]
23494    fn supplied_session_writer_preserves_an_honest_single_chunk_byte_overrun() {
23495        let mut overrun = complete_supplied_session_audit_document();
23496        overrun.resources[0].response_bytes = MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES + 1;
23497        overrun.response_bytes = MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES + 37;
23498        overrun.response_byte_limit = MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES;
23499        overrun.response_byte_limit_exceeded = true;
23500        assert!(overrun.validate(&[]).is_ok());
23501
23502        overrun.response_byte_limit_exceeded = false;
23503        assert_eq!(overrun.validate(&[]), Err(ReportError::Serialization));
23504
23505        overrun.response_byte_limit = 0;
23506        overrun.response_byte_limit_exceeded = true;
23507        assert_eq!(overrun.validate(&[]), Err(ReportError::Serialization));
23508    }
23509
23510    #[cfg(all(feature = "scanning", feature = "authorization-review"))]
23511    #[test]
23512    fn absent_authorization_audit_adds_no_wire_field_row_or_section() {
23513        let document = complete_assessment_document();
23514        assert!(document.authorization_review.is_none());
23515
23516        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23517        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23518        assert!(parsed.get("authorization_review").is_none());
23519
23520        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
23521        assert!(!csv.contains("authorization_review_audit"));
23522
23523        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
23524        assert!(!html.contains("Resource authorization review audit"));
23525
23526        let markdown =
23527            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
23528        assert!(!markdown.contains("Resource authorization review audit"));
23529    }
23530
23531    #[cfg(all(feature = "scanning", feature = "websocket-review"))]
23532    #[test]
23533    fn websocket_review_writer_is_value_free_strict_and_honest_in_every_format() {
23534        let mut document = observation_assessment_document("ordinary-observation");
23535        document.items.clear();
23536        document.item_count = 0;
23537        document.websocket_review = Some(websocket_review_document());
23538
23539        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23540        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23541        let audit = &parsed["websocket_review"];
23542        assert_eq!(audit["schema"], WEBSOCKET_REVIEW_AUDIT_SCHEMA);
23543        assert!(audit.get("supplied_session_context").is_none());
23544        assert_eq!(audit["coverage"]["transport_read_bytes"], 241);
23545        assert_eq!(audit["coverage"]["accounted_transport_response_bytes"], 241);
23546        assert_eq!(audit["methodology"]["physical_frame_count"], "unavailable");
23547        assert_eq!(audit["messages"].as_array().unwrap().len(), 2);
23548        for forbidden in [
23549            "observed_response_reference",
23550            "outbound_frame_count",
23551            "inbound_frame_count",
23552            "max_frames",
23553            "wss://",
23554            "payload",
23555            "private-subprotocol",
23556            "backend error",
23557        ] {
23558            assert!(!json.contains(forbidden));
23559        }
23560
23561        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
23562        assert!(csv.contains("websocket_review_audit"));
23563        assert!(csv.contains("accounted_transport_response_bytes"));
23564        assert!(!csv.contains("observed_response_reference"));
23565
23566        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
23567        let markdown =
23568            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
23569        for rendered in [&html, &markdown] {
23570            assert!(rendered.contains("WebSocket protocol review audit"));
23571            assert!(rendered.contains("encrypted TLS records"));
23572            assert!(rendered.contains("websocket-message-revision-sha256:"));
23573            assert!(!rendered.contains("observed_response_reference"));
23574        }
23575
23576        document
23577            .websocket_review
23578            .as_mut()
23579            .unwrap()
23580            .coverage
23581            .transport_read_bytes = 14;
23582        document
23583            .websocket_review
23584            .as_mut()
23585            .unwrap()
23586            .coverage
23587            .accounted_transport_response_bytes = 14;
23588        assert_eq!(document.validate(), Err(ReportError::Serialization));
23589    }
23590
23591    #[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
23592    #[test]
23593    fn xml_external_entity_review_writer_is_value_free_strict_and_rendered_everywhere() {
23594        let mut document = xml_external_entity_assessment_document();
23595
23596        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23597        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23598        comparison::import_assessment_summary(json.as_bytes()).unwrap();
23599        let audit = &parsed["xml_external_entity_review"];
23600        assert_eq!(audit["schema"], XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA);
23601        assert_eq!(audit["policy"]["method"], "post");
23602        assert_eq!(audit["target"]["request_count"], 3);
23603        assert_eq!(audit["provider"]["event_identities_distinct"], true);
23604        assert_eq!(audit["semantic_effect"], "not_performed");
23605        assert_eq!(audit["impact_validation"], "not_performed");
23606        assert_eq!(audit["claim_limits"].as_array().unwrap().len(), 7);
23607
23608        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
23609        assert!(csv.contains("xml_external_entity_review_audit"));
23610        assert!(csv.contains(XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA));
23611        let audit_row = csv
23612            .lines()
23613            .find(|line| line.contains("xml_external_entity_review_audit"))
23614            .unwrap();
23615        let audit_cells = parse_csv_line(audit_row);
23616        assert_eq!(audit_cells.len(), ASSESSMENT_CSV_HEADERS.len());
23617        assert_eq!(audit_cells[18], "8");
23618        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
23619        let markdown =
23620            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
23621        for rendered in [&json, &csv, &html, &markdown] {
23622            assert!(rendered.contains("xml-external-entity-policy-sha256:"));
23623            assert!(rendered.contains("not_confirmed_xxe"));
23624            for forbidden in [
23625                "https://provider.invalid",
23626                "/private/xml-endpoint",
23627                "<!DOCTYPE",
23628                "SYSTEM",
23629                "file:///",
23630                "callback-token-sentinel",
23631                "raw-provider-error",
23632            ] {
23633                assert!(!rendered.contains(forbidden));
23634            }
23635        }
23636
23637        document
23638            .xml_external_entity_review
23639            .as_mut()
23640            .unwrap()
23641            .provider
23642            .control_callback_observed = true;
23643        assert_eq!(document.validate(), Err(ReportError::Serialization));
23644    }
23645
23646    #[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
23647    #[test]
23648    fn xml_external_entity_review_empty_negative_audit_is_allowed_but_positive_item_requires_it() {
23649        let mut document = observation_assessment_document("ordinary-observation");
23650        document.items.clear();
23651        document.item_count = 0;
23652        let mut audit = xml_external_entity_review_document();
23653        audit.outcome = "not_eligible";
23654        audit.target.request_count = 0;
23655        audit.target.request_body_bytes = 0;
23656        audit.target.response_bytes = 0;
23657        audit.target.complete = false;
23658        audit.target.accounting_complete = false;
23659        audit.provider.request_count = 0;
23660        audit.provider.active_verification_count = 0;
23661        audit.provider.preflight_clean = false;
23662        audit.provider.candidate_callback_observed = false;
23663        audit.provider.replay_callback_observed = false;
23664        audit.provider.callback_targets_distinct = false;
23665        audit.provider.event_identities_distinct = false;
23666        audit.provider.cleanup_verified = false;
23667        audit.provider.complete = false;
23668        audit.item_projected = false;
23669        document.xml_external_entity_review = Some(audit);
23670        assert!(document.validate().is_ok());
23671
23672        document.items.push(xml_external_entity_item_document());
23673        document.item_count = 1;
23674        assert_eq!(document.validate(), Err(ReportError::Serialization));
23675        document.xml_external_entity_review = None;
23676        assert_eq!(document.validate(), Err(ReportError::Serialization));
23677    }
23678
23679    #[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
23680    #[test]
23681    fn xml_external_entity_review_writer_rejects_substituted_item_contract() {
23682        assert!(xml_external_entity_assessment_document().validate().is_ok());
23683
23684        assert_xml_external_entity_item_mutation_rejected(|item| {
23685            item.subject_reference = "subject-0001".to_owned();
23686        });
23687        assert_xml_external_entity_item_mutation_rejected(|item| {
23688            item.presentation_target = "request";
23689        });
23690        assert_xml_external_entity_item_mutation_rejected(|item| {
23691            item.title = "Substituted XML observation";
23692        });
23693        assert_xml_external_entity_item_mutation_rejected(|item| {
23694            item.category = "substituted category";
23695        });
23696        assert_xml_external_entity_item_mutation_rejected(|item| {
23697            item.remediation.id = "web.remediation.substituted@1";
23698        });
23699        assert_xml_external_entity_item_mutation_rejected(|item| {
23700            item.remediation.summary = "Substituted remediation.";
23701        });
23702        assert_xml_external_entity_item_mutation_rejected(|item| {
23703            item.confidence_ppm = 899_999;
23704        });
23705        assert_xml_external_entity_item_mutation_rejected(|item| {
23706            item.evidence_count = 1;
23707            item.evidence_references = vec!["evidence-0000".to_owned()];
23708            item.control_evidence_references.clear();
23709            item.candidate_evidence_references.clear();
23710        });
23711        assert_xml_external_entity_item_mutation_rejected(|item| {
23712            item.evidence_count = 7;
23713            item.control_evidence_references.pop();
23714        });
23715        assert_xml_external_entity_item_mutation_rejected(|item| {
23716            item.control_evidence_references.swap(0, 1);
23717        });
23718        assert_xml_external_entity_item_mutation_rejected(|item| {
23719            item.candidate_evidence_references[4] = "evidence-0008".to_owned();
23720        });
23721        assert_xml_external_entity_item_mutation_rejected(|item| {
23722            item.candidate_evidence_references[0] = "evidence-0002".to_owned();
23723        });
23724    }
23725
23726    #[cfg(all(feature = "scanning", feature = "xml-external-entity-review"))]
23727    #[test]
23728    fn xml_external_entity_review_writer_rejects_impossible_negative_lifecycle_facts() {
23729        let mut audit = xml_external_entity_review_document();
23730        audit.outcome = "candidate_only";
23731        audit.provider.replay_callback_observed = false;
23732        audit.provider.event_identities_distinct = false;
23733        audit.item_projected = false;
23734        assert!(audit.validate_wire().is_ok());
23735
23736        audit.provider.active_verification_count = 0;
23737        assert_eq!(audit.validate_wire(), Err(ReportError::Serialization));
23738        audit.provider.active_verification_count = 1;
23739
23740        audit.target.complete = false;
23741        assert_eq!(audit.validate_wire(), Err(ReportError::Serialization));
23742        audit.target.complete = true;
23743
23744        audit.provider.callback_targets_distinct = false;
23745        assert_eq!(audit.validate_wire(), Err(ReportError::Serialization));
23746
23747        let mut oversized_response = xml_external_entity_review_document();
23748        oversized_response.target.response_bytes =
23749            MAX_XML_EXTERNAL_ENTITY_COMPLETE_TARGET_RESPONSE_BYTES + 1;
23750        assert_eq!(
23751            oversized_response.validate_wire(),
23752            Err(ReportError::Serialization)
23753        );
23754
23755        let mut stopped_with_complete_accounting = xml_external_entity_review_document();
23756        stopped_with_complete_accounting.outcome = "incomplete";
23757        stopped_with_complete_accounting.provider.cleanup_verified = false;
23758        stopped_with_complete_accounting.item_projected = false;
23759        assert_eq!(
23760            stopped_with_complete_accounting.validate_wire(),
23761            Err(ReportError::Serialization)
23762        );
23763    }
23764
23765    #[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
23766    #[test]
23767    fn template_evaluation_review_writer_is_value_free_strict_and_rendered_everywhere() {
23768        let document = template_evaluation_assessment_document();
23769        assert!(document.validate().is_ok());
23770
23771        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23772        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23773        comparison::import_assessment_summary(json.as_bytes()).unwrap();
23774        let audit = &parsed["template_evaluation_review"];
23775        assert_eq!(audit["schema"], TEMPLATE_EVALUATION_REVIEW_AUDIT_SCHEMA);
23776        assert_eq!(audit["policy_id"], TEMPLATE_EVALUATION_REVIEW_POLICY_ID);
23777        assert_eq!(audit["family_id"], TEMPLATE_EVALUATION_REVIEW_FAMILY_ID);
23778        assert_eq!(audit["outcome"], "candidate_specific_evaluation");
23779        assert_eq!(audit["engine_identity"], "not_established");
23780        assert_eq!(audit["coverage"]["selected_case_count"], 2);
23781        assert_eq!(audit["coverage"]["attempted_request_count"], 4);
23782        assert_eq!(audit["coverage"]["active_request_count"], 2);
23783        assert_eq!(audit["coverage"]["completed_response_count"], 4);
23784        assert_eq!(audit["coverage"]["committed_response_count"], 4);
23785        assert_eq!(audit["coverage"]["projected_item_count"], 1);
23786        assert_eq!(audit["operations"]["os_execution"], "not_performed");
23787        assert_eq!(audit["operations"]["file_access"], "not_performed");
23788        assert_eq!(audit["claim_limits"].as_array().unwrap().len(), 7);
23789        assert_eq!(parsed["items"][0]["confidence_ppm"], 1_000_000);
23790        assert_eq!(parsed["items"][0]["evidence_count"], 12);
23791        assert_eq!(
23792            parsed["items"][0]["control_evidence_references"]
23793                .as_array()
23794                .unwrap()
23795                .len(),
23796            6
23797        );
23798        assert_eq!(
23799            parsed["items"][0]["candidate_evidence_references"]
23800                .as_array()
23801                .unwrap()
23802                .len(),
23803            6
23804        );
23805
23806        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
23807        let audit_row = csv
23808            .lines()
23809            .find(|line| line.contains("template_evaluation_review_audit"))
23810            .unwrap();
23811        let audit_cells = parse_csv_line(audit_row);
23812        assert_eq!(audit_cells.len(), ASSESSMENT_CSV_HEADERS.len());
23813        assert_eq!(audit_cells[18], "12");
23814
23815        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
23816        let markdown =
23817            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
23818        for rendered in [&json, &csv, &html, &markdown] {
23819            assert!(rendered.contains("candidate_specific_evaluation"));
23820            assert!(rendered.contains("engine_identity_not_established"));
23821            assert!(rendered.contains(TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID));
23822            for forbidden in [
23823                "{{",
23824                "query=",
23825                "https://target.invalid",
23826                "jinja2.exceptions",
23827                "raw-template-body",
23828                "raw-transport-error",
23829            ] {
23830                assert!(!rendered.contains(forbidden));
23831            }
23832        }
23833    }
23834
23835    #[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
23836    #[test]
23837    fn template_evaluation_review_selected_empty_audit_is_valid_and_cross_link_is_mandatory() {
23838        let mut document = observation_assessment_document("ordinary-observation");
23839        document.items.clear();
23840        document.item_count = 0;
23841        document.template_evaluation_review =
23842            Some(template_evaluation_review_document("literal_or_escaped", 0));
23843        assert!(document.validate().is_ok());
23844        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23845        comparison::import_assessment_summary(json.as_bytes()).unwrap();
23846
23847        document
23848            .items
23849            .push(template_evaluation_review_item_document());
23850        document.item_count = 1;
23851        assert_eq!(document.validate(), Err(ReportError::Serialization));
23852
23853        let mut positive_without_audit = template_evaluation_assessment_document();
23854        positive_without_audit.template_evaluation_review = None;
23855        assert_eq!(
23856            positive_without_audit.validate(),
23857            Err(ReportError::Serialization)
23858        );
23859
23860        let mut historical = observation_assessment_document("ordinary-observation");
23861        historical.items[0].fingerprint =
23862            "sha256:5555555555555555555555555555555555555555555555555555555555555555";
23863        let historical_json =
23864            render_assessment_with_limit(&historical, ReportFormat::Json, usize::MAX).unwrap();
23865        let historical_value: serde_json::Value = serde_json::from_str(&historical_json).unwrap();
23866        assert!(historical_value.get("template_evaluation_review").is_none());
23867        comparison::import_assessment_summary(historical_json.as_bytes()).unwrap();
23868    }
23869
23870    #[cfg(all(feature = "scanning", feature = "template-evaluation-review"))]
23871    #[test]
23872    fn template_evaluation_review_writer_rejects_invalid_counts_and_substituted_cross_links() {
23873        let mut invalid_positive =
23874            template_evaluation_review_document("candidate_specific_evaluation", 1);
23875        invalid_positive.coverage.committed_response_count = 3;
23876        assert_eq!(
23877            invalid_positive.validate_wire(),
23878            Err(ReportError::Serialization)
23879        );
23880
23881        let mut invalid_negative = template_evaluation_review_document("incomplete", 0);
23882        invalid_negative.coverage.selected_case_count = 0;
23883        assert_eq!(
23884            invalid_negative.validate_wire(),
23885            Err(ReportError::Serialization)
23886        );
23887
23888        let mut partial_incomplete = template_evaluation_review_document("incomplete", 0);
23889        partial_incomplete.coverage.attempted_request_count = 3;
23890        partial_incomplete.coverage.active_request_count = 1;
23891        partial_incomplete.coverage.completed_response_count = 2;
23892        partial_incomplete.coverage.committed_response_count = 2;
23893        assert!(partial_incomplete.validate_wire().is_ok());
23894
23895        for outcome in [
23896            "literal_or_escaped",
23897            "unsupported_representation",
23898            "replay_mismatch",
23899        ] {
23900            let mut incomplete_terminal = template_evaluation_review_document(outcome, 0);
23901            incomplete_terminal.coverage.attempted_request_count = 0;
23902            incomplete_terminal.coverage.active_request_count = 0;
23903            incomplete_terminal.coverage.completed_response_count = 0;
23904            incomplete_terminal.coverage.committed_response_count = 0;
23905            assert_eq!(
23906                incomplete_terminal.validate_wire(),
23907                Err(ReportError::Serialization)
23908            );
23909        }
23910
23911        let mut invalid_parent_absent =
23912            template_evaluation_review_document("parent_not_observed", 0);
23913        assert_eq!(
23914            invalid_parent_absent.validate_wire(),
23915            Err(ReportError::Serialization)
23916        );
23917        invalid_parent_absent.coverage = AssessmentTemplateEvaluationCoverageDocument {
23918            selected_case_count: 0,
23919            attempted_request_count: 0,
23920            active_request_count: 0,
23921            completed_response_count: 0,
23922            committed_response_count: 0,
23923            projected_item_count: 0,
23924        };
23925        assert!(invalid_parent_absent.validate_wire().is_ok());
23926
23927        assert_template_evaluation_item_mutation_rejected(|item| {
23928            item.subject_reference = "subject-0001".to_owned();
23929        });
23930        assert_template_evaluation_item_mutation_rejected(|item| {
23931            item.presentation_target = "assessment_subject";
23932        });
23933        assert_template_evaluation_item_mutation_rejected(|item| {
23934            item.title = "Substituted template result";
23935        });
23936        assert_template_evaluation_item_mutation_rejected(|item| {
23937            item.claim_basis = "observation";
23938        });
23939        assert_template_evaluation_item_mutation_rejected(|item| {
23940            item.cwe = Some("CWE-94");
23941        });
23942        assert_template_evaluation_item_mutation_rejected(|item| {
23943            item.confidence_ppm = 999_999;
23944        });
23945        assert_template_evaluation_item_mutation_rejected(|item| {
23946            let moved = item.control_evidence_references.pop().unwrap();
23947            item.candidate_evidence_references.push(moved);
23948        });
23949        assert_template_evaluation_item_mutation_rejected(|item| {
23950            let moved = item.candidate_evidence_references.pop().unwrap();
23951            item.control_evidence_references.push(moved);
23952        });
23953        assert_template_evaluation_item_mutation_rejected(|item| {
23954            item.evidence_count = 1;
23955            item.evidence_references = vec!["evidence-0000".to_owned()];
23956            item.control_evidence_references.clear();
23957            item.candidate_evidence_references.clear();
23958        });
23959        assert_template_evaluation_item_mutation_rejected(|item| {
23960            item.candidate_evidence_references[0] = item.control_evidence_references[0].clone();
23961        });
23962    }
23963
23964    #[cfg(all(
23965        feature = "scanning",
23966        feature = "websocket-review",
23967        feature = "supplied-session-review"
23968    ))]
23969    #[test]
23970    fn websocket_v2_writer_cross_validates_session_health_and_separates_protocol_completion() {
23971        let mut document = observation_assessment_document("ordinary-observation");
23972        document.items.clear();
23973        document.item_count = 0;
23974        document.supplied_session = Some(complete_supplied_session_audit_document());
23975        document.websocket_review = Some(supplied_session_websocket_review_document());
23976        assert!(document.validate().is_ok());
23977
23978        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
23979        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
23980        let audit = &parsed["websocket_review"];
23981        assert_eq!(audit["schema"], "security.websocket-review-audit/v2");
23982        assert_eq!(audit["context"], "supplied_session");
23983        assert_eq!(
23984            audit["supplied_session_context"]["qualification"],
23985            "context_qualified"
23986        );
23987        assert_eq!(
23988            audit["supplied_session_context"]["continuous_authentication_established"],
23989            false
23990        );
23991        for format in [ReportFormat::Html, ReportFormat::Markdown] {
23992            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
23993            assert!(rendered.contains("protocol-exchange completion"));
23994            assert!(rendered.contains("continuous authentication"));
23995            assert!(!rendered.contains("Authorization: Bearer"));
23996        }
23997
23998        let mut multiple_resources = observation_assessment_document("ordinary-observation");
23999        multiple_resources.items.clear();
24000        multiple_resources.item_count = 0;
24001        let mut session = complete_supplied_session_audit_document();
24002        session.checkpoints.insert(
24003            1,
24004            AssessmentSuppliedSessionCheckpointDocument {
24005                sequence: 1,
24006                phase: "subject_boundary",
24007                after_subject_count: 1,
24008                evidence_reference: Some(format!(
24009                    "supplied-session-checkpoint-evidence-sha256:{}",
24010                    "8".repeat(64)
24011                )),
24012                outcome: "healthy",
24013                status: Some(200),
24014                body_state: "complete",
24015                predicate: "matched",
24016                response_bytes: 31,
24017                epoch: None,
24018            },
24019        );
24020        session.checkpoints[2].sequence = 2;
24021        session.checkpoints[2].after_subject_count = 2;
24022        session
24023            .resources
24024            .push(AssessmentSuppliedSessionResourceDocument {
24025                sequence: 1,
24026                resource_reference: format!("supplied-session-resource-sha256:{}", "9".repeat(64)),
24027                evidence_reference: Some(format!(
24028                    "supplied-session-resource-evidence-sha256:{}",
24029                    "a".repeat(64)
24030                )),
24031                outcome: "committed",
24032                status: Some(200),
24033                response_bytes: 29,
24034                epoch: 1,
24035            });
24036        session.selected_resource_count = 2;
24037        session.dispatched_resource_count = 2;
24038        session.committed_resource_count = 2;
24039        session.dispatched_request_count = 5;
24040        session.response_bytes = 119;
24041        assert!(session.validate(&[]).is_ok());
24042        multiple_resources.supplied_session = Some(session);
24043        multiple_resources.websocket_review = Some(supplied_session_websocket_review_document());
24044        assert_eq!(
24045            multiple_resources.validate(),
24046            Err(ReportError::Serialization)
24047        );
24048
24049        document
24050            .websocket_review
24051            .as_mut()
24052            .unwrap()
24053            .coverage
24054            .terminal = "incomplete";
24055        document
24056            .websocket_review
24057            .as_mut()
24058            .unwrap()
24059            .coverage
24060            .completeness = "incomplete";
24061        document
24062            .websocket_review
24063            .as_mut()
24064            .unwrap()
24065            .coverage
24066            .first_failure = Some("close_failed");
24067        assert!(document.validate().is_ok());
24068
24069        document
24070            .websocket_review
24071            .as_mut()
24072            .unwrap()
24073            .supplied_session_context
24074            .as_mut()
24075            .unwrap()
24076            .terminal_health
24077            .as_mut()
24078            .unwrap()
24079            .evidence_reference = Some(format!(
24080            "supplied-session-checkpoint-evidence-sha256:{}",
24081            "9".repeat(64)
24082        ));
24083        assert_eq!(document.validate(), Err(ReportError::Serialization));
24084    }
24085
24086    #[cfg(all(
24087        feature = "scanning",
24088        feature = "websocket-review",
24089        feature = "supplied-session-review"
24090    ))]
24091    #[test]
24092    fn websocket_v2_writer_accepts_zero_admission_only_as_not_established() {
24093        let mut document = observation_assessment_document("ordinary-observation");
24094        document.items.clear();
24095        document.item_count = 0;
24096        document.supplied_session = Some(complete_supplied_session_audit_document());
24097        document.websocket_review =
24098            Some(not_established_supplied_session_websocket_review_document());
24099        assert!(document.validate().is_ok());
24100
24101        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
24102        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
24103        let audit = &parsed["websocket_review"];
24104        assert_eq!(
24105            audit["supplied_session_context"]["qualification"],
24106            "not_established"
24107        );
24108        assert_eq!(audit["coverage"]["request_attempt_count"], 1);
24109        assert_eq!(audit["coverage"]["request_admitted_count"], 0);
24110        assert_eq!(
24111            audit["supplied_session_context"]["startup_health"]["outcome"],
24112            "healthy"
24113        );
24114        assert_eq!(
24115            audit["supplied_session_context"]["terminal_health"]["outcome"],
24116            "healthy"
24117        );
24118
24119        for invalid_qualification in ["context_qualified", "context_unqualified"] {
24120            document
24121                .websocket_review
24122                .as_mut()
24123                .unwrap()
24124                .supplied_session_context
24125                .as_mut()
24126                .unwrap()
24127                .qualification = invalid_qualification;
24128            assert_eq!(document.validate(), Err(ReportError::Serialization));
24129        }
24130        document
24131            .websocket_review
24132            .as_mut()
24133            .unwrap()
24134            .supplied_session_context
24135            .as_mut()
24136            .unwrap()
24137            .qualification = "not_established";
24138        document
24139            .websocket_review
24140            .as_mut()
24141            .unwrap()
24142            .coverage
24143            .request_admitted_count = 1;
24144        assert_eq!(document.validate(), Err(ReportError::Serialization));
24145    }
24146
24147    #[cfg(all(feature = "scanning", feature = "openapi-review"))]
24148    #[test]
24149    fn openapi_reporting_absent_audit_adds_no_wire_field_row_or_section() {
24150        let document = complete_assessment_document();
24151        assert!(document.openapi_review.is_none());
24152
24153        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
24154        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
24155        assert!(parsed.get("openapi_review").is_none());
24156
24157        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
24158        assert!(!csv.contains("openapi_review_audit"));
24159
24160        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
24161        assert!(!html.contains("OpenAPI review audit"));
24162
24163        let markdown =
24164            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
24165        assert!(!markdown.contains("OpenAPI review audit"));
24166    }
24167
24168    #[cfg(all(feature = "scanning", feature = "openapi-review"))]
24169    #[test]
24170    fn openapi_reporting_positive_audit_is_minimized_and_redacted_in_every_format() {
24171        let document = observed_openapi_assessment_document();
24172        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
24173        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
24174        let audit = parsed["openapi_review"].as_object().unwrap();
24175        assert_eq!(audit["outcome"], "document_observed");
24176        assert_eq!(audit["request_count"], 2);
24177        assert_eq!(audit["active_verification_count"], 1);
24178        assert_eq!(audit["item_projected"], true);
24179        let audit_keys = audit
24180            .keys()
24181            .map(String::as_str)
24182            .collect::<std::collections::BTreeSet<_>>();
24183        let expected_audit_keys = [
24184            "schema",
24185            "capability_id",
24186            "outcome",
24187            "candidate_source",
24188            "request_count",
24189            "active_verification_count",
24190            "version",
24191            "semantic_digest",
24192            "path_count",
24193            "operation_count",
24194            "get_operation_count",
24195            "write_operation_count",
24196            "path_parameter_count",
24197            "query_parameter_count",
24198            "explicit_auth_operation_count",
24199            "anonymous_operation_count",
24200            "url_like_operation_count",
24201            "multipart_operation_count",
24202            "deprecated_operation_count",
24203            "replay_matched",
24204            "item_projected",
24205        ]
24206        .into_iter()
24207        .collect();
24208        assert_eq!(audit_keys, expected_audit_keys);
24209        for forbidden_key in [
24210            "document",
24211            "raw_document",
24212            "server",
24213            "servers",
24214            "example",
24215            "examples",
24216            "request",
24217            "response",
24218            "headers",
24219            "cookies",
24220            "authorization",
24221        ] {
24222            assert!(!audit.contains_key(forbidden_key));
24223        }
24224
24225        for format in ReportGenerator::available_formats() {
24226            let rendered = render_assessment_with_limit(&document, *format, usize::MAX).unwrap();
24227            assert!(rendered.contains(OPENAPI_REVIEW_CAPABILITY_ID));
24228            assert!(rendered.contains("document_observed"));
24229            for sentinel in [
24230                "RAW-OPENAPI-DOCUMENT-MUST-NOT-LEAK-4E5A91",
24231                "https://private-openapi-server.example.test/secret",
24232                "OPENAPI-EXAMPLE-VALUE-MUST-NOT-LEAK-13C8D7",
24233                "Bearer OPENAPI-AUTH-MUST-NOT-LEAK-98A02F",
24234                "session=OPENAPI-COOKIE-MUST-NOT-LEAK-79BD10",
24235            ] {
24236                assert!(!rendered.contains(sentinel), "{format:?} leaked {sentinel}");
24237            }
24238        }
24239    }
24240
24241    #[cfg(all(feature = "scanning", feature = "openapi-review"))]
24242    #[test]
24243    fn openapi_reporting_outcome_tokens_are_exhaustive_and_stable() {
24244        for (outcome, token) in [
24245            (OpenApiRuntimeOutcome::NotEligible, "not_eligible"),
24246            (OpenApiRuntimeOutcome::DocumentObserved, "document_observed"),
24247            (
24248                OpenApiRuntimeOutcome::Swagger20MetadataOnly,
24249                "swagger_20_metadata_only",
24250            ),
24251            (
24252                OpenApiRuntimeOutcome::UnsupportedVersion,
24253                "unsupported_version",
24254            ),
24255            (OpenApiRuntimeOutcome::ReplayMismatch, "replay_mismatch"),
24256            (OpenApiRuntimeOutcome::UnsupportedMedia, "unsupported_media"),
24257            (OpenApiRuntimeOutcome::Malformed, "malformed"),
24258            (OpenApiRuntimeOutcome::LimitExceeded, "limit_exceeded"),
24259            (OpenApiRuntimeOutcome::TooLarge, "too_large"),
24260            (OpenApiRuntimeOutcome::RedirectObserved, "redirect_observed"),
24261            (OpenApiRuntimeOutcome::RateLimited, "rate_limited"),
24262            (
24263                OpenApiRuntimeOutcome::DefensiveInterference,
24264                "defensive_interference",
24265            ),
24266            (OpenApiRuntimeOutcome::HttpError, "http_error"),
24267            (OpenApiRuntimeOutcome::Truncated, "truncated"),
24268            (OpenApiRuntimeOutcome::Incomplete, "incomplete"),
24269            (OpenApiRuntimeOutcome::BudgetExhausted, "budget_exhausted"),
24270            (OpenApiRuntimeOutcome::Cancelled, "cancelled"),
24271        ] {
24272            assert_eq!(openapi_outcome(outcome), token);
24273        }
24274    }
24275
24276    #[cfg(all(feature = "scanning", feature = "rest-review"))]
24277    #[test]
24278    fn rest_reporting_absent_audit_adds_no_wire_field_row_or_section() {
24279        let document = complete_assessment_document();
24280        assert!(document.rest_review.is_none());
24281
24282        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
24283        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
24284        assert!(parsed.get("rest_review").is_none());
24285
24286        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
24287        assert!(!csv.contains("rest_readonly_review_audit"));
24288
24289        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
24290        assert!(!html.contains("REST read-only review audit"));
24291
24292        let markdown =
24293            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
24294        assert!(!markdown.contains("REST read-only review audit"));
24295    }
24296
24297    #[cfg(all(feature = "scanning", feature = "rest-review"))]
24298    #[test]
24299    fn rest_reporting_positive_audit_is_minimized_and_redacted_in_every_format() {
24300        let document = observed_rest_assessment_document();
24301        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
24302        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
24303        let audit = parsed["rest_review"].as_object().unwrap();
24304        assert_eq!(audit["schema"], "security.rest-readonly-review-audit/v1");
24305        assert_eq!(audit["capability_id"], REST_REVIEW_CAPABILITY_ID);
24306        assert_eq!(audit["enabled"], true);
24307        assert_eq!(audit["method"], "get");
24308        assert_eq!(audit["outcome"], "surface_observed");
24309        assert_eq!(audit["request_count"], 2);
24310        assert_eq!(audit["active_verification_count"], 1);
24311        assert_eq!(audit["eligible_operation_count"], 3);
24312        assert_eq!(audit["documented_response"], "json_compatible");
24313        assert_eq!(audit["observed_media"], "json_compatible");
24314        assert_eq!(audit["status_class"], 2);
24315        assert_eq!(audit["replay_stable"], true);
24316        assert_eq!(audit["item_projected"], true);
24317        let keys = audit
24318            .keys()
24319            .map(String::as_str)
24320            .collect::<std::collections::BTreeSet<_>>();
24321        let expected = [
24322            "schema",
24323            "capability_id",
24324            "enabled",
24325            "method",
24326            "outcome",
24327            "request_count",
24328            "active_verification_count",
24329            "eligible_operation_count",
24330            "selected_operation_identity",
24331            "documented_response",
24332            "observed_media",
24333            "status_class",
24334            "replay_stable",
24335            "item_projected",
24336        ]
24337        .into_iter()
24338        .collect();
24339        assert_eq!(keys, expected);
24340        for forbidden_key in [
24341            "url",
24342            "path",
24343            "query",
24344            "body",
24345            "response",
24346            "scalar_values",
24347            "examples",
24348            "defaults",
24349            "credentials",
24350            "server",
24351        ] {
24352            assert!(!audit.contains_key(forbidden_key));
24353        }
24354
24355        for format in ReportGenerator::available_formats() {
24356            let rendered = render_assessment_with_limit(&document, *format, usize::MAX).unwrap();
24357            assert!(rendered.contains(REST_REVIEW_CAPABILITY_ID));
24358            assert!(rendered.contains("surface_observed"));
24359            for sentinel in [
24360                "REST-RAW-PATH-MUST-NOT-LEAK-5C61E8",
24361                "REST-QUERY-MUST-NOT-LEAK-6A7B20",
24362                "REST-RESPONSE-BODY-MUST-NOT-LEAK-9073D4",
24363                "REST-SCALAR-MUST-NOT-LEAK-44B8F1",
24364                "Bearer REST-AUTH-MUST-NOT-LEAK-A31E09",
24365                "session=REST-COOKIE-MUST-NOT-LEAK-190AF2",
24366            ] {
24367                assert!(!rendered.contains(sentinel), "{format:?} leaked {sentinel}");
24368            }
24369        }
24370    }
24371
24372    #[cfg(all(feature = "scanning", feature = "rest-review"))]
24373    #[test]
24374    fn rest_reporting_contract_rejects_inconsistent_positive_truth() {
24375        let mut too_many_requests = observed_rest_assessment_document();
24376        too_many_requests
24377            .rest_review
24378            .as_mut()
24379            .unwrap()
24380            .request_count = 3;
24381        let mut no_replay = observed_rest_assessment_document();
24382        no_replay.rest_review.as_mut().unwrap().replay_stable = false;
24383        let mut no_audit = observed_rest_assessment_document();
24384        no_audit.rest_review = None;
24385        let mut wrong_identity = observed_rest_assessment_document();
24386        wrong_identity
24387            .rest_review
24388            .as_mut()
24389            .unwrap()
24390            .selected_operation_identity = Some("raw/path".to_owned());
24391        let mut wrong_method = observed_rest_assessment_document();
24392        wrong_method.rest_review.as_mut().unwrap().method = "post";
24393
24394        for document in [
24395            too_many_requests,
24396            no_replay,
24397            no_audit,
24398            wrong_identity,
24399            wrong_method,
24400        ] {
24401            for format in ReportGenerator::available_formats() {
24402                assert_eq!(
24403                    render_assessment_with_limit(&document, *format, usize::MAX),
24404                    Err(ReportError::Serialization)
24405                );
24406            }
24407        }
24408    }
24409
24410    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
24411    #[test]
24412    fn wordpress_reporting_is_bounded_typed_and_escaped_in_every_format() {
24413        let document = observed_wordpress_assessment_document();
24414        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
24415        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
24416        let audit = &parsed["wordpress_review"];
24417        assert_eq!(audit["schema"], "security.wordpress-review-audit/v1");
24418        assert_eq!(audit["capability_id"], WORDPRESS_REVIEW_CAPABILITY_ID);
24419        assert_eq!(audit["catalog_status"], "evaluated");
24420        assert_eq!(
24421            audit["catalog"]["id"],
24422            "termivar-synthetic-wordpress-reporting"
24423        );
24424        assert_eq!(audit["catalog"]["revision"], "synthetic-v1");
24425        assert_eq!(audit["catalog"]["retrieved_on"], "2026-09-06");
24426        assert_eq!(audit["signal_count"], 1);
24427        assert_eq!(audit["evidence_reference_count"], 1);
24428        assert_eq!(audit["additional_request_count"], 0);
24429        assert_eq!(audit["item_projected"], true);
24430        assert_eq!(audit["component_count"], 1);
24431        assert_eq!(audit["advisory_count"], 1);
24432        assert_eq!(audit["components"][0]["identity"]["kind"], "core");
24433        assert_eq!(
24434            audit["components"][0]["versions"][0]["confidence"],
24435            "public_declaration"
24436        );
24437        assert_eq!(
24438            audit["advisories"][0]["applicability"],
24439            "indeterminate_missing_evidence"
24440        );
24441        assert_eq!(audit["advisories"][0]["exploit_execution"], "not_performed");
24442        assert_eq!(audit["advisories"][0]["impact_validation"], "not_performed");
24443        assert!(audit.get("external_review").is_none());
24444        assert!(audit["advisories"][0].get("comparison_profile").is_none());
24445        assert!(audit["advisories"][0].get("version_resolution").is_none());
24446        assert!(audit["advisories"][0]
24447            .get("version_resolution_reason")
24448            .is_none());
24449
24450        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
24451        assert!(csv.contains("wordpress_review_audit"));
24452        assert!(csv.contains("SYNTHETIC-REPORTING-WORDPRESS-0001"));
24453        let audit_row = csv
24454            .lines()
24455            .find(|line| line.contains("wordpress_review_audit"))
24456            .unwrap();
24457        assert_eq!(
24458            parse_csv_line(audit_row).len(),
24459            ASSESSMENT_CSV_HEADERS.len()
24460        );
24461        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
24462        assert!(html.contains("WordPress evidence review audit"));
24463        assert!(html.contains("SYNTHETIC-REPORTING-WORDPRESS-0001"));
24464        assert!(!html.contains("<script>inert()</script>"));
24465        assert!(html.contains("&lt;script&gt;inert()&lt;/script&gt;"));
24466        assert!(!html.contains("<script"));
24467        let markdown =
24468            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
24469        assert!(markdown.contains("WordPress evidence review audit"));
24470        assert!(markdown.contains("SYNTHETIC-REPORTING-WORDPRESS-0001"));
24471        assert!(markdown.contains("not_performed"));
24472    }
24473
24474    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
24475    #[test]
24476    fn wordpress_discovery_reporting_is_separate_strict_and_reader_compatible() {
24477        let document = discovered_wordpress_assessment_document();
24478        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
24479        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
24480        let discovery = &parsed["wordpress_discovery"];
24481        assert_eq!(
24482            parsed["wordpress_review"]["schema"],
24483            "security.wordpress-review-audit/v7"
24484        );
24485        assert_eq!(
24486            parsed["wordpress_review"]["review_basis_schema"],
24487            "security.wordpress-review-audit/v1"
24488        );
24489        assert_eq!(discovery["schema"], WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V2);
24490        assert_eq!(discovery["policy_id"], WORDPRESS_DISCOVERY_POLICY_ID);
24491        assert_eq!(
24492            WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V1,
24493            "security.wordpress-discovery-audit/v1"
24494        );
24495        assert_eq!(
24496            WORDPRESS_DISCOVERY_POLICY_ID_V1,
24497            "termivar.wordpress-metadata-discovery/v1"
24498        );
24499        assert_eq!(discovery["method"], "get");
24500        assert_eq!(discovery["credential_mode"], "anonymous");
24501        assert_eq!(discovery["candidate_limit_reached"], false);
24502        assert_eq!(discovery["omitted_candidate_count"], 0);
24503        assert_eq!(discovery["attempted_request_count"], 3);
24504        assert_eq!(discovery["completed_response_count"], 3);
24505        assert_eq!(discovery["committed_response_count"], 3);
24506        assert_eq!(discovery["response_bytes"], 768);
24507        assert_eq!(
24508            discovery["layout"]["application_reference"],
24509            format!("sha256:{}", "a".repeat(64))
24510        );
24511        assert!(discovery["layout"].get("declaration").is_none());
24512        assert_eq!(
24513            discovery["layout"]["roles"].as_array().map(Vec::len),
24514            Some(4)
24515        );
24516        assert_eq!(discovery["layout"]["roles"][1]["role"], "themes");
24517        assert_eq!(discovery["layout"]["roles"][1]["status"], "exact");
24518        assert_eq!(
24519            discovery["sources"][0]["association"],
24520            "structured_advertisement"
24521        );
24522        assert_eq!(
24523            discovery["sources"][1]["association"],
24524            "observed_conventional"
24525        );
24526        assert_eq!(
24527            discovery["sources"][1]["role_reference"],
24528            discovery["layout"]["roles"][1]["reference"]
24529        );
24530        assert_eq!(
24531            discovery["sources"][2]["role_reference"],
24532            discovery["layout"]["roles"][2]["reference"]
24533        );
24534        assert_eq!(discovery["sources"][0]["namespaces"][0], "wp/v2");
24535        assert_eq!(discovery["sources"][1]["theme"]["version"], "1.2.3");
24536        assert_eq!(discovery["sources"][2]["plugin"]["stable_tag"], "9.9.9");
24537        assert_eq!(discovery["sources"][1]["theme"]["requires_php"], "8.1");
24538        assert_eq!(discovery["sources"][2]["plugin"]["requires_php"], "8.1");
24539        assert_eq!(
24540            parsed["wordpress_review"]["additional_request_count"], 3,
24541            "the discovery-influenced v7 audit must expose actual broker attempts"
24542        );
24543        assert!(parsed["wordpress_review"]["components"]
24544            .as_array()
24545            .unwrap()
24546            .iter()
24547            .flat_map(|component| component["versions"].as_array().into_iter().flatten())
24548            .all(|version| version["value"] != "9.9.9"));
24549
24550        for format in [ReportFormat::Html, ReportFormat::Markdown] {
24551            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
24552            assert!(rendered.contains(WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V2));
24553            assert!(rendered.contains("termivar-metadata-lab"));
24554            assert!(rendered.contains("9.9.9"));
24555            assert!(rendered.contains("not installed version"));
24556            assert!(rendered.contains("Selected application and deployment layout"));
24557            assert!(rendered.contains("observed_conventional"));
24558            assert!(rendered.contains("opaque location identities"));
24559            assert!(!rendered.contains("confirmed_vulnerability"));
24560        }
24561        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
24562        assert!(csv.contains("wordpress_discovery_audit"));
24563        assert!(csv.contains(WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V2));
24564        assert!(csv.contains("termivar-metadata-lab"));
24565
24566        let comparison = comparison::compare_reports(
24567            json.as_bytes(),
24568            json.as_bytes(),
24569            comparison::ComparisonFormat::Json,
24570        )
24571        .unwrap();
24572        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
24573        assert_eq!(
24574            comparison["wordpress_review_comparison"]["methodology"]["status"],
24575            "unchanged"
24576        );
24577        assert_eq!(
24578            comparison["wordpress_review_comparison"]["coverage"]["status"],
24579            "not_established"
24580        );
24581        assert_eq!(comparison["unchanged"].as_array().map(Vec::len), Some(2));
24582        assert_eq!(comparison["only_in_after"], serde_json::json!([]));
24583        assert_eq!(comparison["only_in_before"], serde_json::json!([]));
24584        assert_eq!(comparison["changed"], serde_json::json!([]));
24585
24586        let mut renumbered: serde_json::Value = serde_json::from_str(&json).unwrap();
24587        let replacement_references = ["evidence-0101", "evidence-0102", "evidence-0103"];
24588        for (source, replacement) in renumbered["wordpress_discovery"]["sources"]
24589            .as_array_mut()
24590            .unwrap()
24591            .iter_mut()
24592            .zip(replacement_references)
24593        {
24594            source["evidence_references"] = serde_json::json!([replacement]);
24595        }
24596        let discovery_item = renumbered["items"]
24597            .as_array_mut()
24598            .unwrap()
24599            .iter_mut()
24600            .find(|item| item["capability_id"] == WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID)
24601            .unwrap();
24602        discovery_item["evidence_references"] = serde_json::json!(replacement_references);
24603        let renumbered = serde_json::to_vec(&renumbered).unwrap();
24604        let renumbering_comparison = comparison::compare_reports(
24605            json.as_bytes(),
24606            &renumbered,
24607            comparison::ComparisonFormat::Json,
24608        )
24609        .unwrap();
24610        let renumbering_comparison: serde_json::Value =
24611            serde_json::from_str(&renumbering_comparison).unwrap();
24612        assert_eq!(
24613            renumbering_comparison["only_in_after"],
24614            serde_json::json!([])
24615        );
24616        assert_eq!(
24617            renumbering_comparison["only_in_before"],
24618            serde_json::json!([])
24619        );
24620        assert_eq!(renumbering_comparison["changed"], serde_json::json!([]));
24621        assert_eq!(
24622            renumbering_comparison["unchanged"].as_array().map(Vec::len),
24623            Some(2),
24624            "report-local evidence reference renumbering is not a semantic change"
24625        );
24626        assert_eq!(
24627            renumbering_comparison["wordpress_review_comparison"]["methodology"]["status"],
24628            "unchanged"
24629        );
24630        assert_eq!(
24631            renumbering_comparison["wordpress_review_comparison"]["coverage"]["status"],
24632            "not_established"
24633        );
24634
24635        let mut review_only_document = observed_wordpress_assessment_document();
24636        review_only_document.items[0].fingerprint =
24637            "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
24638        let review_only =
24639            render_assessment_with_limit(&review_only_document, ReportFormat::Json, usize::MAX)
24640                .unwrap();
24641        let collection_change = comparison::compare_reports(
24642            review_only.as_bytes(),
24643            json.as_bytes(),
24644            comparison::ComparisonFormat::Json,
24645        )
24646        .unwrap();
24647        let collection_change: serde_json::Value =
24648            serde_json::from_str(&collection_change).unwrap();
24649        assert_eq!(
24650            collection_change["wordpress_review_comparison"]["methodology"]["status"],
24651            "changed"
24652        );
24653        assert_eq!(
24654            collection_change["wordpress_review_comparison"]["coverage"]["status"],
24655            "changed"
24656        );
24657        assert_eq!(
24658            collection_change["only_in_after"]
24659                .as_array()
24660                .and_then(|items| items.first())
24661                .map(|item| &item["capability_id"]),
24662            Some(&serde_json::json!(
24663                WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID
24664            )),
24665            "the distinct observation must remain in Report Compare's exhaustive four-group partition"
24666        );
24667        assert_eq!(
24668            collection_change["only_in_before"],
24669            serde_json::json!([]),
24670            "absence of discovery must not be labelled remediation"
24671        );
24672
24673        let reverse_collection_change = comparison::compare_reports(
24674            json.as_bytes(),
24675            review_only.as_bytes(),
24676            comparison::ComparisonFormat::Json,
24677        )
24678        .unwrap();
24679        let reverse_collection_change: serde_json::Value =
24680            serde_json::from_str(&reverse_collection_change).unwrap();
24681        assert_eq!(
24682            reverse_collection_change["only_in_after"],
24683            serde_json::json!([])
24684        );
24685        assert_eq!(
24686            reverse_collection_change["only_in_before"]
24687                .as_array()
24688                .and_then(|items| items.first())
24689                .map(|item| &item["capability_id"]),
24690            Some(&serde_json::json!(
24691                WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID
24692            ))
24693        );
24694        assert_eq!(
24695            reverse_collection_change["wordpress_review_comparison"]["methodology"]["status"],
24696            "changed"
24697        );
24698        assert_eq!(
24699            reverse_collection_change["wordpress_review_comparison"]["coverage"]["status"],
24700            "changed"
24701        );
24702
24703        let mut invalid: serde_json::Value = serde_json::from_str(&json).unwrap();
24704        invalid["wordpress_discovery"]["attempted_request_count"] = serde_json::json!(4);
24705        let invalid = serde_json::to_vec(&invalid).unwrap();
24706        assert_eq!(
24707            comparison::import_assessment_summary(&invalid),
24708            Err(comparison::ComparisonError::InvalidDocument)
24709        );
24710
24711        let mut false_commit: serde_json::Value = serde_json::from_str(&json).unwrap();
24712        false_commit["wordpress_discovery"]["sources"][0]["evidence_reference_count"] =
24713            serde_json::json!(0);
24714        assert_eq!(
24715            comparison::import_assessment_summary(&serde_json::to_vec(&false_commit).unwrap()),
24716            Err(comparison::ComparisonError::InvalidDocument)
24717        );
24718
24719        let mut false_observation: serde_json::Value = serde_json::from_str(&json).unwrap();
24720        false_observation["wordpress_discovery"]["sources"][0]
24721            .as_object_mut()
24722            .unwrap()
24723            .remove("namespaces");
24724        assert_eq!(
24725            comparison::import_assessment_summary(&serde_json::to_vec(&false_observation).unwrap()),
24726            Err(comparison::ComparisonError::InvalidDocument)
24727        );
24728
24729        let mut old_schema_vocabulary_leak: serde_json::Value =
24730            serde_json::from_str(&json).unwrap();
24731        old_schema_vocabulary_leak
24732            .as_object_mut()
24733            .unwrap()
24734            .remove("wordpress_discovery");
24735        let review = old_schema_vocabulary_leak["wordpress_review"]
24736            .as_object_mut()
24737            .unwrap();
24738        review.insert(
24739            "schema".to_owned(),
24740            serde_json::json!("security.wordpress-review-audit/v1"),
24741        );
24742        review.remove("review_basis_schema");
24743        review.insert("additional_request_count".to_owned(), serde_json::json!(0));
24744        assert_eq!(
24745            comparison::import_assessment_summary(
24746                &serde_json::to_vec(&old_schema_vocabulary_leak).unwrap()
24747            ),
24748            Err(comparison::ComparisonError::InvalidDocument),
24749            "legacy audit schemas must not accept discovery-only evidence vocabulary"
24750        );
24751
24752        let mut false_discovery_confidence: serde_json::Value =
24753            serde_json::from_str(&json).unwrap();
24754        let theme = false_discovery_confidence["wordpress_review"]["components"]
24755            .as_array_mut()
24756            .unwrap()
24757            .iter_mut()
24758            .find(|component| component["identity"]["slug"] == "termivar-child")
24759            .unwrap();
24760        theme["confidence_classes"]
24761            .as_array_mut()
24762            .unwrap()
24763            .push(serde_json::json!("operator_assertion"));
24764        assert_eq!(
24765            comparison::import_assessment_summary(
24766                &serde_json::to_vec(&false_discovery_confidence).unwrap()
24767            ),
24768            Err(comparison::ComparisonError::InvalidDocument)
24769        );
24770
24771        let mut missing_basis: serde_json::Value = serde_json::from_str(&json).unwrap();
24772        missing_basis["wordpress_review"]
24773            .as_object_mut()
24774            .unwrap()
24775            .remove("review_basis_schema");
24776        assert_eq!(
24777            comparison::import_assessment_summary(&serde_json::to_vec(&missing_basis).unwrap()),
24778            Err(comparison::ComparisonError::InvalidDocument)
24779        );
24780
24781        let mut mismatched_request_accounting: serde_json::Value =
24782            serde_json::from_str(&json).unwrap();
24783        mismatched_request_accounting["wordpress_review"]["additional_request_count"] =
24784            serde_json::json!(2);
24785        assert_eq!(
24786            comparison::import_assessment_summary(
24787                &serde_json::to_vec(&mismatched_request_accounting).unwrap()
24788            ),
24789            Err(comparison::ComparisonError::InvalidDocument)
24790        );
24791
24792        let mut invalid_slug: serde_json::Value = serde_json::from_str(&json).unwrap();
24793        invalid_slug["wordpress_discovery"]["sources"][1]["component"]["slug"] =
24794            serde_json::json!("../theme");
24795        assert_eq!(
24796            comparison::import_assessment_summary(&serde_json::to_vec(&invalid_slug).unwrap()),
24797            Err(comparison::ComparisonError::InvalidDocument)
24798        );
24799
24800        let mut duplicate_source: serde_json::Value = serde_json::from_str(&json).unwrap();
24801        let duplicate = duplicate_source["wordpress_discovery"]["sources"][1].clone();
24802        duplicate_source["wordpress_discovery"]["sources"]
24803            .as_array_mut()
24804            .unwrap()
24805            .push(duplicate);
24806        for field in [
24807            "candidate_count",
24808            "attempted_request_count",
24809            "completed_response_count",
24810            "committed_response_count",
24811            "source_count",
24812        ] {
24813            duplicate_source["wordpress_discovery"][field] = serde_json::json!(4);
24814        }
24815        duplicate_source["wordpress_discovery"]["response_bytes"] = serde_json::json!(1_024);
24816        duplicate_source["wordpress_review"]["additional_request_count"] = serde_json::json!(4);
24817        assert_eq!(
24818            comparison::import_assessment_summary(&serde_json::to_vec(&duplicate_source).unwrap()),
24819            Err(comparison::ComparisonError::InvalidDocument)
24820        );
24821
24822        let mut false_candidate_limit: serde_json::Value = serde_json::from_str(&json).unwrap();
24823        false_candidate_limit["wordpress_discovery"]["candidate_limit_reached"] =
24824            serde_json::json!(true);
24825        assert_eq!(
24826            comparison::import_assessment_summary(
24827                &serde_json::to_vec(&false_candidate_limit).unwrap()
24828            ),
24829            Err(comparison::ComparisonError::InvalidDocument)
24830        );
24831
24832        let mut too_many_theme_requests: serde_json::Value = serde_json::from_str(&json).unwrap();
24833        for ordinal in 0..3 {
24834            too_many_theme_requests["wordpress_discovery"]["sources"]
24835                .as_array_mut()
24836                .unwrap()
24837                .push(serde_json::json!({
24838                    "kind": "theme_stylesheet",
24839                    "component": {
24840                        "kind": "theme",
24841                        "slug": format!("quota-theme-{ordinal}"),
24842                    },
24843                    "parent_depth": 1,
24844                    "outcome": "request_failed",
24845                    "request_attempted": true,
24846                    "response_bytes": 0,
24847                    "evidence_reference_count": 0,
24848                    "evidence_references": [],
24849                }));
24850        }
24851        too_many_theme_requests["wordpress_discovery"]["candidate_count"] = serde_json::json!(6);
24852        too_many_theme_requests["wordpress_discovery"]["attempted_request_count"] =
24853            serde_json::json!(6);
24854        too_many_theme_requests["wordpress_discovery"]["source_count"] = serde_json::json!(6);
24855        too_many_theme_requests["wordpress_review"]["additional_request_count"] =
24856            serde_json::json!(6);
24857        assert_eq!(
24858            comparison::import_assessment_summary(
24859                &serde_json::to_vec(&too_many_theme_requests).unwrap()
24860            ),
24861            Err(comparison::ComparisonError::InvalidDocument),
24862            "the strict reader must reject a reconciled audit above the three-theme request ceiling"
24863        );
24864
24865        let mut invalid_producer_slug = discovered_wordpress_assessment_document();
24866        invalid_producer_slug
24867            .wordpress_discovery
24868            .as_mut()
24869            .unwrap()
24870            .sources[1]
24871            .component
24872            .as_mut()
24873            .unwrap()
24874            .slug = "../theme".to_owned();
24875        assert_eq!(
24876            render_assessment_with_limit(&invalid_producer_slug, ReportFormat::Json, usize::MAX),
24877            Err(ReportError::Serialization)
24878        );
24879
24880        let mut false_candidate_limit_producer = discovered_wordpress_assessment_document();
24881        false_candidate_limit_producer
24882            .wordpress_discovery
24883            .as_mut()
24884            .unwrap()
24885            .candidate_limit_reached = true;
24886        assert_eq!(
24887            render_assessment_with_limit(
24888                &false_candidate_limit_producer,
24889                ReportFormat::Json,
24890                usize::MAX
24891            ),
24892            Err(ReportError::Serialization)
24893        );
24894
24895        let mut too_many_theme_requests_producer = discovered_wordpress_assessment_document();
24896        let discovery = too_many_theme_requests_producer
24897            .wordpress_discovery
24898            .as_mut()
24899            .unwrap();
24900        for ordinal in 0..3 {
24901            discovery.sources.push(WordPressDiscoverySourceDocument {
24902                kind: "theme_stylesheet",
24903                association: "same_theme_base_parent",
24904                resource_reference: format!("sha256:{:064x}", ordinal + 4),
24905                role_reference: Some(format!("sha256:{}", "d".repeat(64))),
24906                component: Some(WordPressComponentIdentityDocument {
24907                    kind: "theme",
24908                    slug: format!("quota-theme-{ordinal}"),
24909                }),
24910                parent_depth: 1,
24911                outcome: "request_failed",
24912                request_attempted: true,
24913                response_bytes: 0,
24914                evidence_reference_count: 0,
24915                evidence_references: Vec::new(),
24916                source_page_references: Vec::new(),
24917                source_supplied_session_page_references: None,
24918                namespaces: Vec::new(),
24919                theme: None,
24920                plugin: None,
24921            });
24922        }
24923        discovery.candidate_count = 6;
24924        discovery.attempted_request_count = 6;
24925        discovery.source_count = 6;
24926        too_many_theme_requests_producer
24927            .wordpress_review
24928            .as_mut()
24929            .unwrap()
24930            .additional_request_count = 6;
24931        assert_eq!(
24932            render_assessment_with_limit(
24933                &too_many_theme_requests_producer,
24934                ReportFormat::Json,
24935                usize::MAX
24936            ),
24937            Err(ReportError::Serialization),
24938            "the report writer must reject a reconciled audit above the three-theme request ceiling"
24939        );
24940
24941        // The broker charges the whole transport-delivered chunk that crosses
24942        // its two-MiB retention boundary. That chunk has no source-level fixed
24943        // maximum, so the strict reader validates u64 accounting and exact
24944        // reconciliation rather than inventing a three-MiB wire ceiling.
24945        let mut chunk_overrun: serde_json::Value = serde_json::from_str(&json).unwrap();
24946        let observed = 4_u64 * 1_024 * 1_024;
24947        chunk_overrun["wordpress_discovery"]["sources"][0]["response_bytes"] =
24948            serde_json::json!(observed);
24949        chunk_overrun["wordpress_discovery"]["response_bytes"] = serde_json::json!(observed + 512);
24950        comparison::import_assessment_summary(&serde_json::to_vec(&chunk_overrun).unwrap())
24951            .unwrap();
24952    }
24953
24954    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
24955    #[test]
24956    fn page_scoped_discovery_v3_is_strict_feature_independent_and_compared_as_methodology() {
24957        let document = page_scoped_wordpress_assessment_document();
24958        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
24959        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
24960        let discovery = &parsed["wordpress_discovery"];
24961        assert_eq!(discovery["schema"], WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V3);
24962        assert_eq!(
24963            discovery["policy_id"],
24964            WORDPRESS_PAGE_SCOPED_DISCOVERY_POLICY_ID
24965        );
24966        assert_eq!(discovery["page_collection"]["mode"], "observed");
24967        assert_eq!(
24968            discovery["page_collection"]["interpreted_response_bytes"],
24969            128
24970        );
24971        assert_eq!(discovery["page_collection"]["response_bytes"], 0);
24972        assert_eq!(
24973            discovery["sources"][1]["source_page_references"],
24974            serde_json::json!([format!("sha256:{}", "9".repeat(64))])
24975        );
24976        comparison::import_assessment_summary(json.as_bytes()).unwrap();
24977
24978        let self_comparison = comparison::compare_reports(
24979            json.as_bytes(),
24980            json.as_bytes(),
24981            comparison::ComparisonFormat::Json,
24982        )
24983        .unwrap();
24984        let self_comparison: serde_json::Value = serde_json::from_str(&self_comparison).unwrap();
24985        assert_eq!(
24986            self_comparison["wordpress_review_comparison"]["methodology"]["status"],
24987            "unchanged"
24988        );
24989        assert_eq!(
24990            self_comparison["wordpress_review_comparison"]["discovery_source_content"]["status"],
24991            "unchanged"
24992        );
24993
24994        let entry_only = discovered_wordpress_assessment_document();
24995        let entry_only =
24996            render_assessment_with_limit(&entry_only, ReportFormat::Json, usize::MAX).unwrap();
24997        let scope_change = comparison::compare_reports(
24998            entry_only.as_bytes(),
24999            json.as_bytes(),
25000            comparison::ComparisonFormat::Json,
25001        )
25002        .unwrap();
25003        let scope_change: serde_json::Value = serde_json::from_str(&scope_change).unwrap();
25004        assert_eq!(
25005            scope_change["wordpress_review_comparison"]["methodology"]["status"],
25006            "changed"
25007        );
25008        assert_eq!(
25009            scope_change["wordpress_review_comparison"]["coverage"]["status"],
25010            "changed"
25011        );
25012
25013        for mutation in [
25014            "missing_page_collection",
25015            "missing_source_page_reference",
25016            "unknown_source_page_reference",
25017            "fabricated_page_evidence_count",
25018        ] {
25019            let mut invalid = parsed.clone();
25020            match mutation {
25021                "missing_page_collection" => {
25022                    invalid["wordpress_discovery"]
25023                        .as_object_mut()
25024                        .unwrap()
25025                        .remove("page_collection");
25026                },
25027                "missing_source_page_reference" => {
25028                    invalid["wordpress_discovery"]["sources"][1]
25029                        .as_object_mut()
25030                        .unwrap()
25031                        .remove("source_page_references");
25032                },
25033                "unknown_source_page_reference" => {
25034                    invalid["wordpress_discovery"]["sources"][1]["source_page_references"] =
25035                        serde_json::json!([format!("sha256:{}", "7".repeat(64))]);
25036                },
25037                "fabricated_page_evidence_count" => {
25038                    invalid["wordpress_discovery"]["page_collection"]["pages"][0]
25039                        ["evidence_reference_count"] = serde_json::json!(0);
25040                },
25041                _ => unreachable!(),
25042            }
25043            assert_eq!(
25044                comparison::import_assessment_summary(&serde_json::to_vec(&invalid).unwrap()),
25045                Err(comparison::ComparisonError::InvalidDocument),
25046                "mutation {mutation} must fail closed"
25047            );
25048        }
25049    }
25050
25051    #[cfg(all(
25052        feature = "scanning",
25053        feature = "wordpress-review",
25054        feature = "supplied-session-review"
25055    ))]
25056    #[test]
25057    fn supplied_session_discovery_v4_is_cross_linked_rendered_and_compared() {
25058        let document = supplied_session_wordpress_assessment_document();
25059        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
25060        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
25061        let discovery = &parsed["wordpress_discovery"];
25062        assert_eq!(discovery["schema"], WORDPRESS_DISCOVERY_AUDIT_SCHEMA_V4);
25063        assert_eq!(
25064            discovery["policy_id"],
25065            WORDPRESS_SUPPLIED_SESSION_DISCOVERY_POLICY_ID
25066        );
25067        assert_eq!(
25068            discovery["supplied_session_pages"]["mode"],
25069            "committed_supplied_session_resources"
25070        );
25071        assert_eq!(
25072            discovery["supplied_session_pages"]["credential_mechanism"],
25073            "authorization_header"
25074        );
25075        assert_eq!(
25076            discovery["supplied_session_pages"]["pages"][0]["fingerprint_evaluation"],
25077            "not_selected_in_v1"
25078        );
25079        assert_eq!(
25080            discovery["sources"][0]["source_supplied_session_page_references"],
25081            serde_json::json!([])
25082        );
25083        assert_eq!(
25084            discovery["sources"][2]["source_supplied_session_page_references"],
25085            serde_json::json!([format!("sha256:{}", "b".repeat(64))])
25086        );
25087        assert!(discovery.get("page_collection").is_none());
25088        comparison::import_assessment_summary(json.as_bytes()).unwrap();
25089
25090        for format in [
25091            ReportFormat::Html,
25092            ReportFormat::Markdown,
25093            ReportFormat::Csv,
25094        ] {
25095            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
25096            assert!(rendered.contains("supplied-session"));
25097            assert!(rendered.contains("not_selected_in_v1"));
25098            assert!(!rendered.contains("Bearer "));
25099            assert!(!rendered.contains("Cookie:"));
25100        }
25101
25102        let comparison = comparison::compare_reports(
25103            json.as_bytes(),
25104            json.as_bytes(),
25105            comparison::ComparisonFormat::Json,
25106        )
25107        .unwrap();
25108        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
25109        let wordpress = &comparison["wordpress_review_comparison"];
25110        assert_eq!(
25111            wordpress["schema"],
25112            "termivar-wordpress-review-comparison/v4"
25113        );
25114        assert_eq!(wordpress["status"], "compared");
25115        assert_eq!(wordpress["methodology"]["status"], "unchanged");
25116        assert_eq!(
25117            wordpress["components"]["only_in_before"],
25118            serde_json::json!([])
25119        );
25120        assert_eq!(
25121            wordpress["components"]["only_in_after"],
25122            serde_json::json!([])
25123        );
25124
25125        let mut changed_coverage = supplied_session_wordpress_assessment_document();
25126        let pages = changed_coverage
25127            .wordpress_discovery
25128            .as_mut()
25129            .unwrap()
25130            .supplied_session_pages
25131            .as_mut()
25132            .unwrap();
25133        pages.pages[0].interpreted_response_bytes = 22;
25134        pages.interpreted_response_bytes = 22;
25135        let changed_coverage =
25136            render_assessment_with_limit(&changed_coverage, ReportFormat::Json, usize::MAX)
25137                .unwrap();
25138        let comparison = comparison::compare_reports(
25139            json.as_bytes(),
25140            changed_coverage.as_bytes(),
25141            comparison::ComparisonFormat::Json,
25142        )
25143        .unwrap();
25144        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
25145        let wordpress = &comparison["wordpress_review_comparison"];
25146        assert_eq!(wordpress["status"], "compared");
25147        assert_eq!(wordpress["methodology"]["status"], "unchanged");
25148        assert_eq!(wordpress["coverage"]["status"], "changed");
25149        assert_eq!(
25150            wordpress["components"]["only_in_before"],
25151            serde_json::json!([])
25152        );
25153        assert_eq!(
25154            wordpress["components"]["only_in_after"],
25155            serde_json::json!([])
25156        );
25157
25158        let old_entry_only = discovered_wordpress_assessment_document();
25159        let old_entry_only =
25160            render_assessment_with_limit(&old_entry_only, ReportFormat::Json, usize::MAX).unwrap();
25161        let old_entry_only: serde_json::Value = serde_json::from_str(&old_entry_only).unwrap();
25162        assert!(old_entry_only["wordpress_discovery"]
25163            .get("supplied_session_pages")
25164            .is_none());
25165        assert!(old_entry_only["wordpress_discovery"]["sources"]
25166            .as_array()
25167            .unwrap()
25168            .iter()
25169            .all(|source| source
25170                .get("source_supplied_session_page_references")
25171                .is_none()));
25172    }
25173
25174    #[cfg(all(
25175        feature = "scanning",
25176        feature = "wordpress-review",
25177        feature = "supplied-session-review"
25178    ))]
25179    #[test]
25180    fn supplied_session_discovery_v4_rejects_broken_cross_links_and_context_pairing() {
25181        let valid = supplied_session_wordpress_assessment_document();
25182        let valid_json =
25183            render_assessment_with_limit(&valid, ReportFormat::Json, usize::MAX).unwrap();
25184        let valid_value: serde_json::Value = serde_json::from_str(&valid_json).unwrap();
25185
25186        for mutation in [
25187            "missing_source_array",
25188            "unknown_source_page",
25189            "resource_reference_mismatch",
25190            "resource_evidence_mismatch",
25191            "policy_mismatch",
25192            "committed_count_mismatch",
25193            "rejected_page_as_source",
25194        ] {
25195            let mut invalid = valid_value.clone();
25196            match mutation {
25197                "missing_source_array" => {
25198                    invalid["wordpress_discovery"]["sources"][0]
25199                        .as_object_mut()
25200                        .unwrap()
25201                        .remove("source_supplied_session_page_references");
25202                },
25203                "unknown_source_page" => {
25204                    invalid["wordpress_discovery"]["sources"][2]
25205                        ["source_supplied_session_page_references"] =
25206                        serde_json::json!([format!("sha256:{}", "c".repeat(64))]);
25207                },
25208                "resource_reference_mismatch" => {
25209                    invalid["wordpress_discovery"]["supplied_session_pages"]["pages"][0]
25210                        ["resource_reference"] = serde_json::json!(format!(
25211                        "supplied-session-resource-sha256:{}",
25212                        "c".repeat(64)
25213                    ));
25214                },
25215                "resource_evidence_mismatch" => {
25216                    invalid["wordpress_discovery"]["supplied_session_pages"]["pages"][0]
25217                        ["resource_evidence_reference"] = serde_json::json!(format!(
25218                        "supplied-session-resource-evidence-sha256:{}",
25219                        "c".repeat(64)
25220                    ));
25221                },
25222                "policy_mismatch" => {
25223                    invalid["wordpress_discovery"]["supplied_session_pages"]["policy_reference"] = serde_json::json!(
25224                        format!("supplied-session-policy-sha256:{}", "c".repeat(64))
25225                    );
25226                },
25227                "committed_count_mismatch" => {
25228                    invalid["wordpress_discovery"]["supplied_session_pages"]["committed_count"] =
25229                        serde_json::json!(0);
25230                },
25231                "rejected_page_as_source" => {
25232                    let page =
25233                        &mut invalid["wordpress_discovery"]["supplied_session_pages"]["pages"][0];
25234                    page["association"] = serde_json::json!("rejected");
25235                    page["outcome"] = serde_json::json!("incompatible_application");
25236                    let scope = &mut invalid["wordpress_discovery"]["supplied_session_pages"];
25237                    scope["accepted_association_count"] = serde_json::json!(0);
25238                    scope["rejected_association_count"] = serde_json::json!(1);
25239                },
25240                _ => unreachable!(),
25241            }
25242            assert_eq!(
25243                comparison::import_assessment_summary(&serde_json::to_vec(&invalid).unwrap()),
25244                Err(comparison::ComparisonError::InvalidDocument),
25245                "mutation {mutation} must fail closed"
25246            );
25247        }
25248
25249        let mut other_context = supplied_session_wordpress_assessment_document();
25250        let application_reference =
25251            format!("supplied-session-application-sha256:{}", "c".repeat(64));
25252        other_context
25253            .supplied_session
25254            .as_mut()
25255            .unwrap()
25256            .application_reference = application_reference.clone();
25257        other_context
25258            .wordpress_discovery
25259            .as_mut()
25260            .unwrap()
25261            .supplied_session_pages
25262            .as_mut()
25263            .unwrap()
25264            .application_reference = application_reference;
25265        let other_context =
25266            render_assessment_with_limit(&other_context, ReportFormat::Json, usize::MAX).unwrap();
25267        let comparison = comparison::compare_reports(
25268            valid_json.as_bytes(),
25269            other_context.as_bytes(),
25270            comparison::ComparisonFormat::Json,
25271        )
25272        .unwrap();
25273        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
25274        let wordpress = &comparison["wordpress_review_comparison"];
25275        assert_eq!(wordpress["status"], "not_compared");
25276        assert_eq!(wordpress["reason"], "supplied_session_context_mismatch");
25277        assert_eq!(wordpress["components"]["paired_unchanged_count"], 0);
25278        assert_eq!(
25279            wordpress["components"]["only_in_before"],
25280            serde_json::json!([])
25281        );
25282        assert_eq!(
25283            wordpress["components"]["only_in_after"],
25284            serde_json::json!([])
25285        );
25286        assert_eq!(
25287            comparison["only_in_before"].as_array().map(Vec::len),
25288            Some(2)
25289        );
25290        assert_eq!(
25291            comparison["only_in_after"].as_array().map(Vec::len),
25292            Some(2)
25293        );
25294
25295        // The V1 principal reference is deliberately generic. Two reports
25296        // with the same policy/application digests but different
25297        // operator-declared aliases must therefore remain different session
25298        // contexts for both top-level items and the nested WordPress audit.
25299        // The reduced supplied-session page binding does not carry that alias
25300        // and cannot independently authorize WordPress entity pairing.
25301        let mut other_principal = supplied_session_wordpress_assessment_document();
25302        other_principal
25303            .supplied_session
25304            .as_mut()
25305            .unwrap()
25306            .principal_alias = "fixture-peer".to_owned();
25307        let other_principal =
25308            render_assessment_with_limit(&other_principal, ReportFormat::Json, usize::MAX).unwrap();
25309        let comparison = comparison::compare_reports(
25310            valid_json.as_bytes(),
25311            other_principal.as_bytes(),
25312            comparison::ComparisonFormat::Json,
25313        )
25314        .unwrap();
25315        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
25316        assert_eq!(
25317            comparison["supplied_session_comparison"]["status"],
25318            "not_compared"
25319        );
25320        assert_eq!(
25321            comparison["supplied_session_comparison"]["reason"],
25322            "operator_declared_principal_changed"
25323        );
25324        let wordpress = &comparison["wordpress_review_comparison"];
25325        assert_eq!(
25326            wordpress["schema"],
25327            "termivar-wordpress-review-comparison/v4"
25328        );
25329        assert_eq!(wordpress["status"], "not_compared");
25330        assert_eq!(wordpress["reason"], "supplied_session_context_mismatch");
25331        assert_eq!(wordpress["methodology"]["status"], "unchanged");
25332        assert_eq!(
25333            wordpress["methodology"]["changed_fields"],
25334            serde_json::json!([])
25335        );
25336        assert_eq!(wordpress["coverage"]["status"], "not_established");
25337        assert_eq!(
25338            wordpress["coverage"]["changed_fields"],
25339            serde_json::json!([])
25340        );
25341        assert_eq!(wordpress["provenance"]["status"], "unchanged");
25342        assert_eq!(
25343            wordpress["provenance"]["changed_fields"],
25344            serde_json::json!([])
25345        );
25346        assert_eq!(wordpress["discovery_source_content"]["status"], "unchanged");
25347        assert_eq!(
25348            wordpress["discovery_source_content"]["changed_fields"],
25349            serde_json::json!([])
25350        );
25351        for entity_group in ["components", "advisories"] {
25352            let entities = &wordpress[entity_group];
25353            assert_eq!(entities["paired_unchanged_count"], 0);
25354            assert_eq!(entities["paired_changed"], serde_json::json!([]));
25355            assert_eq!(entities["only_in_before"], serde_json::json!([]));
25356            assert_eq!(entities["only_in_after"], serde_json::json!([]));
25357        }
25358        assert_eq!(
25359            comparison["only_in_before"].as_array().map(Vec::len),
25360            Some(2)
25361        );
25362        assert_eq!(
25363            comparison["only_in_after"].as_array().map(Vec::len),
25364            Some(2)
25365        );
25366        assert!(comparison["changed"].as_array().unwrap().is_empty());
25367        assert!(comparison["unchanged"].as_array().unwrap().is_empty());
25368
25369        // Health and coverage are outcome facets, not session identity. A
25370        // later loss for the same declared principal/policy/application must
25371        // remain comparable while the changed collection coverage is shown.
25372        let mut session_lost = supplied_session_wordpress_assessment_document();
25373        {
25374            let session = session_lost.supplied_session.as_mut().unwrap();
25375            session.outcome = "session_lost";
25376            session.coverage = "none";
25377            session.checkpoints[1].outcome = "unhealthy";
25378            session.checkpoints[1].predicate = "not_matched";
25379            session.resources[0].outcome = "health_unqualified";
25380            session.committed_resource_count = 0;
25381        }
25382        {
25383            let discovery = session_lost.wordpress_discovery.as_mut().unwrap();
25384            let pages = discovery.supplied_session_pages.as_mut().unwrap();
25385            pages.committed_count = 0;
25386            pages.accepted_association_count = 0;
25387            pages.not_established_association_count = 1;
25388            pages.not_evaluated_count = 1;
25389            pages.interpreted_response_bytes = 0;
25390            let page = &mut pages.pages[0];
25391            page.resource_evidence_reference = None;
25392            page.association = "not_established";
25393            page.outcome = "not_evaluated";
25394            page.interpreted_response_bytes = 0;
25395            page.evidence_reference_count = 0;
25396            page.evidence_references.clear();
25397            for source in &mut discovery.sources {
25398                source.source_supplied_session_page_references = Some(Vec::new());
25399            }
25400        }
25401        let discovery_item = session_lost
25402            .items
25403            .iter_mut()
25404            .find(|item| item.capability_id == WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID)
25405            .unwrap();
25406        discovery_item.evidence_count = 3;
25407        discovery_item
25408            .evidence_references
25409            .retain(|reference| reference != "evidence-0004");
25410        let session_lost =
25411            render_assessment_with_limit(&session_lost, ReportFormat::Json, usize::MAX).unwrap();
25412        let comparison = comparison::compare_reports(
25413            valid_json.as_bytes(),
25414            session_lost.as_bytes(),
25415            comparison::ComparisonFormat::Json,
25416        )
25417        .unwrap();
25418        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
25419        assert_eq!(
25420            comparison["supplied_session_comparison"]["status"],
25421            "compared_within_same_declared_context"
25422        );
25423        assert_eq!(
25424            comparison["supplied_session_comparison"]["health_and_coverage"]["status"],
25425            "changed"
25426        );
25427        let wordpress = &comparison["wordpress_review_comparison"];
25428        assert_eq!(wordpress["status"], "compared");
25429        assert!(wordpress.get("reason").is_none());
25430        assert_eq!(wordpress["methodology"]["status"], "unchanged");
25431        assert_eq!(
25432            wordpress["methodology"]["changed_fields"],
25433            serde_json::json!([])
25434        );
25435        assert_eq!(wordpress["coverage"]["status"], "changed");
25436        assert_eq!(
25437            wordpress["coverage"]["changed_fields"],
25438            serde_json::json!(["wordpress_discovery"])
25439        );
25440        assert_eq!(wordpress["provenance"]["status"], "unchanged");
25441        assert_eq!(
25442            wordpress["provenance"]["changed_fields"],
25443            serde_json::json!([])
25444        );
25445        assert_eq!(wordpress["discovery_source_content"]["status"], "changed");
25446        assert_eq!(
25447            wordpress["discovery_source_content"]["changed_fields"],
25448            serde_json::json!(["supplied_session_pages"])
25449        );
25450        assert_eq!(wordpress["components"]["paired_unchanged_count"], 2);
25451        assert_eq!(
25452            wordpress["components"]["paired_changed"]
25453                .as_array()
25454                .map(Vec::len),
25455            Some(1)
25456        );
25457        assert_eq!(
25458            wordpress["components"]["paired_changed"][0]["key"],
25459            serde_json::json!({"kind": "plugin", "slug": "termivar-metadata-lab"})
25460        );
25461        assert_eq!(
25462            wordpress["components"]["paired_changed"][0]["changed_dimensions"],
25463            serde_json::json!(["discovery_metadata"])
25464        );
25465        assert_eq!(wordpress["advisories"]["paired_unchanged_count"], 1);
25466        assert_eq!(
25467            wordpress["advisories"]["paired_changed"],
25468            serde_json::json!([])
25469        );
25470        for entity_group in ["components", "advisories"] {
25471            assert_eq!(
25472                wordpress[entity_group]["only_in_before"],
25473                serde_json::json!([])
25474            );
25475            assert_eq!(
25476                wordpress[entity_group]["only_in_after"],
25477                serde_json::json!([])
25478            );
25479        }
25480        assert!(comparison["only_in_before"].as_array().unwrap().is_empty());
25481        assert!(comparison["only_in_after"].as_array().unwrap().is_empty());
25482        assert_eq!(comparison["changed"].as_array().map(Vec::len), Some(1));
25483        assert_eq!(
25484            comparison["changed"][0]["capability_id"],
25485            WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID
25486        );
25487        assert_eq!(
25488            comparison["changed"][0]["changed_fields"],
25489            serde_json::json!(["evidence"])
25490        );
25491        assert_eq!(comparison["unchanged"].as_array().map(Vec::len), Some(1));
25492    }
25493
25494    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
25495    #[test]
25496    fn wordpress_discovery_writer_enforces_component_links_and_runtime_field_bounds() {
25497        let render = |document: &AssessmentDocument<'_>| {
25498            render_assessment_with_limit(document, ReportFormat::Json, usize::MAX)
25499        };
25500
25501        let mut missing_component = discovered_wordpress_assessment_document();
25502        let review = missing_component.wordpress_review.as_mut().unwrap();
25503        review.components.retain(|component| {
25504            !(component.identity.kind == "plugin"
25505                && component.identity.slug == "termivar-metadata-lab")
25506        });
25507        review.component_count = review.components.len();
25508        assert_eq!(render(&missing_component), Err(ReportError::Serialization));
25509
25510        let mut wrong_source_class = discovered_wordpress_assessment_document();
25511        let component = wrong_source_class
25512            .wordpress_review
25513            .as_mut()
25514            .unwrap()
25515            .components
25516            .iter_mut()
25517            .find(|component| component.identity.slug == "termivar-metadata-lab")
25518            .unwrap();
25519        component.identity_sources = vec!["operator_context"];
25520        component.confidence_classes = vec!["operator_assertion"];
25521        component.evidence_class = "operator_supplied";
25522        assert_eq!(render(&wrong_source_class), Err(ReportError::Serialization));
25523
25524        let mut metadata_at_limit = discovered_wordpress_assessment_document();
25525        metadata_at_limit
25526            .wordpress_discovery
25527            .as_mut()
25528            .unwrap()
25529            .sources[2]
25530            .plugin
25531            .as_mut()
25532            .unwrap()
25533            .name = Some("m".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_METADATA_BYTES));
25534        render(&metadata_at_limit).unwrap();
25535        metadata_at_limit
25536            .wordpress_discovery
25537            .as_mut()
25538            .unwrap()
25539            .sources[2]
25540            .plugin
25541            .as_mut()
25542            .unwrap()
25543            .name = Some("m".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_METADATA_BYTES + 1));
25544        assert_eq!(render(&metadata_at_limit), Err(ReportError::Serialization));
25545
25546        let mut version_at_limit = discovered_wordpress_assessment_document();
25547        let version = "1".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_VERSION_BYTES);
25548        version_at_limit
25549            .wordpress_discovery
25550            .as_mut()
25551            .unwrap()
25552            .sources[1]
25553            .theme
25554            .as_mut()
25555            .unwrap()
25556            .version = Some(version.clone());
25557        version_at_limit
25558            .wordpress_review
25559            .as_mut()
25560            .unwrap()
25561            .components
25562            .iter_mut()
25563            .find(|component| component.identity.slug == "termivar-child")
25564            .unwrap()
25565            .versions[0]
25566            .value = version;
25567        render(&version_at_limit).unwrap();
25568        let oversized_version = "1".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_VERSION_BYTES + 1);
25569        version_at_limit
25570            .wordpress_discovery
25571            .as_mut()
25572            .unwrap()
25573            .sources[1]
25574            .theme
25575            .as_mut()
25576            .unwrap()
25577            .version = Some(oversized_version.clone());
25578        version_at_limit
25579            .wordpress_review
25580            .as_mut()
25581            .unwrap()
25582            .components
25583            .iter_mut()
25584            .find(|component| component.identity.slug == "termivar-child")
25585            .unwrap()
25586            .versions[0]
25587            .value = oversized_version;
25588        assert_eq!(render(&version_at_limit), Err(ReportError::Serialization));
25589
25590        let mut namespace_at_limit = discovered_wordpress_assessment_document();
25591        namespace_at_limit
25592            .wordpress_discovery
25593            .as_mut()
25594            .unwrap()
25595            .sources[0]
25596            .namespaces[0] = "n".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_NAMESPACE_BYTES);
25597        render(&namespace_at_limit).unwrap();
25598        namespace_at_limit
25599            .wordpress_discovery
25600            .as_mut()
25601            .unwrap()
25602            .sources[0]
25603            .namespaces[0] = "n".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_NAMESPACE_BYTES + 1);
25604        assert_eq!(render(&namespace_at_limit), Err(ReportError::Serialization));
25605    }
25606
25607    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
25608    #[test]
25609    fn wordpress_discovery_writer_enforces_deployment_layout_and_source_bindings() {
25610        let render = |document: &AssessmentDocument<'_>| {
25611            render_assessment_with_limit(document, ReportFormat::Json, usize::MAX)
25612        };
25613
25614        let mut declared = discovered_wordpress_assessment_document();
25615        declared
25616            .wordpress_discovery
25617            .as_mut()
25618            .unwrap()
25619            .layout
25620            .declaration = Some(WordPressDiscoveryLayoutDeclarationDocument {
25621            schema: WORDPRESS_DISCOVERY_LAYOUT_SCHEMA,
25622            byte_length: MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES,
25623            sha256: "9".repeat(64),
25624        });
25625        let core = &mut declared.wordpress_discovery.as_mut().unwrap().layout.roles[0];
25626        core.status = "exact";
25627        core.basis = "operator_declaration";
25628        core.reference = Some(format!("sha256:{}", "c".repeat(64)));
25629        core.candidate_count = 1;
25630        render(&declared).unwrap();
25631        declared
25632            .wordpress_discovery
25633            .as_mut()
25634            .unwrap()
25635            .layout
25636            .declaration
25637            .as_mut()
25638            .unwrap()
25639            .byte_length = 0;
25640        assert_eq!(render(&declared), Err(ReportError::Serialization));
25641
25642        let mut reordered_roles = discovered_wordpress_assessment_document();
25643        reordered_roles
25644            .wordpress_discovery
25645            .as_mut()
25646            .unwrap()
25647            .layout
25648            .roles
25649            .swap(1, 2);
25650        assert_eq!(render(&reordered_roles), Err(ReportError::Serialization));
25651
25652        let mut missing_exact_reference = discovered_wordpress_assessment_document();
25653        missing_exact_reference
25654            .wordpress_discovery
25655            .as_mut()
25656            .unwrap()
25657            .layout
25658            .roles[1]
25659            .reference = None;
25660        assert_eq!(
25661            render(&missing_exact_reference),
25662            Err(ReportError::Serialization)
25663        );
25664
25665        let mut mismatched_role_reference = discovered_wordpress_assessment_document();
25666        mismatched_role_reference
25667            .wordpress_discovery
25668            .as_mut()
25669            .unwrap()
25670            .sources[1]
25671            .role_reference = Some(format!("sha256:{}", "e".repeat(64)));
25672        assert_eq!(
25673            render(&mismatched_role_reference),
25674            Err(ReportError::Serialization)
25675        );
25676
25677        let mut duplicate_resource = discovered_wordpress_assessment_document();
25678        let duplicate_reference = duplicate_resource
25679            .wordpress_discovery
25680            .as_ref()
25681            .unwrap()
25682            .sources[0]
25683            .resource_reference
25684            .clone();
25685        duplicate_resource
25686            .wordpress_discovery
25687            .as_mut()
25688            .unwrap()
25689            .sources[1]
25690            .resource_reference = duplicate_reference;
25691        assert_eq!(render(&duplicate_resource), Err(ReportError::Serialization));
25692
25693        let mut parent_only = discovered_wordpress_assessment_document();
25694        let review = parent_only.wordpress_review.as_mut().unwrap();
25695        review
25696            .components
25697            .retain(|component| component.identity.slug != "termivar-child");
25698        review.component_count = review.components.len();
25699        let source = &mut parent_only.wordpress_discovery.as_mut().unwrap().sources[1];
25700        source.component.as_mut().unwrap().slug = "termivar-parent".to_owned();
25701        source.parent_depth = 1;
25702        source.association = "same_theme_base_parent";
25703        render(&parent_only).expect(
25704            "a role-bound parent-depth source remains metadata but does not require a depth-zero review component",
25705        );
25706
25707        let review = parent_only.wordpress_review.as_mut().unwrap();
25708        review.components.push(WordPressComponentDocument {
25709            identity: WordPressComponentIdentityDocument {
25710                kind: "theme",
25711                slug: "termivar-parent".to_owned(),
25712            },
25713            evidence_class: "observed_hint",
25714            identity_sources: vec!["same_origin_asset_path", "theme_stylesheet_declaration"],
25715            confidence_classes: vec!["structural_hint", "public_declaration"],
25716            versions: vec![WordPressVersionEvidenceDocument {
25717                value: "1.2.3".to_owned(),
25718                source: "theme_stylesheet_declaration",
25719                confidence: "public_declaration",
25720            }],
25721            activation: None,
25722            inventory_status: None,
25723        });
25724        review.component_count = review.components.len();
25725        render(&parent_only)
25726            .expect("a projected same-base parent version must match its discovery source");
25727
25728        parent_only
25729            .wordpress_review
25730            .as_mut()
25731            .unwrap()
25732            .components
25733            .last_mut()
25734            .unwrap()
25735            .versions[0]
25736            .value = "9.9.9".to_owned();
25737        assert_eq!(render(&parent_only), Err(ReportError::Serialization));
25738    }
25739
25740    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
25741    #[test]
25742    fn wordpress_discovery_importer_rejects_unlinked_components_and_runtime_bound_overruns() {
25743        let json = render_assessment_with_limit(
25744            &discovered_wordpress_assessment_document(),
25745            ReportFormat::Json,
25746            usize::MAX,
25747        )
25748        .unwrap();
25749        let valid: serde_json::Value = serde_json::from_str(&json).unwrap();
25750
25751        let mut unlinked = valid.clone();
25752        unlinked["wordpress_discovery"]["sources"][2]["component"]["slug"] =
25753            serde_json::json!("unseen-plugin");
25754        assert_eq!(
25755            comparison::import_assessment_summary(&serde_json::to_vec(&unlinked).unwrap()),
25756            Err(comparison::ComparisonError::InvalidDocument)
25757        );
25758
25759        let mut metadata_at_limit = valid.clone();
25760        metadata_at_limit["wordpress_discovery"]["sources"][2]["plugin"]["name"] =
25761            serde_json::json!("m".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_METADATA_BYTES));
25762        comparison::import_assessment_summary(&serde_json::to_vec(&metadata_at_limit).unwrap())
25763            .unwrap();
25764        metadata_at_limit["wordpress_discovery"]["sources"][2]["plugin"]["name"] =
25765            serde_json::json!("m".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_METADATA_BYTES + 1));
25766        assert_eq!(
25767            comparison::import_assessment_summary(&serde_json::to_vec(&metadata_at_limit).unwrap()),
25768            Err(comparison::ComparisonError::InvalidDocument)
25769        );
25770
25771        let mut version_at_limit = valid.clone();
25772        let version = "1".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_VERSION_BYTES);
25773        version_at_limit["wordpress_discovery"]["sources"][1]["theme"]["version"] =
25774            serde_json::json!(version);
25775        version_at_limit["wordpress_review"]["components"]
25776            .as_array_mut()
25777            .unwrap()
25778            .iter_mut()
25779            .find(|component| component["identity"]["slug"] == "termivar-child")
25780            .unwrap()["versions"][0]["value"] = serde_json::json!(version);
25781        comparison::import_assessment_summary(&serde_json::to_vec(&version_at_limit).unwrap())
25782            .unwrap();
25783        let oversized_version = "1".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_VERSION_BYTES + 1);
25784        version_at_limit["wordpress_discovery"]["sources"][1]["theme"]["version"] =
25785            serde_json::json!(oversized_version);
25786        version_at_limit["wordpress_review"]["components"]
25787            .as_array_mut()
25788            .unwrap()
25789            .iter_mut()
25790            .find(|component| component["identity"]["slug"] == "termivar-child")
25791            .unwrap()["versions"][0]["value"] = serde_json::json!(oversized_version);
25792        assert_eq!(
25793            comparison::import_assessment_summary(&serde_json::to_vec(&version_at_limit).unwrap()),
25794            Err(comparison::ComparisonError::InvalidDocument)
25795        );
25796
25797        let mut namespace_at_limit = valid.clone();
25798        namespace_at_limit["wordpress_discovery"]["sources"][0]["namespaces"][0] =
25799            serde_json::json!("n".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_NAMESPACE_BYTES));
25800        comparison::import_assessment_summary(&serde_json::to_vec(&namespace_at_limit).unwrap())
25801            .unwrap();
25802        namespace_at_limit["wordpress_discovery"]["sources"][0]["namespaces"][0] =
25803            serde_json::json!("n".repeat(MAX_WORDPRESS_DISCOVERY_AUDIT_NAMESPACE_BYTES + 1));
25804        assert_eq!(
25805            comparison::import_assessment_summary(
25806                &serde_json::to_vec(&namespace_at_limit).unwrap()
25807            ),
25808            Err(comparison::ComparisonError::InvalidDocument)
25809        );
25810    }
25811
25812    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
25813    #[test]
25814    fn profiled_wordpress_reporting_exposes_method_and_uncertainty_in_every_format() {
25815        let document = profiled_wordpress_assessment_document();
25816        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
25817        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
25818        let advisory = &parsed["wordpress_review"]["advisories"][0];
25819        assert_eq!(
25820            parsed["wordpress_review"]["schema"],
25821            "security.wordpress-review-audit/v2"
25822        );
25823        assert_eq!(advisory["comparison_profile"], "php-release-subset/v1");
25824        assert_eq!(advisory["version_resolution"], "supported_equivalent");
25825        assert_eq!(
25826            advisory["version_resolution_reason"],
25827            "single_supported_version"
25828        );
25829        assert_eq!(advisory["version_relation"], "within_declared_range");
25830        assert_eq!(
25831            advisory["applicability"],
25832            "candidate_match_on_declared_facts"
25833        );
25834        assert_eq!(advisory["exploit_execution"], "not_performed");
25835        assert_eq!(advisory["impact_validation"], "not_performed");
25836        assert!(parsed["wordpress_review"].get("external_review").is_none());
25837
25838        for format in [
25839            ReportFormat::Html,
25840            ReportFormat::Markdown,
25841            ReportFormat::Csv,
25842        ] {
25843            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
25844            assert!(rendered.contains("security.wordpress-review-audit/v2"));
25845            assert!(rendered.contains("php-release-subset/v1"));
25846            assert!(rendered.contains("single_supported_version"));
25847            assert!(rendered.contains("not_performed"));
25848            assert!(!rendered.contains("confirmed_vulnerability"));
25849        }
25850    }
25851
25852    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
25853    #[test]
25854    fn external_wordpress_reporting_is_source_qualified_and_reader_compatible() {
25855        let document = external_wordpress_assessment_document();
25856        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
25857        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
25858        let audit = &parsed["wordpress_review"];
25859        let external = &audit["external_review"];
25860        assert_eq!(audit["schema"], "security.wordpress-review-audit/v4");
25861        assert_eq!(audit["catalog_status"], "evaluated");
25862        assert!(audit.get("catalog").is_none());
25863        assert!(audit.get("catalog_schema").is_none());
25864        assert!(audit.get("inventory_import").is_none());
25865        assert_eq!(audit["advisory_count"], 0);
25866        assert_eq!(audit["advisories"], serde_json::json!([]));
25867        assert_eq!(external["source_namespace"], "wordfence-intelligence");
25868        assert_eq!(external["source_format"], "wordfence-v3-production");
25869        assert_eq!(
25870            external["mapping_revision"],
25871            "termivar-wordfence-v3-production/v1"
25872        );
25873        assert_eq!(
25874            external["comparison_policy"],
25875            "wordfence-v3/source-semantics-unresolved/v1"
25876        );
25877        assert_eq!(external["input"]["byte_length"], 3883);
25878        assert_eq!(
25879            external["input"]["sha256"],
25880            "d9ed3140f44ae1e0a3746beb9937de2d829d28c068101120a9104c3349901db7"
25881        );
25882        assert!(external["input"]["semantic_sha256"]
25883            .as_str()
25884            .is_some_and(valid_lowercase_sha256));
25885        assert_eq!(external["counts"]["parsed_records"], 2);
25886        assert_eq!(external["counts"]["software_associations"], 3);
25887        assert_eq!(external["counts"]["selected_associations"], 1);
25888        assert_eq!(external["counts"]["evaluable_associations"], 0);
25889        assert_eq!(external["counts"]["unsupported_associations"], 1);
25890        assert_eq!(external["counts"]["excluded_associations"], 2);
25891        for absent in [
25892            "comparison_profile",
25893            "policy_selection",
25894            "source_semantics_assurance",
25895            "identity_mapping_policy",
25896            "identity_source_assurance",
25897            "resource_policy",
25898        ] {
25899            assert!(external.get(absent).is_none());
25900        }
25901        assert!(external["input"].get("accounted_retained_bytes").is_none());
25902        for absent in [
25903            "exact_identity_associations",
25904            "candidate_identity_associations",
25905            "ambiguous_identity_associations",
25906            "unresolved_identity_associations",
25907            "projected_identity_limitations",
25908            "unprojected_identity_limitations",
25909            "within_associations",
25910            "outside_associations",
25911            "indeterminate_associations",
25912            "selected_ranges",
25913            "evaluated_ranges",
25914            "containing_ranges",
25915            "noncontaining_ranges",
25916            "unsupported_ranges",
25917            "invalid_ranges",
25918            "not_evaluated_ranges",
25919            "partial_range_coverage_associations",
25920        ] {
25921            assert!(external["counts"].get(absent).is_none());
25922        }
25923        assert_eq!(external["notices"].as_array().unwrap().len(), 1);
25924        let evaluation = &external["evaluations"][0];
25925        assert!(evaluation.get("identity_mapping").is_none());
25926        assert!(evaluation.get("identity_collision_raw_count").is_none());
25927        assert!(evaluation["key"].get("source_component").is_none());
25928        assert_eq!(evaluation["key"]["component"]["kind"], "plugin");
25929        assert_eq!(
25930            evaluation["key"]["component"]["slug"],
25931            "termivar-fixture-component"
25932        );
25933        assert_eq!(
25934            evaluation["version_relation"],
25935            "source_comparison_semantics_unresolved"
25936        );
25937        assert_eq!(
25938            evaluation["version_evidence_resolution"]["status"],
25939            "missing"
25940        );
25941        assert_eq!(
25942            evaluation["version_evidence_resolution"]["evidence_row_count"],
25943            0
25944        );
25945        assert_eq!(
25946            evaluation["version_evidence_resolution"]["distinct_spelling_count"],
25947            0
25948        );
25949        assert_eq!(evaluation["applicability"], "indeterminate_unsupported");
25950        assert!(evaluation.get("range_evaluations").is_none());
25951        assert!(evaluation.get("version_relation_reason").is_none());
25952        assert!(evaluation["version_evidence_resolution"]
25953            .get("semantic_status")
25954            .is_none());
25955        assert!(evaluation["version_evidence_resolution"]
25956            .get("semantic_reason")
25957            .is_none());
25958        assert_eq!(
25959            evaluation["record_reference"],
25960            "https://example.invalid/termivar/wordfence-v3/fixture-advisory-1"
25961        );
25962        assert_eq!(
25963            evaluation["execution"]["exploit_execution"],
25964            "not_performed"
25965        );
25966        assert_eq!(
25967            evaluation["execution"]["impact_validation"],
25968            "not_performed"
25969        );
25970        assert!(comparison::import_assessment_summary(json.as_bytes()).is_ok());
25971
25972        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
25973        assert!(html.contains("<h3>External source attribution</h3>"));
25974        assert!(html.contains(
25975            "rel=\"noreferrer noopener\" href=\"https://example.invalid/termivar/wordfence-v3/fixture-advisory-1\">Selected source reference</a>"
25976        ));
25977        assert!(html.contains("Synthetic fixture material created for Termivar parser testing."));
25978        assert!(html.contains("This fictional fixture text may be copied"));
25979        assert!(html.contains(
25980            "href=\"https://example.invalid/termivar/wordfence-v3/synthetic-fixture-terms\">License terms</a>"
25981        ));
25982
25983        let markdown =
25984            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
25985        assert!(markdown.contains("\n#### External source attribution\n"));
25986        assert!(!markdown.contains("\n### External source attribution\n"));
25987        assert!(markdown.contains(
25988            "[Selected source reference](<https://example.invalid/termivar/wordfence-v3/fixture-advisory-1>)"
25989        ));
25990        assert!(markdown.contains("\n##### Rights notices\n"));
25991        assert!(!markdown.contains("\n#### Rights notices\n"));
25992        assert!(markdown.contains(
25993            "[License terms](<https://example.invalid/termivar/wordfence-v3/synthetic-fixture-terms>)"
25994        ));
25995
25996        for format in [
25997            ReportFormat::Html,
25998            ReportFormat::Markdown,
25999            ReportFormat::Csv,
26000        ] {
26001            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
26002            for expected in [
26003                "security.wordpress-review-audit/v4",
26004                "wordfence-intelligence",
26005                "source_comparison_semantics_unresolved",
26006                "indeterminate_unsupported",
26007                "not_performed",
26008            ] {
26009                assert!(rendered.contains(expected), "{format:?} omitted {expected}");
26010            }
26011            assert!(!rendered.contains("candidate_match_on_declared_facts"));
26012            assert!(!rendered.contains("confirmed_vulnerability"));
26013            for v5_only_label in [
26014                "Comparison profile",
26015                "Policy selection",
26016                "Source comparison semantics assurance",
26017                "Version-relation reason",
26018                "Semantic version resolution",
26019                "Semantic resolution reason",
26020                "Within-range associations",
26021                "Outside-range associations",
26022                "Indeterminate associations",
26023                "Selected source ranges",
26024                "Evaluated ranges",
26025                "Containing ranges",
26026                "Noncontaining ranges",
26027                "Unsupported ranges",
26028                "Invalid ranges",
26029                "Ranges not evaluated",
26030                "Qualified partial-range matches",
26031            ] {
26032                assert!(
26033                    !rendered.contains(v5_only_label),
26034                    "{format:?} changed the v4 presentation with {v5_only_label}"
26035                );
26036            }
26037        }
26038
26039        for (parsed_records, software_associations, excluded_associations) in
26040            [(0_usize, 1_usize, 0_usize), (1, 2_049, 2_048)]
26041        {
26042            let mut invalid = external_wordpress_assessment_document();
26043            let counts = &mut invalid
26044                .wordpress_review
26045                .as_mut()
26046                .unwrap()
26047                .external_review
26048                .as_mut()
26049                .unwrap()
26050                .counts;
26051            counts.parsed_records = parsed_records;
26052            counts.software_associations = software_associations;
26053            counts.excluded_associations = excluded_associations;
26054            for format in ReportGenerator::available_formats() {
26055                assert_eq!(
26056                    render_assessment_with_limit(&invalid, *format, usize::MAX),
26057                    Err(ReportError::Serialization)
26058                );
26059            }
26060        }
26061    }
26062
26063    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
26064    #[test]
26065    fn explicit_external_policy_reporting_is_v5_strict_and_reader_compatible() {
26066        for (profile, profile_id, relation, reason, applicability, within, outside) in [
26067            (
26068                WordPressComparisonProfile::NumericDottedV1,
26069                "numeric-dotted/v1",
26070                "within_supported_range_under_selected_policy",
26071                "containing_range",
26072                "version_match_under_selected_policy",
26073                1_u64,
26074                0_u64,
26075            ),
26076            (
26077                WordPressComparisonProfile::PhpReleaseSubsetV1,
26078                "php-release-subset/v1",
26079                "outside_declared_ranges_under_selected_policy",
26080                "all_ranges_outside",
26081                "no_version_match_under_selected_policy",
26082                0,
26083                1,
26084            ),
26085        ] {
26086            let document = explicitly_profiled_external_wordpress_assessment_document(profile);
26087            let json =
26088                render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
26089            let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
26090            let audit = &parsed["wordpress_review"];
26091            let external = &audit["external_review"];
26092            assert!(comparison::import_assessment_summary(json.as_bytes()).is_ok());
26093            let self_comparison = comparison::compare_reports(
26094                json.as_bytes(),
26095                json.as_bytes(),
26096                comparison::ComparisonFormat::Json,
26097            )
26098            .unwrap();
26099            let self_comparison: serde_json::Value =
26100                serde_json::from_str(&self_comparison).unwrap();
26101            assert_eq!(
26102                self_comparison["wordpress_review_comparison"]["status"],
26103                "compared"
26104            );
26105            assert_eq!(
26106                self_comparison["wordpress_review_comparison"]["advisories"]
26107                    ["paired_unchanged_count"],
26108                1
26109            );
26110            let evaluation = &external["evaluations"][0];
26111
26112            assert_eq!(audit["schema"], "security.wordpress-review-audit/v5");
26113            assert_eq!(
26114                external["comparison_policy"],
26115                "termivar.wordfence-v3-explicit-interpretation/v1"
26116            );
26117            assert_eq!(external["comparison_profile"], profile_id);
26118            assert_eq!(external["policy_selection"], "explicit_operator");
26119            assert_eq!(external["source_semantics_assurance"], "not_established");
26120            for absent in [
26121                "identity_mapping_policy",
26122                "identity_source_assurance",
26123                "resource_policy",
26124            ] {
26125                assert!(external.get(absent).is_none());
26126            }
26127            assert!(external["input"].get("accounted_retained_bytes").is_none());
26128            assert_eq!(external["counts"]["selected_associations"], 1);
26129            assert_eq!(external["counts"]["evaluable_associations"], 1);
26130            assert_eq!(external["counts"]["unsupported_associations"], 0);
26131            assert_eq!(external["counts"]["within_associations"], within);
26132            assert_eq!(external["counts"]["outside_associations"], outside);
26133            assert_eq!(external["counts"]["indeterminate_associations"], 0);
26134            assert_eq!(external["counts"]["selected_ranges"], 2);
26135            assert_eq!(external["counts"]["evaluated_ranges"], 2);
26136            assert_eq!(external["counts"]["unsupported_ranges"], 0);
26137            assert_eq!(external["counts"]["invalid_ranges"], 0);
26138            assert_eq!(external["counts"]["not_evaluated_ranges"], 0);
26139            assert_eq!(evaluation["version_relation"], relation);
26140            assert!(evaluation.get("identity_mapping").is_none());
26141            assert!(evaluation.get("identity_collision_raw_count").is_none());
26142            assert!(evaluation["key"].get("source_component").is_none());
26143            assert_eq!(evaluation["version_relation_reason"], reason);
26144            assert_eq!(evaluation["applicability"], applicability);
26145            assert_eq!(
26146                evaluation["version_evidence_resolution"]["semantic_status"],
26147                "supported_equivalent"
26148            );
26149            assert_eq!(
26150                evaluation["version_evidence_resolution"]["semantic_reason"],
26151                "single_supported_version"
26152            );
26153            assert_eq!(evaluation["range_evaluations"].as_array().unwrap().len(), 2);
26154            assert_eq!(
26155                evaluation["execution"]["exploit_execution"],
26156                "not_performed"
26157            );
26158            assert_eq!(
26159                evaluation["execution"]["impact_validation"],
26160                "not_performed"
26161            );
26162            assert!(comparison::import_assessment_summary(json.as_bytes()).is_ok());
26163
26164            for format in [
26165                ReportFormat::Html,
26166                ReportFormat::Markdown,
26167                ReportFormat::Csv,
26168            ] {
26169                let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
26170                for expected in [
26171                    "security.wordpress-review-audit/v5",
26172                    "termivar.wordfence-v3-explicit-interpretation/v1",
26173                    profile_id,
26174                    "explicit_operator",
26175                    "not_established",
26176                    relation,
26177                    reason,
26178                    applicability,
26179                    "not_performed",
26180                ] {
26181                    assert!(rendered.contains(expected), "{format:?} omitted {expected}");
26182                }
26183                assert!(!rendered.contains("confirmed_vulnerability"));
26184                assert!(!rendered.contains("verified_remediation"));
26185                assert!(!rendered.contains("Unsupported relevant associations"));
26186            }
26187        }
26188
26189        let mut source_conflict = explicitly_profiled_external_wordpress_assessment_document(
26190            WordPressComparisonProfile::NumericDottedV1,
26191        );
26192        let external = source_conflict
26193            .wordpress_review
26194            .as_mut()
26195            .unwrap()
26196            .external_review
26197            .as_mut()
26198            .unwrap();
26199        external.counts.within_associations = Some(0);
26200        external.counts.indeterminate_associations = Some(1);
26201        external.counts.evaluable_associations = 0;
26202        external.counts.unsupported_associations = 1;
26203        let evaluation = &mut external.evaluations[0];
26204        evaluation.source_patched_versions[0] = "1.1".to_owned();
26205        evaluation.version_relation = "indeterminate";
26206        evaluation.version_relation_reason = Some("source_patched_version_within_affected_range");
26207        evaluation.applicability = "indeterminate";
26208        let source_conflict_json =
26209            render_assessment_with_limit(&source_conflict, ReportFormat::Json, usize::MAX).unwrap();
26210        assert!(source_conflict_json.contains("source_patched_version_within_affected_range"));
26211        assert!(comparison::import_assessment_summary(source_conflict_json.as_bytes()).is_ok());
26212
26213        let mut stale_source_conflict = source_conflict;
26214        stale_source_conflict
26215            .wordpress_review
26216            .as_mut()
26217            .unwrap()
26218            .external_review
26219            .as_mut()
26220            .unwrap()
26221            .evaluations[0]
26222            .version_relation_reason = Some("containing_range");
26223        for format in ReportGenerator::available_formats() {
26224            assert_eq!(
26225                render_assessment_with_limit(&stale_source_conflict, *format, usize::MAX),
26226                Err(ReportError::Serialization)
26227            );
26228        }
26229
26230        let mut invalid_documents = Vec::new();
26231
26232        let mut invalid = explicitly_profiled_external_wordpress_assessment_document(
26233            WordPressComparisonProfile::NumericDottedV1,
26234        );
26235        invalid
26236            .wordpress_review
26237            .as_mut()
26238            .unwrap()
26239            .external_review
26240            .as_mut()
26241            .unwrap()
26242            .source_semantics_assurance = Some("established");
26243        invalid_documents.push(invalid);
26244
26245        let mut invalid = explicitly_profiled_external_wordpress_assessment_document(
26246            WordPressComparisonProfile::NumericDottedV1,
26247        );
26248        invalid
26249            .wordpress_review
26250            .as_mut()
26251            .unwrap()
26252            .external_review
26253            .as_mut()
26254            .unwrap()
26255            .evaluations[0]
26256            .affected_ranges[0]
26257            .from_version = "9.0".to_owned();
26258        invalid_documents.push(invalid);
26259
26260        let mut invalid = explicitly_profiled_external_wordpress_assessment_document(
26261            WordPressComparisonProfile::NumericDottedV1,
26262        );
26263        let range = &mut invalid
26264            .wordpress_review
26265            .as_mut()
26266            .unwrap()
26267            .external_review
26268            .as_mut()
26269            .unwrap()
26270            .evaluations[0]
26271            .range_evaluations
26272            .as_mut()
26273            .unwrap()[1];
26274        range.relation = "not_evaluated";
26275        range.reason = "missing_version_evidence";
26276        invalid_documents.push(invalid);
26277
26278        let mut invalid = explicitly_profiled_external_wordpress_assessment_document(
26279            WordPressComparisonProfile::NumericDottedV1,
26280        );
26281        invalid
26282            .wordpress_review
26283            .as_mut()
26284            .unwrap()
26285            .external_review
26286            .as_mut()
26287            .unwrap()
26288            .counts
26289            .within_associations = Some(0);
26290        invalid_documents.push(invalid);
26291
26292        let mut invalid = explicitly_profiled_external_wordpress_assessment_document(
26293            WordPressComparisonProfile::NumericDottedV1,
26294        );
26295        invalid
26296            .wordpress_review
26297            .as_mut()
26298            .unwrap()
26299            .external_review
26300            .as_mut()
26301            .unwrap()
26302            .evaluations[0]
26303            .range_evaluations
26304            .as_mut()
26305            .unwrap()[0]
26306            .reason = "selected_version_outside_bounds";
26307        invalid_documents.push(invalid);
26308
26309        let mut invalid = explicitly_profiled_external_wordpress_assessment_document(
26310            WordPressComparisonProfile::NumericDottedV1,
26311        );
26312        invalid
26313            .wordpress_review
26314            .as_mut()
26315            .unwrap()
26316            .external_review
26317            .as_mut()
26318            .unwrap()
26319            .evaluations[0]
26320            .applicability = "no_version_match_under_selected_policy";
26321        invalid_documents.push(invalid);
26322
26323        for invalid in invalid_documents {
26324            for format in ReportGenerator::available_formats() {
26325                assert_eq!(
26326                    render_assessment_with_limit(&invalid, *format, usize::MAX),
26327                    Err(ReportError::Serialization)
26328                );
26329            }
26330        }
26331    }
26332
26333    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
26334    #[test]
26335    fn source_identity_reporting_is_v6_strict_and_keeps_uncertainty_visible() {
26336        for profile in [None, Some(WordPressComparisonProfile::NumericDottedV1)] {
26337            let document = source_identity_external_wordpress_assessment_document(profile, false);
26338            let json =
26339                render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
26340            let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
26341            let audit = &parsed["wordpress_review"];
26342            let external = &audit["external_review"];
26343            assert_eq!(audit["schema"], "security.wordpress-review-audit/v6");
26344            assert_eq!(
26345                external["mapping_revision"],
26346                WORDFENCE_V3_MAPPING_REVISION_V2
26347            );
26348            assert_eq!(
26349                external["identity_mapping_policy"],
26350                WORDFENCE_V3_IDENTITY_MAPPING_POLICY
26351            );
26352            assert_eq!(external["identity_source_assurance"], "not_established");
26353            assert_eq!(external["resource_policy"], WORDFENCE_V3_RESOURCE_POLICY_V1);
26354            assert!(external["input"]["accounted_retained_bytes"]
26355                .as_u64()
26356                .is_some_and(|bytes| bytes > 0));
26357            assert_eq!(external["counts"]["software_associations"], 3);
26358            assert_eq!(external["counts"]["exact_identity_associations"], 2);
26359            assert_eq!(external["counts"]["candidate_identity_associations"], 1);
26360            assert_eq!(external["counts"]["ambiguous_identity_associations"], 0);
26361            assert_eq!(external["counts"]["unresolved_identity_associations"], 0);
26362            let evaluation = &external["evaluations"][0];
26363            assert_eq!(
26364                evaluation["key"]["source_component"],
26365                serde_json::json!({
26366                    "kind": "plugin",
26367                    "slug": "Termivar-Fixture-Component"
26368                })
26369            );
26370            assert_eq!(evaluation["identity_mapping"], "ascii_case_fold_candidate");
26371            assert!(evaluation.get("identity_collision_raw_count").is_none());
26372            if profile.is_some() {
26373                assert_eq!(evaluation["version_relation"], "indeterminate");
26374                assert_eq!(
26375                    evaluation["version_relation_reason"],
26376                    "identity_mapping_candidate"
26377                );
26378                assert_eq!(evaluation["applicability"], "indeterminate");
26379                assert!(evaluation["range_evaluations"]
26380                    .as_array()
26381                    .unwrap()
26382                    .iter()
26383                    .all(|range| range["relation"] == "not_evaluated"
26384                        && range["reason"] == "identity_mapping_candidate"));
26385                assert!(evaluation["version_evidence_resolution"]
26386                    .get("semantic_status")
26387                    .is_none());
26388            } else {
26389                assert_eq!(
26390                    evaluation["version_relation"],
26391                    "source_comparison_semantics_unresolved"
26392                );
26393                assert_eq!(evaluation["applicability"], "indeterminate_unsupported");
26394                assert!(evaluation.get("range_evaluations").is_none());
26395            }
26396
26397            for format in [
26398                ReportFormat::Html,
26399                ReportFormat::Markdown,
26400                ReportFormat::Csv,
26401            ] {
26402                let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
26403                for expected in [
26404                    "security.wordpress-review-audit/v6",
26405                    "Termivar-Fixture-Component",
26406                    "ascii_case_fold_candidate",
26407                    "not_established",
26408                ] {
26409                    assert!(rendered.contains(expected), "{format:?} omitted {expected}");
26410                }
26411                assert!(!rendered.contains("Irrelevant associations excluded"));
26412                assert!(!rendered.contains("confirmed_vulnerability"));
26413                assert!(!rendered.contains("verified_remediation"));
26414            }
26415        }
26416
26417        let collision = source_identity_external_wordpress_assessment_document(None, true);
26418        let json =
26419            render_assessment_with_limit(&collision, ReportFormat::Json, usize::MAX).unwrap();
26420        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
26421        let external = &parsed["wordpress_review"]["external_review"];
26422        assert_eq!(external["counts"]["software_associations"], 4);
26423        assert_eq!(external["counts"]["exact_identity_associations"], 3);
26424        assert_eq!(external["counts"]["candidate_identity_associations"], 0);
26425        assert_eq!(external["counts"]["ambiguous_identity_associations"], 1);
26426        let ambiguous = external["evaluations"]
26427            .as_array()
26428            .unwrap()
26429            .iter()
26430            .find(|evaluation| evaluation["identity_mapping"] == "ascii_case_fold_ambiguous")
26431            .unwrap();
26432        assert_eq!(ambiguous["identity_collision_raw_count"], 2);
26433
26434        const FIRST_ID: &str = "00000000-0000-4000-8000-000000000001";
26435        let mut expanded_source: serde_json::Value = serde_json::from_slice(include_bytes!(
26436            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
26437        ))
26438        .unwrap();
26439        expanded_source[FIRST_ID]["description"] = serde_json::Value::String("d".repeat(2_049));
26440        let expanded_bytes = serde_json::to_vec(&expanded_source).unwrap();
26441        let expanded = external_wordpress_assessment_document_from_bytes(&expanded_bytes);
26442        let json = render_assessment_with_limit(&expanded, ReportFormat::Json, usize::MAX).unwrap();
26443        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
26444        let external = &parsed["wordpress_review"]["external_review"];
26445        assert!(comparison::import_assessment_summary(json.as_bytes()).is_ok());
26446        let self_comparison = comparison::compare_reports(
26447            json.as_bytes(),
26448            json.as_bytes(),
26449            comparison::ComparisonFormat::Json,
26450        )
26451        .unwrap();
26452        let self_comparison: serde_json::Value = serde_json::from_str(&self_comparison).unwrap();
26453        assert_eq!(
26454            self_comparison["wordpress_review_comparison"]["advisories"]["paired_unchanged_count"],
26455            1
26456        );
26457        assert_eq!(
26458            parsed["wordpress_review"]["schema"],
26459            "security.wordpress-review-audit/v6"
26460        );
26461        assert_eq!(external["mapping_revision"], WORDFENCE_V3_MAPPING_REVISION);
26462        assert_eq!(external["resource_policy"], WORDFENCE_V3_RESOURCE_POLICY_V2);
26463        assert_eq!(external["counts"]["exact_identity_associations"], 3);
26464        assert_eq!(external["counts"]["candidate_identity_associations"], 0);
26465        assert_eq!(external["evaluations"][0]["identity_mapping"], "exact");
26466        assert_eq!(
26467            external["evaluations"][0]["key"]["source_component"]["slug"],
26468            "termivar-fixture-component"
26469        );
26470
26471        let mut opaque_source: serde_json::Value = serde_json::from_slice(include_bytes!(
26472            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
26473        ))
26474        .unwrap();
26475        opaque_source[FIRST_ID]["researchers"] =
26476            serde_json::json!(["Synthetic Researcher", "Synthetic Researcher"]);
26477        opaque_source[FIRST_ID]["software"][0]["affected_versions"]["[1.0.0, 1.2.3]"]
26478            ["to_version"] = serde_json::Value::String("0.1.2 β".to_owned());
26479        opaque_source[FIRST_ID]["software"][0]["patched_versions"] =
26480            serde_json::json!(["44.0 (17-08-2023)"]);
26481        let opaque_bytes = serde_json::to_vec(&opaque_source).unwrap();
26482        let opaque = external_wordpress_assessment_document_from_bytes(&opaque_bytes);
26483        let opaque_json =
26484            render_assessment_with_limit(&opaque, ReportFormat::Json, usize::MAX).unwrap();
26485        let opaque_parsed: serde_json::Value = serde_json::from_str(&opaque_json).unwrap();
26486        let opaque_evaluation = &opaque_parsed["wordpress_review"]["external_review"]
26487            ["evaluations"]
26488            .as_array()
26489            .unwrap()
26490            .iter()
26491            .find(|evaluation| evaluation["key"]["upstream_id"] == FIRST_ID)
26492            .unwrap();
26493        assert_eq!(
26494            opaque_evaluation["researchers"],
26495            serde_json::json!(["Synthetic Researcher", "Synthetic Researcher"])
26496        );
26497        assert_eq!(
26498            opaque_evaluation["affected_ranges"][0]["to_version"],
26499            "0.1.2 β"
26500        );
26501        assert_eq!(
26502            opaque_evaluation["source_patched_versions"],
26503            serde_json::json!(["44.0 (17-08-2023)"])
26504        );
26505        assert!(comparison::import_assessment_summary(opaque_json.as_bytes()).is_ok());
26506        assert!(comparison::compare_reports(
26507            opaque_json.as_bytes(),
26508            opaque_json.as_bytes(),
26509            comparison::ComparisonFormat::Json,
26510        )
26511        .is_ok());
26512
26513        let mut duplicate_researcher_under_v1 =
26514            source_identity_external_wordpress_assessment_document(None, false);
26515        duplicate_researcher_under_v1
26516            .wordpress_review
26517            .as_mut()
26518            .unwrap()
26519            .external_review
26520            .as_mut()
26521            .unwrap()
26522            .evaluations[0]
26523            .researchers = vec!["Synthetic Researcher".to_owned(); 2];
26524        let mut opaque_version_under_v1 =
26525            source_identity_external_wordpress_assessment_document(None, false);
26526        opaque_version_under_v1
26527            .wordpress_review
26528            .as_mut()
26529            .unwrap()
26530            .external_review
26531            .as_mut()
26532            .unwrap()
26533            .evaluations[0]
26534            .affected_ranges[0]
26535            .to_version = "0.1.2 β".to_owned();
26536        for invalid in [duplicate_researcher_under_v1, opaque_version_under_v1] {
26537            assert_eq!(
26538                render_assessment_with_limit(&invalid, ReportFormat::Json, usize::MAX),
26539                Err(ReportError::Serialization)
26540            );
26541        }
26542
26543        let mut wrong_mapping = source_identity_external_wordpress_assessment_document(None, false);
26544        wrong_mapping
26545            .wordpress_review
26546            .as_mut()
26547            .unwrap()
26548            .external_review
26549            .as_mut()
26550            .unwrap()
26551            .evaluations[0]
26552            .identity_mapping = Some("exact");
26553        let mut false_decision = source_identity_external_wordpress_assessment_document(
26554            Some(WordPressComparisonProfile::NumericDottedV1),
26555            false,
26556        );
26557        let external = false_decision
26558            .wordpress_review
26559            .as_mut()
26560            .unwrap()
26561            .external_review
26562            .as_mut()
26563            .unwrap();
26564        external.counts.evaluable_associations = 1;
26565        external.counts.unsupported_associations = 0;
26566        external.counts.outside_associations = Some(1);
26567        external.counts.indeterminate_associations = Some(0);
26568        let evaluation = &mut external.evaluations[0];
26569        evaluation.version_relation = "outside_declared_ranges_under_selected_policy";
26570        evaluation.version_relation_reason = Some("all_ranges_outside");
26571        evaluation.applicability = "no_version_match_under_selected_policy";
26572        for invalid in [wrong_mapping, false_decision] {
26573            for format in ReportGenerator::available_formats() {
26574                assert_eq!(
26575                    render_assessment_with_limit(&invalid, *format, usize::MAX),
26576                    Err(ReportError::Serialization)
26577                );
26578            }
26579        }
26580    }
26581
26582    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
26583    #[test]
26584    fn unresolved_external_identities_remain_visible_strict_and_comparable() {
26585        let document = unresolved_identity_external_wordpress_assessment_document(Some(
26586            WordPressComparisonProfile::NumericDottedV1,
26587        ));
26588        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
26589        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
26590        let external = &parsed["wordpress_review"]["external_review"];
26591        assert_eq!(external["counts"]["software_associations"], 3);
26592        assert_eq!(external["counts"]["unresolved_identity_associations"], 1);
26593        assert_eq!(external["counts"]["projected_identity_limitations"], 1);
26594        assert_eq!(external["counts"]["unprojected_identity_limitations"], 0);
26595        assert_eq!(external["counts"]["mapped_unselected_associations"], 2);
26596        assert_eq!(external["counts"]["excluded_associations"], 3);
26597        assert_eq!(external["evaluations"].as_array().unwrap().len(), 0);
26598        let limitation = &external["identity_limitations"][0];
26599        assert_eq!(
26600            limitation["key"]["source_component"],
26601            serde_json::json!({"kind":"plugin","slug":"vendor/plugin.php"})
26602        );
26603        assert_eq!(
26604            limitation["identity_resolution"],
26605            "canonical_identity_unavailable"
26606        );
26607        assert_eq!(limitation["version_relation"], "not_evaluated");
26608        assert_eq!(
26609            limitation["version_relation_reason"],
26610            "canonical_identity_unavailable"
26611        );
26612        assert_eq!(limitation["applicability"], "indeterminate");
26613        assert!(limitation["range_evaluations"]
26614            .as_array()
26615            .unwrap()
26616            .iter()
26617            .all(|range| range["relation"] == "not_evaluated"
26618                && range["reason"] == "canonical_identity_unavailable"));
26619        assert!(comparison::import_assessment_summary(json.as_bytes()).is_ok());
26620
26621        for slug in ["valid-plugin", "Valid-Plugin"] {
26622            let mut invalid = unresolved_identity_external_wordpress_assessment_document(Some(
26623                WordPressComparisonProfile::NumericDottedV1,
26624            ));
26625            invalid
26626                .wordpress_review
26627                .as_mut()
26628                .unwrap()
26629                .external_review
26630                .as_mut()
26631                .unwrap()
26632                .identity_limitations
26633                .as_mut()
26634                .unwrap()[0]
26635                .key
26636                .source_component
26637                .slug = slug.to_owned();
26638            for format in ReportGenerator::available_formats() {
26639                assert_eq!(
26640                    render_assessment_with_limit(&invalid, *format, usize::MAX),
26641                    Err(ReportError::Serialization),
26642                    "a mappable source identity must not be projected as unavailable"
26643                );
26644            }
26645        }
26646
26647        let mut missing_projection = parsed.clone();
26648        missing_projection["wordpress_review"]["external_review"]["identity_limitations"] =
26649            serde_json::json!([]);
26650        let mut false_projection_partition = parsed.clone();
26651        false_projection_partition["wordpress_review"]["external_review"]["counts"]
26652            ["unprojected_identity_limitations"] = serde_json::json!(1);
26653        let mut invalid_raw_identity = parsed.clone();
26654        invalid_raw_identity["wordpress_review"]["external_review"]["identity_limitations"][0]
26655            ["key"]["source_component"]["slug"] = serde_json::json!("vendor\nplugin.php");
26656        let mut false_relation = parsed.clone();
26657        false_relation["wordpress_review"]["external_review"]["identity_limitations"][0]
26658            ["version_relation"] =
26659            serde_json::json!("outside_declared_ranges_under_selected_policy");
26660        let mut unbound_notice_import = parsed.clone();
26661        unbound_notice_import["wordpress_review"]["external_review"]["identity_limitations"][0]
26662            ["notice_ids"] = serde_json::json!([]);
26663        let mut false_exact_limitation = parsed.clone();
26664        false_exact_limitation["wordpress_review"]["external_review"]["identity_limitations"][0]
26665            ["key"]["source_component"]["slug"] = serde_json::json!("valid-plugin");
26666        let mut false_candidate_limitation = parsed.clone();
26667        false_candidate_limitation["wordpress_review"]["external_review"]["identity_limitations"]
26668            [0]["key"]["source_component"]["slug"] = serde_json::json!("Valid-Plugin");
26669        for invalid in [
26670            missing_projection,
26671            false_projection_partition,
26672            invalid_raw_identity,
26673            false_relation,
26674            unbound_notice_import,
26675            false_exact_limitation,
26676            false_candidate_limitation,
26677        ] {
26678            let encoded = serde_json::to_vec(&invalid).unwrap();
26679            assert_eq!(
26680                comparison::import_assessment_summary(&encoded),
26681                Err(comparison::ComparisonError::InvalidDocument)
26682            );
26683        }
26684
26685        let self_comparison = comparison::compare_reports(
26686            json.as_bytes(),
26687            json.as_bytes(),
26688            comparison::ComparisonFormat::Json,
26689        )
26690        .unwrap();
26691        let self_comparison: serde_json::Value = serde_json::from_str(&self_comparison).unwrap();
26692        let changes = &self_comparison["wordpress_review_comparison"]["advisories"];
26693        assert_eq!(changes["paired_unchanged_count"], 1);
26694        assert_eq!(changes["paired_changed"].as_array().unwrap().len(), 0);
26695
26696        for format in [ReportFormat::Html, ReportFormat::Markdown] {
26697            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
26698            for expected in [
26699                "vendor/plugin.php",
26700                "canonical_identity_unavailable",
26701                "not_evaluated",
26702                "Synthetic fixture guidance",
26703            ] {
26704                assert!(rendered.contains(expected), "{format:?} omitted {expected}");
26705            }
26706        }
26707
26708        let renamed = unresolved_identity_external_wordpress_assessment_document_with_slug(
26709            Some(WordPressComparisonProfile::NumericDottedV1),
26710            "vendor/renamed.php",
26711        );
26712        let renamed_json =
26713            render_assessment_with_limit(&renamed, ReportFormat::Json, usize::MAX).unwrap();
26714        let comparison = comparison::compare_reports(
26715            json.as_bytes(),
26716            renamed_json.as_bytes(),
26717            comparison::ComparisonFormat::Json,
26718        )
26719        .unwrap();
26720        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
26721        let changes = &comparison["wordpress_review_comparison"]["advisories"];
26722        assert_eq!(changes["paired_unchanged_count"], 0);
26723        assert_eq!(changes["only_in_before"].as_array().unwrap().len(), 1);
26724        assert_eq!(changes["only_in_after"].as_array().unwrap().len(), 1);
26725
26726        let mut wrong_count = unresolved_identity_external_wordpress_assessment_document(None);
26727        wrong_count
26728            .wordpress_review
26729            .as_mut()
26730            .unwrap()
26731            .external_review
26732            .as_mut()
26733            .unwrap()
26734            .counts
26735            .unresolved_identity_associations = Some(0);
26736        let mut false_result = unresolved_identity_external_wordpress_assessment_document(None);
26737        false_result
26738            .wordpress_review
26739            .as_mut()
26740            .unwrap()
26741            .external_review
26742            .as_mut()
26743            .unwrap()
26744            .identity_limitations
26745            .as_mut()
26746            .unwrap()[0]
26747            .version_relation = "outside_declared_ranges_under_selected_policy";
26748        let mut unbound_notice = unresolved_identity_external_wordpress_assessment_document(None);
26749        unbound_notice
26750            .wordpress_review
26751            .as_mut()
26752            .unwrap()
26753            .external_review
26754            .as_mut()
26755            .unwrap()
26756            .identity_limitations
26757            .as_mut()
26758            .unwrap()[0]
26759            .notice_ids
26760            .clear();
26761        for invalid in [wrong_count, false_result, unbound_notice] {
26762            assert_eq!(
26763                render_assessment_with_limit(&invalid, ReportFormat::Json, usize::MAX),
26764                Err(ReportError::Serialization)
26765            );
26766        }
26767    }
26768
26769    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
26770    #[test]
26771    fn legacy_external_exact_identity_normalizes_for_v6_semantic_comparison() {
26772        let before = external_wordpress_assessment_document();
26773        let mut after = external_wordpress_assessment_document();
26774        let audit = after.wordpress_review.as_mut().unwrap();
26775        audit.schema = "security.wordpress-review-audit/v6";
26776        let external = audit.external_review.as_mut().unwrap();
26777        external.identity_mapping_policy = Some(WORDFENCE_V3_IDENTITY_MAPPING_POLICY);
26778        external.identity_source_assurance = Some("not_established");
26779        external.resource_policy = Some(WORDFENCE_V3_RESOURCE_POLICY_V2);
26780        external.input.accounted_retained_bytes = Some(1);
26781        external.counts.exact_identity_associations = Some(3);
26782        external.counts.candidate_identity_associations = Some(0);
26783        external.counts.ambiguous_identity_associations = Some(0);
26784        external.counts.unresolved_identity_associations = Some(0);
26785        external.counts.projected_identity_limitations = Some(0);
26786        external.counts.unprojected_identity_limitations = Some(0);
26787        external.counts.mapped_unselected_associations =
26788            Some(external.counts.excluded_associations);
26789        external.identity_limitations = Some(Vec::new());
26790        let source_association_fingerprint = external_fixture_source_association_fingerprint();
26791        for evaluation in &mut external.evaluations {
26792            evaluation.identity_mapping = Some("exact");
26793            evaluation.key.source_component =
26794                Some(WordPressExternalSourceComponentIdentityDocument {
26795                    kind: evaluation.key.component.kind,
26796                    slug: evaluation.key.component.slug.clone(),
26797                });
26798            evaluation.key.source_association_fingerprint =
26799                Some(source_association_fingerprint.clone());
26800        }
26801        let before_json =
26802            render_assessment_with_limit(&before, ReportFormat::Json, usize::MAX).unwrap();
26803        let after_json =
26804            render_assessment_with_limit(&after, ReportFormat::Json, usize::MAX).unwrap();
26805        let comparison = comparison::compare_reports(
26806            before_json.as_bytes(),
26807            after_json.as_bytes(),
26808            comparison::ComparisonFormat::Json,
26809        )
26810        .unwrap();
26811        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
26812        let wordpress = &comparison["wordpress_review_comparison"];
26813        assert_eq!(wordpress["methodology"]["status"], "changed");
26814        assert_eq!(wordpress["advisories"]["paired_unchanged_count"], 1);
26815        assert_eq!(
26816            wordpress["advisories"]["paired_changed"]
26817                .as_array()
26818                .unwrap()
26819                .len(),
26820            0
26821        );
26822    }
26823
26824    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
26825    #[test]
26826    fn external_source_variants_round_trip_and_compare_under_the_stable_source_key() {
26827        fn variant_document(
26828            display_name: &str,
26829            reverse_source_order: bool,
26830        ) -> AssessmentDocument<'static> {
26831            let mut source: serde_json::Value = serde_json::from_slice(include_bytes!(
26832                "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
26833            ))
26834            .unwrap();
26835            let software = source["00000000-0000-4000-8000-000000000001"]["software"]
26836                .as_array_mut()
26837                .unwrap();
26838            let mut variant = software[0].clone();
26839            variant["name"] = serde_json::Value::String(display_name.to_owned());
26840            variant["remediation"] =
26841                serde_json::Value::String("Synthetic alternate declaration guidance.".to_owned());
26842            software.push(variant);
26843            if reverse_source_order {
26844                software.reverse();
26845            }
26846            let bytes = serde_json::to_vec(&source).unwrap();
26847            external_wordpress_assessment_document_from_bytes(&bytes)
26848        }
26849
26850        let before = variant_document("[SYNTHETIC] Alternate declaration A", false);
26851        let reordered = variant_document("[SYNTHETIC] Alternate declaration A", true);
26852        let after = variant_document("[SYNTHETIC] Alternate declaration B", false);
26853        let before_json =
26854            render_assessment_with_limit(&before, ReportFormat::Json, usize::MAX).unwrap();
26855        let reordered_json =
26856            render_assessment_with_limit(&reordered, ReportFormat::Json, usize::MAX).unwrap();
26857        let after_json =
26858            render_assessment_with_limit(&after, ReportFormat::Json, usize::MAX).unwrap();
26859
26860        let parsed: serde_json::Value = serde_json::from_str(&before_json).unwrap();
26861        let external = &parsed["wordpress_review"]["external_review"];
26862        assert_eq!(
26863            parsed["wordpress_review"]["schema"],
26864            "security.wordpress-review-audit/v6"
26865        );
26866        assert_eq!(
26867            external["resource_policy"],
26868            "termivar.wordfence-v3-bounded-capacity/v2"
26869        );
26870        let evaluations = external["evaluations"].as_array().unwrap();
26871        assert_eq!(evaluations.len(), 2);
26872        let fingerprints = evaluations
26873            .iter()
26874            .map(|evaluation| {
26875                evaluation["key"]["source_association_fingerprint"]
26876                    .as_str()
26877                    .unwrap()
26878            })
26879            .collect::<std::collections::BTreeSet<_>>();
26880        assert_eq!(fingerprints.len(), 2);
26881
26882        let mut reordered_wire = parsed.clone();
26883        let reordered_evaluation =
26884            &mut reordered_wire["wordpress_review"]["external_review"]["evaluations"][0];
26885        reordered_evaluation["affected_ranges"]
26886            .as_array_mut()
26887            .unwrap()
26888            .reverse();
26889        reordered_evaluation["source_patched_versions"]
26890            .as_array_mut()
26891            .unwrap()
26892            .reverse();
26893        let reordered_wire = serde_json::to_vec(&reordered_wire).unwrap();
26894        comparison::import_assessment_summary(&reordered_wire).unwrap();
26895
26896        let mut tampered_fingerprint = parsed.clone();
26897        let fingerprint = tampered_fingerprint["wordpress_review"]["external_review"]
26898            ["evaluations"][0]["key"]["source_association_fingerprint"]
26899            .as_str()
26900            .unwrap()
26901            .to_owned();
26902        let replacement = if fingerprint.ends_with('0') { '1' } else { '0' };
26903        tampered_fingerprint["wordpress_review"]["external_review"]["evaluations"][0]["key"]
26904            ["source_association_fingerprint"] = serde_json::Value::String(format!(
26905            "{}{replacement}",
26906            &fingerprint[..fingerprint.len() - 1]
26907        ));
26908        let tampered_fingerprint = serde_json::to_vec(&tampered_fingerprint).unwrap();
26909        assert_eq!(
26910            comparison::import_assessment_summary(&tampered_fingerprint),
26911            Err(comparison::ComparisonError::InvalidDocument)
26912        );
26913
26914        let mut duplicate_range_label = parsed.clone();
26915        let ranges = duplicate_range_label["wordpress_review"]["external_review"]["evaluations"][0]
26916            ["affected_ranges"]
26917            .as_array_mut()
26918            .unwrap();
26919        ranges.push(ranges[0].clone());
26920        let duplicate_range_label = serde_json::to_vec(&duplicate_range_label).unwrap();
26921        assert_eq!(
26922            comparison::import_assessment_summary(&duplicate_range_label),
26923            Err(comparison::ComparisonError::InvalidDocument)
26924        );
26925
26926        for candidate in [&before_json, &reordered_json, &after_json] {
26927            comparison::import_assessment_summary(candidate.as_bytes()).unwrap();
26928        }
26929        for same in [&before_json, &reordered_json] {
26930            let comparison = comparison::compare_reports(
26931                before_json.as_bytes(),
26932                same.as_bytes(),
26933                comparison::ComparisonFormat::Json,
26934            )
26935            .unwrap();
26936            let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
26937            let advisories = &comparison["wordpress_review_comparison"]["advisories"];
26938            assert_eq!(advisories["paired_unchanged_count"], 1);
26939            assert_eq!(advisories["paired_changed"].as_array().unwrap().len(), 0);
26940            assert_eq!(advisories["only_in_before"].as_array().unwrap().len(), 0);
26941            assert_eq!(advisories["only_in_after"].as_array().unwrap().len(), 0);
26942        }
26943
26944        let comparison = comparison::compare_reports(
26945            before_json.as_bytes(),
26946            after_json.as_bytes(),
26947            comparison::ComparisonFormat::Json,
26948        )
26949        .unwrap();
26950        let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
26951        let advisories = &comparison["wordpress_review_comparison"]["advisories"];
26952        assert_eq!(advisories["paired_unchanged_count"], 0);
26953        assert_eq!(advisories["paired_changed"].as_array().unwrap().len(), 1);
26954        assert_eq!(advisories["only_in_before"].as_array().unwrap().len(), 0);
26955        assert_eq!(advisories["only_in_after"].as_array().unwrap().len(), 0);
26956        assert_eq!(
26957            advisories["paired_changed"][0]["changed_dimensions"],
26958            serde_json::json!(["source_association_variants"])
26959        );
26960
26961        let discovery_json = render_assessment_with_limit(
26962            &discovered_wordpress_assessment_document(),
26963            ReportFormat::Json,
26964            usize::MAX,
26965        )
26966        .unwrap();
26967        let discovery_document: serde_json::Value = serde_json::from_str(&discovery_json).unwrap();
26968        let discovery = discovery_document["wordpress_discovery"].clone();
26969        let discovery_item = discovery_document["items"]
26970            .as_array()
26971            .unwrap()
26972            .iter()
26973            .find(|item| item["capability_id"] == WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID)
26974            .unwrap()
26975            .clone();
26976        let depth_zero_identities = discovery["sources"]
26977            .as_array()
26978            .unwrap()
26979            .iter()
26980            .filter(|source| source["parent_depth"] == 0 && !source["component"].is_null())
26981            .map(|source| source["component"].clone())
26982            .collect::<Vec<_>>();
26983        assert_eq!(depth_zero_identities.len(), 2);
26984        let discovery_components = depth_zero_identities
26985            .iter()
26986            .map(|identity| {
26987                let matches = discovery_document["wordpress_review"]["components"]
26988                    .as_array()
26989                    .unwrap()
26990                    .iter()
26991                    .filter(|component| component["identity"] == *identity)
26992                    .collect::<Vec<_>>();
26993                assert_eq!(matches.len(), 1);
26994                matches[0].clone()
26995            })
26996            .collect::<Vec<_>>();
26997        let as_discovery_v7 = |source: &str| {
26998            let mut value: serde_json::Value = serde_json::from_str(source).unwrap();
26999            let review = value["wordpress_review"].as_object_mut().unwrap();
27000            let basis = review["schema"].clone();
27001            review.insert(
27002                "schema".to_owned(),
27003                serde_json::json!("security.wordpress-review-audit/v7"),
27004            );
27005            review.insert("review_basis_schema".to_owned(), basis);
27006            review.insert("additional_request_count".to_owned(), serde_json::json!(3));
27007            let components = review["components"].as_array_mut().unwrap();
27008            for discovery_component in &discovery_components {
27009                assert!(components
27010                    .iter()
27011                    .all(|component| { component["identity"] != discovery_component["identity"] }));
27012                components.push(discovery_component.clone());
27013            }
27014            let component_count = components.len();
27015            review.insert(
27016                "component_count".to_owned(),
27017                serde_json::json!(component_count),
27018            );
27019            value["wordpress_discovery"] = discovery.clone();
27020            let items = value["items"].as_array_mut().unwrap();
27021            items.push(discovery_item.clone());
27022            value["item_count"] = serde_json::json!(items.len());
27023            serde_json::to_vec(&value).unwrap()
27024        };
27025        let v7_before = as_discovery_v7(&before_json);
27026        let v7_reordered = as_discovery_v7(&reordered_json);
27027        let v7_after = as_discovery_v7(&after_json);
27028        for same in [&v7_before, &v7_reordered] {
27029            comparison::import_assessment_summary(same).unwrap();
27030            let comparison =
27031                comparison::compare_reports(&v7_before, same, comparison::ComparisonFormat::Json)
27032                    .unwrap();
27033            let comparison: serde_json::Value = serde_json::from_str(&comparison).unwrap();
27034            let advisories = &comparison["wordpress_review_comparison"]["advisories"];
27035            assert_eq!(advisories["paired_unchanged_count"], 1);
27036            assert_eq!(advisories["paired_changed"], serde_json::json!([]));
27037        }
27038        let v7_comparison =
27039            comparison::compare_reports(&v7_before, &v7_after, comparison::ComparisonFormat::Json)
27040                .unwrap();
27041        let v7_comparison: serde_json::Value = serde_json::from_str(&v7_comparison).unwrap();
27042        let v7_advisories = &v7_comparison["wordpress_review_comparison"]["advisories"];
27043        assert_eq!(v7_advisories["paired_unchanged_count"], 0);
27044        assert_eq!(v7_advisories["paired_changed"].as_array().unwrap().len(), 1);
27045        assert_eq!(
27046            v7_advisories["paired_changed"][0]["changed_dimensions"],
27047            serde_json::json!(["source_association_variants"])
27048        );
27049
27050        let mut impossible_v1: serde_json::Value = serde_json::from_str(&before_json).unwrap();
27051        impossible_v1["wordpress_review"]["external_review"]["resource_policy"] =
27052            serde_json::Value::String(WORDFENCE_V3_RESOURCE_POLICY_V1.to_owned());
27053        let impossible_v1 = serde_json::to_vec(&impossible_v1).unwrap();
27054        assert_eq!(
27055            comparison::import_assessment_summary(&impossible_v1),
27056            Err(comparison::ComparisonError::InvalidDocument)
27057        );
27058    }
27059
27060    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27061    #[test]
27062    fn wordpress_human_reports_group_typed_results_without_claim_promotion_or_wire_dump() {
27063        let cases = [
27064            (
27065                observed_wordpress_assessment_document(),
27066                0_usize,
27067                1_usize,
27068                "SYNTHETIC-REPORTING-WORDPRESS-0001",
27069            ),
27070            (
27071                profiled_wordpress_assessment_document(),
27072                1,
27073                0,
27074                "php-release-subset/v1",
27075            ),
27076            (
27077                external_wordpress_assessment_document(),
27078                0,
27079                1,
27080                "wordfence-v3/source-semantics-unresolved/v1",
27081            ),
27082        ];
27083
27084        for (document, candidates, limitations, distinguishing_value) in cases {
27085            let html =
27086                render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
27087            let markdown =
27088                render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX)
27089                    .unwrap();
27090
27091            for rendered in [&html, &markdown] {
27092                for heading in [
27093                    "Source and coverage",
27094                    "Component evidence",
27095                    "Review candidates",
27096                    "Contradicted on supplied facts",
27097                    "Evaluation limitations",
27098                    "Source-declared remediation information",
27099                ] {
27100                    assert!(rendered.contains(heading), "missing {heading}");
27101                }
27102                assert!(rendered.contains(distinguishing_value));
27103                assert!(rendered.contains("not_performed"));
27104                assert!(!rendered.contains("confirmed_vulnerability"));
27105                assert!(!rendered.contains("verified_remediation"));
27106                assert!(!rendered.contains("\"external_review\":"));
27107                assert!(!rendered.contains("\"advisories\":"));
27108            }
27109
27110            assert!(html.contains(&format!(
27111                "<strong>{candidates}</strong><span>Review candidates</span>"
27112            )));
27113            assert!(html.contains(&format!(
27114                "<strong>{limitations}</strong><span>Evaluation limitations</span>"
27115            )));
27116            assert!(markdown.contains(&format!("- Review candidates: `{candidates}`")));
27117            assert!(markdown.contains(&format!("- Evaluation limitations: `{limitations}`")));
27118        }
27119    }
27120
27121    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27122    #[test]
27123    fn wordpress_human_grouping_is_mutually_exclusive_ordered_and_deterministic() {
27124        const ADVISORY_ID: &str = "SYNTHETIC-REPORTING-WORDPRESS-0001";
27125        let candidate = profiled_wordpress_assessment_document();
27126        let mut contradicted = profiled_wordpress_assessment_document();
27127        let contradicted_advisory =
27128            &mut contradicted.wordpress_review.as_mut().unwrap().advisories[0];
27129        contradicted_advisory.applicability = "contradicted_by_declared_facts";
27130        contradicted_advisory.version_relation = "outside_declared_ranges";
27131        let limitation = observed_wordpress_assessment_document();
27132
27133        for (document, expected_counts) in [
27134            (candidate, [1_usize, 0_usize, 0_usize]),
27135            (contradicted, [0, 1, 0]),
27136            (limitation, [0, 0, 1]),
27137        ] {
27138            for format in [ReportFormat::Html, ReportFormat::Markdown] {
27139                let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
27140                let repeated = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
27141                assert_eq!(rendered, repeated);
27142
27143                let heading = |title| match format {
27144                    ReportFormat::Html => format!("<h3>{title}</h3>"),
27145                    ReportFormat::Markdown => format!("### {title}\n"),
27146                    _ => unreachable!(),
27147                };
27148                let candidate_heading = rendered.find(&heading("Review candidates")).unwrap();
27149                let contradicted_heading = rendered
27150                    .find(&heading("Contradicted on supplied facts"))
27151                    .unwrap();
27152                let limitation_heading = rendered.find(&heading("Evaluation limitations")).unwrap();
27153                let remediation_heading = rendered
27154                    .find(&heading("Source-declared remediation information"))
27155                    .unwrap();
27156                assert!(candidate_heading < contradicted_heading);
27157                assert!(contradicted_heading < limitation_heading);
27158                assert!(limitation_heading < remediation_heading);
27159                assert_eq!(rendered.matches(ADVISORY_ID).count(), 1);
27160                assert_eq!(rendered.matches("not_performed").count(), 2);
27161
27162                let advisory = rendered.find(ADVISORY_ID).unwrap();
27163                let (expected_start, expected_end) = if expected_counts[0] == 1 {
27164                    (candidate_heading, contradicted_heading)
27165                } else if expected_counts[1] == 1 {
27166                    (contradicted_heading, limitation_heading)
27167                } else {
27168                    (limitation_heading, remediation_heading)
27169                };
27170                assert!(expected_start < advisory && advisory < expected_end);
27171
27172                for (label, count) in [
27173                    ("Review candidates", expected_counts[0]),
27174                    ("Contradicted on supplied facts", expected_counts[1]),
27175                    ("Evaluation limitations", expected_counts[2]),
27176                ] {
27177                    let expected = match format {
27178                        ReportFormat::Html => {
27179                            format!("<strong>{count}</strong><span>{label}</span>")
27180                        },
27181                        ReportFormat::Markdown => format!("- {label}: `{count}`"),
27182                        _ => unreachable!(),
27183                    };
27184                    assert!(
27185                        rendered.contains(&expected),
27186                        "{format:?} omitted {expected}"
27187                    );
27188                }
27189            }
27190        }
27191    }
27192
27193    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27194    #[test]
27195    fn saved_inventory_human_report_distinguishes_supplied_and_missing_categories() {
27196        let mut document = saved_inventory_wordpress_assessment_document();
27197        document
27198            .wordpress_review
27199            .as_mut()
27200            .unwrap()
27201            .inventory_import
27202            .as_mut()
27203            .unwrap()
27204            .limitations
27205            .push(WordPressInventoryLimitationDocument {
27206                declared_name: "synthetic-dropin.php".to_owned(),
27207                version: Some("1.0".to_owned()),
27208                status: "drop_in",
27209                reason: "drop_in_identity_is_not_catalog_slug",
27210            });
27211        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
27212        let markdown =
27213            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
27214
27215        for rendered in [&html, &markdown] {
27216            for expected in [
27217                "Core inventory",
27218                "Plugin inventory",
27219                "Theme inventory",
27220                "not_supplied",
27221                "supplied",
27222                "Saved inventory provenance and limitations",
27223                "wp_cli_plugins_json",
27224                "operator_supplied",
27225                "operator_context",
27226                "operator_assertion",
27227                "1.2.3-vendor",
27228                "Unsupported inventory row",
27229                "synthetic-dropin.php",
27230                "drop_in_identity_is_not_catalog_slug",
27231            ] {
27232                assert!(rendered.contains(expected), "missing {expected}");
27233            }
27234            assert!(!rendered.contains("PRIVATE"));
27235        }
27236    }
27237
27238    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27239    #[test]
27240    fn external_wordpress_human_report_reconciles_source_counts_and_declared_guidance() {
27241        let document = external_wordpress_assessment_document();
27242        for format in [ReportFormat::Html, ReportFormat::Markdown] {
27243            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
27244            for expected in [
27245                "Parsed source records",
27246                "Software associations",
27247                "Relevant associations",
27248                "Evaluable associations",
27249                "Unsupported relevant associations",
27250                "Irrelevant associations excluded",
27251                "source_comparison_semantics_unresolved",
27252                "missing",
27253                "Source patched flag",
27254                "Source-declared patched versions",
27255                "Source-declared remediation",
27256                "Source-declared CVSS metadata",
27257                "Source informational classification",
27258                "Source bibliography",
27259                "Source researchers",
27260                "Source notice identifiers",
27261                "Synthetic Fixture Author",
27262                "not_performed",
27263            ] {
27264                assert!(rendered.contains(expected), "{format:?} missing {expected}");
27265            }
27266            assert!(rendered.contains("Evaluations with source guidance"));
27267            assert!(!rendered.contains("Records with source guidance"));
27268            assert!(rendered.contains("not an automatic update"));
27269            assert!(rendered.contains("not confirmed vulnerabilities"));
27270            assert!(!rendered.contains("eleven verified vulnerabilities"));
27271            assert_eq!(
27272                rendered
27273                    .matches("Synthetic fixture guidance: review a source-declared patched version")
27274                    .count(),
27275                1
27276            );
27277        }
27278    }
27279
27280    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27281    #[test]
27282    fn wordpress_human_presentation_respects_the_report_output_limit() {
27283        let document = external_wordpress_assessment_document();
27284        for format in [ReportFormat::Html, ReportFormat::Markdown] {
27285            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
27286            assert_eq!(
27287                render_assessment_with_limit(&document, format, rendered.len()),
27288                Ok(rendered.clone())
27289            );
27290            assert_eq!(
27291                render_assessment_with_limit(&document, format, rendered.len() - 1),
27292                Err(ReportError::OutputLimitExceeded {
27293                    limit: rendered.len() - 1
27294                })
27295            );
27296        }
27297    }
27298
27299    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27300    #[test]
27301    fn external_wordpress_reporting_preserves_parser_accepted_safe_text() {
27302        const ID: &str = "00000000-0000-4000-8000-000000000001";
27303        let mut source: serde_json::Value = serde_json::from_slice(include_bytes!(
27304            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
27305        ))
27306        .unwrap();
27307        let record = source[ID].as_object_mut().unwrap();
27308        record.insert(
27309            "title".to_owned(),
27310            serde_json::json!("Synthetic\nreport title"),
27311        );
27312        record.insert(
27313            "description".to_owned(),
27314            serde_json::json!("Synthetic\tdescription"),
27315        );
27316        record["cwe"]["name"] = serde_json::json!("Synthetic\tCWE");
27317        record["cwe"]["description"] = serde_json::json!("Synthetic\rCWE detail");
27318        record["cvss"]["vector"] = serde_json::json!("CVSS:3.1/AV:N\n");
27319        record["researchers"][0] = serde_json::json!("Synthetic\tResearcher");
27320        let software = record["software"].as_array_mut().unwrap();
27321        software[0]["name"] = serde_json::json!("Synthetic\tPlugin");
27322        software[0]["remediation"] = serde_json::json!("Synthetic\rremediation");
27323        let ranges = software[0]["affected_versions"].as_object_mut().unwrap();
27324        let range = ranges.remove("[1.0.0, 1.2.3]").unwrap();
27325        ranges.insert("[1.0.0, 1.2.3]\n".to_owned(), range);
27326
27327        let bytes = serde_json::to_vec(&source).unwrap();
27328        let document = external_wordpress_assessment_document_from_bytes(&bytes);
27329        let rendered =
27330            render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
27331        let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
27332        let evaluation = &parsed["wordpress_review"]["external_review"]["evaluations"][0];
27333        assert_eq!(evaluation["title"], "Synthetic\nreport title");
27334        assert_eq!(evaluation["display_name"], "Synthetic\tPlugin");
27335        assert_eq!(
27336            evaluation["affected_ranges"][0]["label"],
27337            "[1.0.0, 1.2.3]\n"
27338        );
27339        assert!(comparison::import_assessment_summary(rendered.as_bytes()).is_ok());
27340
27341        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
27342        assert!(html.contains("Synthetic\\u{000A}report title"));
27343        assert!(html.contains("Synthetic\\u{0009}description"));
27344        assert!(!html.contains("Synthetic\nreport title"));
27345        assert!(!html.contains("<script"));
27346        let markdown =
27347            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
27348        assert!(markdown.contains("Synthetic\\u{000A}report title"));
27349        assert!(markdown.contains("Synthetic\\u{0009}description"));
27350        assert!(!markdown.contains("Synthetic\nreport title"));
27351
27352        let mut invalid = parsed;
27353        invalid["wordpress_review"]["external_review"]["evaluations"][0]["title"] =
27354            serde_json::json!("control\u{1}character");
27355        let invalid = serde_json::to_vec(&invalid).unwrap();
27356        assert!(comparison::import_assessment_summary(&invalid).is_err());
27357    }
27358
27359    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27360    #[test]
27361    fn defiant_notice_renders_the_exact_source_record_link() {
27362        const ID: &str = "00000000-0000-4000-8000-000000000001";
27363        const RECORD: &str = "https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source=api-test";
27364        let mut source: serde_json::Value = serde_json::from_slice(include_bytes!(
27365            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
27366        ))
27367        .unwrap();
27368        let record = source[ID].as_object_mut().unwrap();
27369        record.insert("references".to_owned(), serde_json::json!([RECORD]));
27370        let copyrights = record["copyrights"].as_object_mut().unwrap();
27371        let notice = copyrights.remove("termivar_fixture_author").unwrap();
27372        copyrights.insert("defiant".to_owned(), notice);
27373
27374        let bytes = serde_json::to_vec(&source).unwrap();
27375        let document = external_wordpress_assessment_document_from_bytes(&bytes);
27376        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
27377        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
27378        assert_eq!(
27379            parsed["wordpress_review"]["external_review"]["evaluations"][0]["record_reference"],
27380            RECORD
27381        );
27382        assert!(comparison::import_assessment_summary(json.as_bytes()).is_ok());
27383        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
27384        assert!(html.contains(&format!("href=\"{RECORD}\">Selected source reference</a>")));
27385        let markdown =
27386            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
27387        assert!(markdown.contains(&format!("[Selected source reference](<{RECORD}>)")));
27388    }
27389
27390    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27391    #[test]
27392    fn saved_inventory_reporting_is_path_free_versioned_and_reader_compatible() {
27393        let document = saved_inventory_wordpress_assessment_document();
27394        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
27395        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
27396        let audit = &parsed["wordpress_review"];
27397        assert_eq!(audit["schema"], "security.wordpress-review-audit/v3");
27398        assert!(audit.get("external_review").is_none());
27399        assert_eq!(audit["catalog_schema"], WORDPRESS_ADVISORY_CATALOG_SCHEMA);
27400        assert_eq!(audit["inventory_import"]["component_count"], 1);
27401        assert_eq!(
27402            audit["inventory_import"]["inputs"][0]["class"],
27403            "wp_cli_plugins_json"
27404        );
27405        assert_eq!(
27406            audit["inventory_import"]["inputs"][0]["sha256"],
27407            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
27408        );
27409        assert_eq!(audit["components"][1]["inventory_status"], "active");
27410        assert!(comparison::import_assessment_summary(json.as_bytes()).is_ok());
27411
27412        for format in [
27413            ReportFormat::Html,
27414            ReportFormat::Markdown,
27415            ReportFormat::Csv,
27416        ] {
27417            let rendered = render_assessment_with_limit(&document, format, usize::MAX).unwrap();
27418            assert!(rendered.contains("security.wordpress-review-audit/v3"));
27419            assert!(rendered.contains("wp_cli_plugins_json"));
27420            assert!(!rendered.contains("PRIVATE"));
27421        }
27422
27423        let mut missing_inventory = saved_inventory_wordpress_assessment_document();
27424        missing_inventory
27425            .wordpress_review
27426            .as_mut()
27427            .unwrap()
27428            .inventory_import = None;
27429        let mut wrong_digest = saved_inventory_wordpress_assessment_document();
27430        wrong_digest
27431            .wordpress_review
27432            .as_mut()
27433            .unwrap()
27434            .inventory_import
27435            .as_mut()
27436            .unwrap()
27437            .inputs[0]
27438            .sha256 = "A".repeat(64);
27439        let mut status_without_context = saved_inventory_wordpress_assessment_document();
27440        let component = &mut status_without_context
27441            .wordpress_review
27442            .as_mut()
27443            .unwrap()
27444            .components[1];
27445        component.identity_sources = vec!["same_origin_asset_path"];
27446        component.confidence_classes = vec!["structural_hint"];
27447
27448        let mut status_without_inventory_count = saved_inventory_wordpress_assessment_document();
27449        let audit = status_without_inventory_count
27450            .wordpress_review
27451            .as_mut()
27452            .unwrap();
27453        audit.components[1].inventory_status = None;
27454        audit.inventory_import.as_mut().unwrap().component_count = 0;
27455
27456        let mut status_outside_supplied_category = saved_inventory_wordpress_assessment_document();
27457        let component = &mut status_outside_supplied_category
27458            .wordpress_review
27459            .as_mut()
27460            .unwrap()
27461            .components[1];
27462        component.identity.kind = "theme";
27463        component.identity.slug = "synthetic-theme".to_owned();
27464
27465        for document in [
27466            missing_inventory,
27467            wrong_digest,
27468            status_without_context,
27469            status_without_inventory_count,
27470            status_outside_supplied_category,
27471        ] {
27472            for format in ReportGenerator::available_formats() {
27473                assert_eq!(
27474                    render_assessment_with_limit(&document, *format, usize::MAX),
27475                    Err(ReportError::Serialization)
27476                );
27477            }
27478        }
27479
27480        let mut combined_observed_and_operator = saved_inventory_wordpress_assessment_document();
27481        let component = &mut combined_observed_and_operator
27482            .wordpress_review
27483            .as_mut()
27484            .unwrap()
27485            .components[1];
27486        component.evidence_class = "observed_hint";
27487        component.identity_sources = vec!["same_origin_asset_path", "operator_context"];
27488        component.confidence_classes = vec!["structural_hint", "operator_assertion"];
27489        component.versions.insert(
27490            0,
27491            WordPressVersionEvidenceDocument {
27492                value: "1.2.3-vendor".to_owned(),
27493                source: "same_origin_asset_path",
27494                confidence: "structural_hint",
27495            },
27496        );
27497        let json = render_assessment_with_limit(
27498            &combined_observed_and_operator,
27499            ReportFormat::Json,
27500            usize::MAX,
27501        )
27502        .unwrap();
27503        assert!(comparison::import_assessment_summary(json.as_bytes()).is_ok());
27504    }
27505
27506    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27507    #[test]
27508    fn wordpress_reporting_rejects_inconsistent_audit_truth() {
27509        let mut extra_request = observed_wordpress_assessment_document();
27510        extra_request
27511            .wordpress_review
27512            .as_mut()
27513            .unwrap()
27514            .additional_request_count = 1;
27515        let mut missing_item = observed_wordpress_assessment_document();
27516        missing_item
27517            .wordpress_review
27518            .as_mut()
27519            .unwrap()
27520            .item_projected = false;
27521        let mut missing_audit = observed_wordpress_assessment_document();
27522        missing_audit.wordpress_review = None;
27523        let mut wrong_component_count = observed_wordpress_assessment_document();
27524        wrong_component_count
27525            .wordpress_review
27526            .as_mut()
27527            .unwrap()
27528            .component_count = 2;
27529        let mut missing_catalog = observed_wordpress_assessment_document();
27530        missing_catalog.wordpress_review.as_mut().unwrap().catalog = None;
27531        let mut v1_profile_leak = observed_wordpress_assessment_document();
27532        v1_profile_leak
27533            .wordpress_review
27534            .as_mut()
27535            .unwrap()
27536            .advisories[0]
27537            .comparison_profile = Some("numeric-dotted/v1");
27538        let mut v2_missing_resolution = profiled_wordpress_assessment_document();
27539        v2_missing_resolution
27540            .wordpress_review
27541            .as_mut()
27542            .unwrap()
27543            .advisories[0]
27544            .version_resolution = None;
27545        let mut v2_contradictory_resolution = profiled_wordpress_assessment_document();
27546        let advisory = &mut v2_contradictory_resolution
27547            .wordpress_review
27548            .as_mut()
27549            .unwrap()
27550            .advisories[0];
27551        advisory.version_resolution = Some("unsupported");
27552        advisory.version_resolution_reason = Some("single_supported_version");
27553        let mut v4_missing_external = external_wordpress_assessment_document();
27554        v4_missing_external
27555            .wordpress_review
27556            .as_mut()
27557            .unwrap()
27558            .external_review = None;
27559        let mut v4_native_catalog = external_wordpress_assessment_document();
27560        v4_native_catalog.wordpress_review.as_mut().unwrap().catalog =
27561            Some(WordPressCatalogDocument {
27562                id: "invalid-native-catalog".to_owned(),
27563                revision: "invalid".to_owned(),
27564                retrieved_on: "2026-09-07".to_owned(),
27565            });
27566        let mut v4_wrong_digest = external_wordpress_assessment_document();
27567        v4_wrong_digest
27568            .wordpress_review
27569            .as_mut()
27570            .unwrap()
27571            .external_review
27572            .as_mut()
27573            .unwrap()
27574            .input
27575            .sha256 = "A".repeat(64);
27576
27577        for document in [
27578            extra_request,
27579            missing_item,
27580            missing_audit,
27581            wrong_component_count,
27582            missing_catalog,
27583            v1_profile_leak,
27584            v2_missing_resolution,
27585            v2_contradictory_resolution,
27586            v4_missing_external,
27587            v4_native_catalog,
27588            v4_wrong_digest,
27589        ] {
27590            for format in ReportGenerator::available_formats() {
27591                assert_eq!(
27592                    render_assessment_with_limit(&document, *format, usize::MAX),
27593                    Err(ReportError::Serialization)
27594                );
27595            }
27596        }
27597    }
27598
27599    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27600    #[test]
27601    fn wordpress_reporting_and_import_share_combined_result_bounds() {
27602        let mut maximum_components = observed_wordpress_assessment_document();
27603        maximum_components.items[0].fingerprint =
27604            "sha256:0000000000000000000000000000000000000000000000000000000000000001";
27605        let audit = maximum_components.wordpress_review.as_mut().unwrap();
27606        audit.catalog_status = "catalogue_not_supplied";
27607        audit.catalog = None;
27608        audit.signal_count = u16::try_from(MAX_WORDPRESS_SIGNALS).unwrap();
27609        audit.evidence_reference_count = 1;
27610        audit.advisories.clear();
27611        audit.advisory_count = 0;
27612        audit.components = (0..MAX_WORDPRESS_RESULT_COMPONENTS)
27613            .map(|index| {
27614                let observed = index < MAX_WORDPRESS_SIGNALS;
27615                WordPressComponentDocument {
27616                    identity: WordPressComponentIdentityDocument {
27617                        kind: "plugin",
27618                        slug: format!("component-{index:03}"),
27619                    },
27620                    evidence_class: if observed {
27621                        "observed_hint"
27622                    } else {
27623                        "operator_supplied"
27624                    },
27625                    identity_sources: vec![if observed {
27626                        "same_origin_asset_path"
27627                    } else {
27628                        "operator_context"
27629                    }],
27630                    confidence_classes: vec![if observed {
27631                        "structural_hint"
27632                    } else {
27633                        "operator_assertion"
27634                    }],
27635                    versions: Vec::new(),
27636                    activation: None,
27637                    inventory_status: None,
27638                }
27639            })
27640            .collect();
27641        audit.component_count = audit.components.len();
27642        let maximum_components_json = render_assessment_with_limit(
27643            &maximum_components,
27644            ReportFormat::Json,
27645            MAX_RENDERED_REPORT_BYTES,
27646        )
27647        .unwrap();
27648        comparison::import_assessment_summary(maximum_components_json.as_bytes()).unwrap();
27649
27650        let mut maximum_versions = observed_wordpress_assessment_document();
27651        maximum_versions.items[0].fingerprint =
27652            "sha256:0000000000000000000000000000000000000000000000000000000000000001";
27653        let audit = maximum_versions.wordpress_review.as_mut().unwrap();
27654        audit.catalog_status = "catalogue_not_supplied";
27655        audit.catalog = None;
27656        audit.signal_count = u16::try_from(MAX_WORDPRESS_SIGNALS).unwrap();
27657        audit.evidence_reference_count = 1;
27658        audit.advisories.clear();
27659        audit.advisory_count = 0;
27660        let mut components = vec![WordPressComponentDocument {
27661            identity: WordPressComponentIdentityDocument {
27662                kind: "core",
27663                slug: "wordpress".to_owned(),
27664            },
27665            evidence_class: "conflicting",
27666            identity_sources: vec!["generator_metadata"],
27667            confidence_classes: vec!["public_declaration"],
27668            versions: (0..MAX_WORDPRESS_SIGNALS)
27669                .map(|index| WordPressVersionEvidenceDocument {
27670                    value: format!("1.0.{index}"),
27671                    source: "generator_metadata",
27672                    confidence: "public_declaration",
27673                })
27674                .collect(),
27675            activation: None,
27676            inventory_status: None,
27677        }];
27678        components.extend(
27679            (0..MAX_WORDPRESS_RESULT_COMPONENTS - MAX_WORDPRESS_SIGNALS).map(|index| {
27680                WordPressComponentDocument {
27681                    identity: WordPressComponentIdentityDocument {
27682                        kind: "plugin",
27683                        slug: format!("declared-{index:03}"),
27684                    },
27685                    evidence_class: "operator_supplied",
27686                    identity_sources: vec!["operator_context"],
27687                    confidence_classes: vec!["operator_assertion"],
27688                    versions: vec![WordPressVersionEvidenceDocument {
27689                        value: format!("2.0.{index}"),
27690                        source: "operator_context",
27691                        confidence: "operator_assertion",
27692                    }],
27693                    activation: None,
27694                    inventory_status: None,
27695                }
27696            }),
27697        );
27698        audit.components = components;
27699        audit.component_count = audit.components.len();
27700        assert_eq!(
27701            audit
27702                .components
27703                .iter()
27704                .map(|component| component.versions.len())
27705                .sum::<usize>(),
27706            MAX_WORDPRESS_RESULT_VERSION_EVIDENCE
27707        );
27708        let maximum_versions_json = render_assessment_with_limit(
27709            &maximum_versions,
27710            ReportFormat::Json,
27711            MAX_RENDERED_REPORT_BYTES,
27712        )
27713        .unwrap();
27714        comparison::import_assessment_summary(maximum_versions_json.as_bytes()).unwrap();
27715    }
27716
27717    #[cfg(all(feature = "scanning", feature = "wordpress-review"))]
27718    #[test]
27719    fn wordpress_reporting_tokens_are_exhaustive_and_semantically_named() {
27720        for (kind, token) in [
27721            (WordPressComponentKind::Core, "core"),
27722            (WordPressComponentKind::Plugin, "plugin"),
27723            (WordPressComponentKind::Theme, "theme"),
27724        ] {
27725            assert_eq!(wordpress_component_kind(kind), token);
27726        }
27727        for (source, token) in [
27728            (
27729                WordPressEvidenceSource::GeneratorMetadata,
27730                "generator_metadata",
27731            ),
27732            (
27733                WordPressEvidenceSource::SameOriginAssetPath,
27734                "same_origin_asset_path",
27735            ),
27736            (
27737                WordPressEvidenceSource::ThemeStylesheetDeclaration,
27738                "theme_stylesheet_declaration",
27739            ),
27740            (WordPressEvidenceSource::OperatorContext, "operator_context"),
27741        ] {
27742            assert_eq!(wordpress_evidence_source(source), token);
27743        }
27744        for (confidence, token) in [
27745            (
27746                WordPressEvidenceConfidence::PublicDeclaration,
27747                "public_declaration",
27748            ),
27749            (
27750                WordPressEvidenceConfidence::StructuralHint,
27751                "structural_hint",
27752            ),
27753            (
27754                WordPressEvidenceConfidence::OperatorAssertion,
27755                "operator_assertion",
27756            ),
27757        ] {
27758            assert_eq!(wordpress_confidence(confidence), token);
27759        }
27760        for (class, token) in [
27761            (
27762                WordPressComponentEvidenceClass::ObservedHint,
27763                "observed_hint",
27764            ),
27765            (
27766                WordPressComponentEvidenceClass::OperatorSupplied,
27767                "operator_supplied",
27768            ),
27769            (WordPressComponentEvidenceClass::Conflicting, "conflicting"),
27770            (WordPressComponentEvidenceClass::Unknown, "unknown"),
27771        ] {
27772            assert_eq!(wordpress_evidence_class(class), token);
27773        }
27774        for (relation, token) in [
27775            (
27776                WordPressVersionRelation::WithinDeclaredRange,
27777                "within_declared_range",
27778            ),
27779            (
27780                WordPressVersionRelation::OutsideDeclaredRanges,
27781                "outside_declared_ranges",
27782            ),
27783            (WordPressVersionRelation::Unknown, "unknown"),
27784            (WordPressVersionRelation::Unsupported, "unsupported"),
27785        ] {
27786            assert_eq!(wordpress_version_relation(relation), token);
27787        }
27788        for (profile, token) in [
27789            (
27790                WordPressComparisonProfile::NumericDottedV1,
27791                "numeric-dotted/v1",
27792            ),
27793            (
27794                WordPressComparisonProfile::PhpReleaseSubsetV1,
27795                "php-release-subset/v1",
27796            ),
27797        ] {
27798            assert_eq!(wordpress_comparison_profile(profile), token);
27799        }
27800        for (resolution, token) in [
27801            (WordPressVersionResolution::Missing, "missing"),
27802            (
27803                WordPressVersionResolution::SupportedEquivalent,
27804                "supported_equivalent",
27805            ),
27806            (WordPressVersionResolution::Conflicting, "conflicting"),
27807            (WordPressVersionResolution::Unsupported, "unsupported"),
27808        ] {
27809            assert_eq!(wordpress_version_resolution(resolution), token);
27810        }
27811        for (reason, token) in [
27812            (
27813                WordPressVersionResolutionReason::NoVersionEvidence,
27814                "no_version_evidence",
27815            ),
27816            (
27817                WordPressVersionResolutionReason::SingleSupportedVersion,
27818                "single_supported_version",
27819            ),
27820            (
27821                WordPressVersionResolutionReason::EquivalentSupportedVersions,
27822                "equivalent_supported_versions",
27823            ),
27824            (
27825                WordPressVersionResolutionReason::ConflictingVersionEvidence,
27826                "conflicting_version_evidence",
27827            ),
27828            (
27829                WordPressVersionResolutionReason::UnsupportedVersionEvidence,
27830                "unsupported_version_evidence",
27831            ),
27832        ] {
27833            assert_eq!(wordpress_version_resolution_reason(reason), token);
27834        }
27835        for (applicability, token) in [
27836            (
27837                WordPressApplicability::CandidateMatchOnDeclaredFacts,
27838                "candidate_match_on_declared_facts",
27839            ),
27840            (
27841                WordPressApplicability::ContradictedByDeclaredFacts,
27842                "contradicted_by_declared_facts",
27843            ),
27844            (
27845                WordPressApplicability::IndeterminateMissingEvidence,
27846                "indeterminate_missing_evidence",
27847            ),
27848            (
27849                WordPressApplicability::IndeterminateUnsupported,
27850                "indeterminate_unsupported",
27851            ),
27852        ] {
27853            assert_eq!(wordpress_applicability(applicability), token);
27854        }
27855        assert_eq!(
27856            wordpress_catalog_status(WordPressCatalogStatus::CatalogNotSupplied),
27857            "catalogue_not_supplied"
27858        );
27859        assert_eq!(
27860            wordpress_catalog_status(WordPressCatalogStatus::Evaluated),
27861            "evaluated"
27862        );
27863        assert_eq!(
27864            wordpress_execution(WordPressExecutionStatus::NotPerformed),
27865            "not_performed"
27866        );
27867
27868        for prerequisite in [
27869            WordPressPrerequisite::HostingOs {
27870                equals: WordPressHostingOs::Linux,
27871            },
27872            WordPressPrerequisite::Multisite {
27873                equals: WordPressMultisiteState::Enabled,
27874            },
27875            WordPressPrerequisite::Activation {
27876                equals: WordPressActivationState::NetworkActive,
27877            },
27878            WordPressPrerequisite::Patch {
27879                id: "upstream-fix".to_owned(),
27880                equals: WordPressPatchState::NotApplied,
27881            },
27882            WordPressPrerequisite::Unsupported {
27883                name: "php-runtime".to_owned(),
27884            },
27885        ] {
27886            let document =
27887                wordpress_prerequisite(&prerequisite, WordPressPrerequisiteOutcome::Unknown);
27888            assert_eq!(document.outcome, "unknown");
27889            assert_eq!(document.kind == "patch", document.patch_id.is_some());
27890        }
27891        for (outcome, token) in [
27892            (
27893                WordPressPrerequisiteOutcome::MatchedOnSuppliedFacts,
27894                "matched_on_supplied_facts",
27895            ),
27896            (
27897                WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts,
27898                "contradicted_on_supplied_facts",
27899            ),
27900            (WordPressPrerequisiteOutcome::Unknown, "unknown"),
27901            (WordPressPrerequisiteOutcome::Unsupported, "unsupported"),
27902        ] {
27903            assert_eq!(wordpress_prerequisite_outcome(outcome), token);
27904        }
27905        for (activation, token) in [
27906            (WordPressActivationState::Active, "active"),
27907            (WordPressActivationState::Inactive, "inactive"),
27908            (WordPressActivationState::NetworkActive, "network_active"),
27909            (WordPressActivationState::Unknown, "unknown"),
27910        ] {
27911            assert_eq!(wordpress_activation(activation), token);
27912        }
27913        for (os, token) in [
27914            (WordPressHostingOs::Linux, "linux"),
27915            (WordPressHostingOs::Windows, "windows"),
27916            (WordPressHostingOs::Macos, "macos"),
27917            (WordPressHostingOs::Bsd, "bsd"),
27918            (WordPressHostingOs::Other, "other"),
27919        ] {
27920            assert_eq!(wordpress_hosting_os(os), token);
27921        }
27922        for (state, token) in [
27923            (WordPressMultisiteState::Enabled, "enabled"),
27924            (WordPressMultisiteState::Disabled, "disabled"),
27925        ] {
27926            assert_eq!(wordpress_multisite(state), token);
27927        }
27928        for (state, token) in [
27929            (WordPressPatchState::Applied, "applied"),
27930            (WordPressPatchState::NotApplied, "not_applied"),
27931            (WordPressPatchState::Unknown, "unknown"),
27932        ] {
27933            assert_eq!(wordpress_patch(state), token);
27934        }
27935    }
27936
27937    #[cfg(all(feature = "scanning", feature = "rest-review"))]
27938    #[test]
27939    fn rest_reporting_outcome_and_class_tokens_are_exhaustive_and_stable() {
27940        for (outcome, token) in [
27941            (RestRuntimeOutcome::NotEligible, "not_eligible"),
27942            (RestRuntimeOutcome::SurfaceObserved, "surface_observed"),
27943            (RestRuntimeOutcome::ReplayMismatch, "replay_mismatch"),
27944            (RestRuntimeOutcome::CompleteNonJson, "complete_non_json"),
27945            (RestRuntimeOutcome::Redirect, "redirect"),
27946            (
27947                RestRuntimeOutcome::AuthenticationRequired,
27948                "authentication_required",
27949            ),
27950            (RestRuntimeOutcome::Forbidden, "forbidden"),
27951            (RestRuntimeOutcome::NotFound, "not_found"),
27952            (RestRuntimeOutcome::RateLimited, "rate_limited"),
27953            (
27954                RestRuntimeOutcome::DefensiveInterference,
27955                "defensive_interference",
27956            ),
27957            (RestRuntimeOutcome::ServerError, "server_error"),
27958            (RestRuntimeOutcome::UnsupportedMedia, "unsupported_media"),
27959            (RestRuntimeOutcome::Truncated, "truncated"),
27960            (RestRuntimeOutcome::Incomplete, "incomplete"),
27961            (RestRuntimeOutcome::Cancelled, "cancelled"),
27962            (RestRuntimeOutcome::BudgetExhausted, "budget_exhausted"),
27963        ] {
27964            assert_eq!(rest_outcome(outcome), token);
27965        }
27966        assert_eq!(
27967            rest_documented_response(RestDocumentedResponseClass::JsonCompatible),
27968            "json_compatible"
27969        );
27970        assert_eq!(
27971            rest_documented_response(RestDocumentedResponseClass::Unknown),
27972            "unknown"
27973        );
27974        for (media, token) in [
27975            (RestObservedMediaClass::JsonCompatible, "json_compatible"),
27976            (RestObservedMediaClass::Text, "text"),
27977            (RestObservedMediaClass::Unsupported, "unsupported"),
27978            (RestObservedMediaClass::Unknown, "unknown"),
27979        ] {
27980            assert_eq!(rest_observed_media(media), token);
27981        }
27982    }
27983
27984    #[cfg(feature = "scanning")]
27985    #[test]
27986    fn assessment_confirmed_linkage_fails_closed_when_incomplete_or_mismatched() {
27987        let mut missing_outcome = complete_assessment_document();
27988        missing_outcome.items[2].outcome_reference = None;
27989        let mut downgraded_basis = complete_assessment_document();
27990        downgraded_basis.items[2].claim_basis = "observation";
27991        let mut cross_basis_evidence = complete_assessment_document();
27992        cross_basis_evidence.items[1].candidate_evidence_references =
27993            vec!["evidence-0001".to_owned()];
27994        for document in [missing_outcome, downgraded_basis, cross_basis_evidence] {
27995            for format in ReportGenerator::available_formats() {
27996                assert_eq!(
27997                    render_assessment_with_limit(&document, *format, usize::MAX),
27998                    Err(ReportError::Serialization)
27999                );
28000            }
28001        }
28002    }
28003
28004    #[cfg(feature = "scanning")]
28005    #[test]
28006    fn assessment_renderers_escape_controls_bidi_html_markdown_and_csv_formulae() {
28007        const HOSTILE: &str = " \t=2+3,<script>alert(`x`)</script>&'\u{202E}\n# injected";
28008        let document = observation_assessment_document(HOSTILE);
28009
28010        let json = render_assessment_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
28011        assert!(!json.contains('\t'));
28012        assert!(!json.contains('\u{202E}'));
28013        assert!(!json.contains('\n'));
28014        assert!(json.contains("\\u202E"));
28015        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
28016        assert_eq!(parsed["items"][0]["title"], HOSTILE);
28017
28018        let html = render_assessment_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
28019        assert!(!html.contains("<script>"));
28020        assert!(!html.contains('\u{202E}'));
28021        assert!(html.contains("&lt;script&gt;"));
28022        assert!(html.contains("\\u{202E}\\u{000A}"));
28023
28024        let markdown =
28025            render_assessment_with_limit(&document, ReportFormat::Markdown, usize::MAX).unwrap();
28026        assert!(!markdown.contains("\n# injected"));
28027        assert!(!markdown.contains('\u{202E}'));
28028        assert!(markdown.contains("\\u{202E}\\u{000A}# injected"));
28029
28030        let csv = render_assessment_with_limit(&document, ReportFormat::Csv, usize::MAX).unwrap();
28031        assert!(!csv.contains('\t'));
28032        assert!(!csv.contains('\u{202E}'));
28033        assert!(!csv.contains("\n# injected"));
28034        assert!(csv.contains("' \\u{0009}=2+3"));
28035        let rows: Vec<Vec<String>> = csv.lines().map(parse_csv_line).collect();
28036        assert_eq!(rows[0].len(), ASSESSMENT_CSV_HEADERS.len());
28037        assert!(rows
28038            .iter()
28039            .all(|row| row.len() == ASSESSMENT_CSV_HEADERS.len()));
28040    }
28041
28042    #[cfg(feature = "scanning")]
28043    #[test]
28044    fn assessment_output_ceiling_is_shared_byte_exact_and_returns_no_partial_document() {
28045        let document = complete_assessment_document();
28046        for format in ReportGenerator::available_formats() {
28047            let rendered = render_assessment_with_limit(&document, *format, usize::MAX).unwrap();
28048            assert_eq!(
28049                render_assessment_with_limit(&document, *format, rendered.len()).unwrap(),
28050                rendered
28051            );
28052            assert_eq!(
28053                render_assessment_with_limit(&document, *format, rendered.len() - 1),
28054                Err(ReportError::OutputLimitExceeded {
28055                    limit: rendered.len() - 1
28056                })
28057            );
28058        }
28059
28060        let oversized = "x".repeat(MAX_RENDERED_REPORT_BYTES + 1);
28061        let document = observation_assessment_document(&oversized);
28062        assert_eq!(
28063            render_assessment_with_limit(
28064                &document,
28065                ReportFormat::Markdown,
28066                MAX_RENDERED_REPORT_BYTES
28067            ),
28068            Err(ReportError::OutputLimitExceeded {
28069                limit: MAX_RENDERED_REPORT_BYTES
28070            })
28071        );
28072    }
28073
28074    #[cfg(feature = "scanning")]
28075    #[test]
28076    fn assessment_documents_never_emit_secret_or_raw_runtime_sentinels() {
28077        let document = complete_assessment_document();
28078        for format in ReportGenerator::available_formats() {
28079            let rendered = render_assessment_with_limit(&document, *format, usize::MAX).unwrap();
28080            for sentinel in [
28081                "Bearer secret-sentinel",
28082                "session=secret-sentinel",
28083                "cookie-value-sentinel",
28084                "csrf-value-sentinel",
28085                "https://private.example.test/secret/path",
28086                "raw-response-body-sentinel",
28087                "private-evidence-id-sentinel",
28088                "private-case-id-sentinel",
28089                "private-outcome-id-sentinel",
28090                "private-verifier-id-sentinel",
28091            ] {
28092                assert!(!rendered.contains(sentinel));
28093            }
28094        }
28095    }
28096
28097    #[cfg(feature = "scanning")]
28098    #[test]
28099    fn assessment_presentation_target_rejects_unclosed_or_location_bearing_values() {
28100        for invalid in [
28101            "",
28102            "subject",
28103            "query_parameter:return_to",
28104            "https://private.example.test/secret/path",
28105        ] {
28106            let mut document = complete_assessment_document();
28107            document.items[0].presentation_target = invalid;
28108            for format in ReportGenerator::available_formats() {
28109                assert_eq!(
28110                    render_assessment_with_limit(&document, *format, usize::MAX),
28111                    Err(ReportError::Serialization)
28112                );
28113            }
28114        }
28115    }
28116
28117    #[test]
28118    fn public_format_metadata_and_order_are_stable() {
28119        assert_eq!(
28120            ReportGenerator::available_formats(),
28121            &[
28122                ReportFormat::Json,
28123                ReportFormat::Csv,
28124                ReportFormat::Html,
28125                ReportFormat::Markdown,
28126            ]
28127        );
28128        let metadata = [
28129            (ReportFormat::Json, "json", "application/json", "json"),
28130            (ReportFormat::Csv, "csv", "text/csv; charset=utf-8", "csv"),
28131            (
28132                ReportFormat::Html,
28133                "html",
28134                "text/html; charset=utf-8",
28135                "html",
28136            ),
28137            (
28138                ReportFormat::Markdown,
28139                "markdown",
28140                "text/markdown; charset=utf-8",
28141                "md",
28142            ),
28143        ];
28144        for (format, name, media_type, extension) in metadata {
28145            assert_eq!(format.as_str(), name);
28146            assert_eq!(format.media_type(), media_type);
28147            assert_eq!(format.extension(), extension);
28148        }
28149    }
28150
28151    #[test]
28152    fn lifecycle_statuses_and_stop_codes_render_in_every_format() {
28153        let cases = [
28154            (
28155                RunStatus::Complete,
28156                RunStopCode::Completed,
28157                RunStepStatus::Succeeded,
28158                "complete",
28159                "completed",
28160            ),
28161            (
28162                RunStatus::Partial,
28163                RunStopCode::StepFailed,
28164                RunStepStatus::Failed,
28165                "partial",
28166                "step_failed",
28167            ),
28168            (
28169                RunStatus::Cancelled,
28170                RunStopCode::Cancelled,
28171                RunStepStatus::Cancelled,
28172                "cancelled",
28173                "cancelled",
28174            ),
28175            (
28176                RunStatus::Failed,
28177                RunStopCode::RuntimeFailed,
28178                RunStepStatus::Failed,
28179                "failed",
28180                "runtime_failed",
28181            ),
28182        ];
28183        for (status, stop_code, step_status, status_token, stop_token) in cases {
28184            let steps = vec![RunStepReport::new(1, "scan.observe", step_status, 1, None).unwrap()];
28185            let report = report_for_status(status, stop_code, steps, "target", "summary");
28186            for format in ReportGenerator::available_formats() {
28187                let rendered = ReportGenerator::generate(&report, *format).unwrap();
28188                assert!(rendered.contains(status_token));
28189                assert!(rendered.contains(stop_token));
28190            }
28191        }
28192    }
28193
28194    #[test]
28195    fn non_complete_lifecycle_is_prominent_in_human_formats() {
28196        let report = report_for_status(
28197            RunStatus::Partial,
28198            RunStopCode::StepFailed,
28199            vec![RunStepReport::new(1, "scan.observe", RunStepStatus::Failed, 1, None).unwrap()],
28200            "target",
28201            "summary",
28202        );
28203        let html = ReportGenerator::generate(&report, ReportFormat::Html).unwrap();
28204        assert!(
28205            html.contains("<aside class=\"lifecycle\">") && html.contains("Run did not complete")
28206        );
28207        let markdown = ReportGenerator::generate(&report, ReportFormat::Markdown).unwrap();
28208        assert!(markdown.contains("> **Lifecycle notice:** This run did not complete."));
28209    }
28210
28211    #[test]
28212    fn json_projection_has_exact_metadata_and_minimized_outcome_shape() {
28213        let report = complete_report("https://example.test/path", "redacted summary");
28214        let rendered = ReportGenerator::generate(&report, ReportFormat::Json).unwrap();
28215        let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
28216        assert_eq!(value["schema"], REPORT_DOCUMENT_SCHEMA);
28217        assert_eq!(value["source_schema"], termivar_core::RUN_REPORT_SCHEMA);
28218        assert_eq!(value["status"], "complete");
28219        assert_eq!(value["stop_code"], "completed");
28220        assert_eq!(value["target"], "https://example.test/path");
28221        assert_eq!(value["authorized_origin"], "https://example.test");
28222        assert_eq!(value["started_at"], "2026-08-20T10:00:00+00:00");
28223        assert_eq!(value["completed_at"], "2026-08-20T10:00:01+00:00");
28224        assert_eq!(
28225            value["accounting"]["requests"],
28226            serde_json::json!({"mode":"metered","limit":"10","consumed":"4","remaining":"6"})
28227        );
28228        assert_eq!(
28229            value["steps"][0],
28230            serde_json::json!({
28231                "ordinal":1,
28232                "action_id":"scan.observe",
28233                "status":"succeeded",
28234                "duration_ms":"25"
28235            })
28236        );
28237        assert_eq!(
28238            value["outcomes"][0],
28239            serde_json::json!({
28240                "kind":"verification_outcome",
28241                "action_id":"scan.observe",
28242                "severity":"info",
28243                "disposition":"success",
28244                "confidence_ppm":812345,
28245                "evidence_count":1,
28246                "redacted_summary":"redacted summary"
28247            })
28248        );
28249    }
28250
28251    #[test]
28252    fn unresolved_observation_kind_is_distinct() {
28253        let unresolved = RunOutcomeRecord::unresolved(
28254            EntityId::new(PRIVATE_SUBJECT).unwrap(),
28255            "scan.observe",
28256            PRIVATE_RATIONALE,
28257            "summary",
28258        )
28259        .unwrap();
28260        let input = RunReportInput::new(
28261            RunStatus::Complete,
28262            RunStopReason::new(RunStopCode::Completed, "done").unwrap(),
28263            "target",
28264            "origin",
28265            "2026-08-20T10:00:00Z".parse().unwrap(),
28266            "2026-08-20T10:00:01Z".parse().unwrap(),
28267        )
28268        .unwrap()
28269        .with_steps(vec![RunStepReport::new(
28270            1,
28271            "scan.observe",
28272            RunStepStatus::Succeeded,
28273            1,
28274            None,
28275        )
28276        .unwrap()])
28277        .with_outcomes(vec![unresolved]);
28278        let report = RunReport::new(input).unwrap();
28279        let rendered = ReportGenerator::generate(&report, ReportFormat::Json).unwrap();
28280        let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
28281        assert_eq!(value["outcomes"][0]["kind"], "unresolved_observation");
28282        assert_eq!(value["outcomes"][0]["confidence_ppm"], 0);
28283        assert_eq!(value["outcomes"][0]["evidence_count"], 0);
28284    }
28285
28286    #[test]
28287    fn private_source_fields_never_cross_any_renderer() {
28288        let report = complete_report("target", "public redacted summary");
28289        let source_fingerprint = report.outcomes()[0].fingerprint().to_owned();
28290        let forbidden = [
28291            PRIVATE_SUBJECT,
28292            PRIVATE_EVIDENCE,
28293            PRIVATE_RATIONALE,
28294            PRIVATE_CASE,
28295            PRIVATE_RULE,
28296            PRIVATE_HYPOTHESIS,
28297            PRIVATE_STEP_DETAIL,
28298            "private-stop-detail-sentinel",
28299            "evidence_ids",
28300            "rationale",
28301        ];
28302        for format in ReportGenerator::available_formats() {
28303            let rendered = ReportGenerator::generate(&report, *format).unwrap();
28304            assert!(!rendered.contains(&source_fingerprint));
28305            assert!(!rendered.to_ascii_lowercase().contains("fingerprint"));
28306            for sentinel in forbidden {
28307                assert!(!rendered.contains(sentinel));
28308            }
28309        }
28310    }
28311
28312    #[test]
28313    fn html_is_self_contained_csp_guarded_and_contextually_escaped() {
28314        let hostile = "<script>alert(\"x\")</script>&'\u{202E}\n";
28315        let report = complete_report(hostile, hostile);
28316        let html = ReportGenerator::generate(&report, ReportFormat::Html).unwrap();
28317        assert!(html.starts_with("<!doctype html>"));
28318        assert!(html.contains("default-src 'none'"));
28319        assert!(html.contains("style-src 'unsafe-inline'"));
28320        assert!(!html.contains("<script>"));
28321        assert!(!html.contains("src=\"http"));
28322        assert!(!html.contains("href="));
28323        assert!(html.contains("&lt;script&gt;"));
28324        assert!(html.contains("&quot;x&quot;"));
28325        assert!(html.contains("&amp;&#39;\\u{202E}\\u{000A}"));
28326    }
28327
28328    #[test]
28329    fn markdown_uses_dynamic_code_spans_for_all_untrusted_text() {
28330        let hostile = "`` </code> [link](https://attacker.test) ![image](x)\n# injected";
28331        let report = complete_report(hostile, hostile);
28332        let markdown = ReportGenerator::generate(&report, ReportFormat::Markdown).unwrap();
28333        assert!(markdown.contains(
28334            "``` `` </code> [link](https://attacker.test) ![image](x)\\u{000A}# injected ```"
28335        ));
28336        assert!(!markdown.contains("\n# injected"));
28337        assert!(!markdown.lines().any(|line| line.starts_with('<')));
28338    }
28339
28340    #[test]
28341    fn csv_quotes_every_cell_neutralizes_formulas_and_visualizes_controls() {
28342        let hostile = " \t=2+3,\"quoted\"\u{202E}\nnext";
28343        let report = complete_report(hostile, hostile);
28344        let csv = ReportGenerator::generate(&report, ReportFormat::Csv).unwrap();
28345        for line in csv.lines() {
28346            assert!(line.starts_with('"'));
28347            assert!(line.ends_with('"'));
28348        }
28349        assert!(csv.contains("\"' \\u{0009}=2+3,\"\"quoted\"\"\\u{202E}\\u{000A}next\""));
28350        assert!(!csv.contains('\t'));
28351        assert!(!csv.contains('\u{202E}'));
28352        assert!(!csv.contains("\nnext"));
28353        for prefix in ['=', '+', '-', '@'] {
28354            let value = format!(" \t{prefix}payload");
28355            let mut output = RenderBuffer::new(1_024);
28356            write_csv_cell(&mut output, &value).unwrap();
28357            assert!(output.finish().starts_with("\"' \\u{0009}"));
28358        }
28359    }
28360
28361    #[test]
28362    fn csv_rows_match_header_width_and_step_named_columns() {
28363        let report = complete_report("target,\"quoted\"", "summary,\"quoted\"");
28364        let csv = ReportGenerator::generate(&report, ReportFormat::Csv).unwrap();
28365        let rows: Vec<Vec<String>> = csv.lines().map(parse_csv_line).collect();
28366        let headers = &rows[0];
28367        assert_eq!(headers.len(), CSV_HEADERS.len());
28368        for row in &rows[1..] {
28369            assert_eq!(row.len(), headers.len(), "CSV row width drifted: {row:?}");
28370        }
28371
28372        let column = |name: &str| headers.iter().position(|header| header == name).unwrap();
28373        let step = rows
28374            .iter()
28375            .find(|row| row[column("record_type")] == "step")
28376            .unwrap();
28377        assert_eq!(step[column("action_id")], "scan.observe");
28378        assert_eq!(step[column("duration_ms")], "25");
28379        assert_eq!(step[column("status_or_disposition")], "succeeded");
28380        assert_eq!(step[column("severity")], "");
28381        assert_eq!(step[column("redacted_summary")], "");
28382    }
28383
28384    #[test]
28385    fn visible_encodings_are_injective_for_markers_backslashes_and_bidi() {
28386        let render_csv_cell = |value: &str| {
28387            let mut output = RenderBuffer::new(1_024);
28388            write_csv_cell(&mut output, value).unwrap();
28389            output.finish()
28390        };
28391        let formula = render_csv_cell("=1+1");
28392        let literal_apostrophe = render_csv_cell("'=1+1");
28393        assert_eq!(formula, "\"'=1+1\"");
28394        assert_eq!(literal_apostrophe, "\"\\u{0027}=1+1\"");
28395        assert_ne!(formula, literal_apostrophe);
28396
28397        let actual_bidi_csv = render_csv_cell("\u{202E}");
28398        let literal_bidi_csv = render_csv_cell("\\u{202E}");
28399        assert_eq!(actual_bidi_csv, "\"\\u{202E}\"");
28400        assert_eq!(literal_bidi_csv, "\"\\\\u{202E}\"");
28401        assert_ne!(actual_bidi_csv, literal_bidi_csv);
28402
28403        let render_html_text = |value: &str| {
28404            let mut output = RenderBuffer::new(1_024);
28405            write_html_text(&mut output, value).unwrap();
28406            output.finish()
28407        };
28408        assert_eq!(render_html_text("\u{202E}"), "\\u{202E}");
28409        assert_eq!(render_html_text("\\u{202E}"), "\\\\u{202E}");
28410
28411        assert_eq!(visible_text("\u{202E}"), "\\u{202E}");
28412        assert_eq!(visible_text("\\u{202E}"), "\\\\u{202E}");
28413    }
28414
28415    #[test]
28416    fn json_escapes_bidi_and_c1_controls_with_parsed_semantics_unchanged() {
28417        let payload = "actual:\u{202E}:c1:\u{0085}:literal:\\u{202E}";
28418        let report = complete_report(payload, payload);
28419        let document = ReportDocument::from_report(&report).unwrap();
28420        let raw = serde_json::to_string(&document).unwrap();
28421        let rendered = render_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
28422
28423        assert!(rendered.len() > raw.len());
28424        assert!(!rendered.contains('\u{202E}'));
28425        assert!(!rendered.contains('\u{0085}'));
28426        assert!(rendered.contains("\\u202E"));
28427        assert!(rendered.contains("\\u0085"));
28428
28429        let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
28430        assert_eq!(parsed["target"], payload);
28431        assert_eq!(parsed["outcomes"][0]["redacted_summary"], payload);
28432        assert_eq!(
28433            render_with_limit(&document, ReportFormat::Json, rendered.len()).unwrap(),
28434            rendered
28435        );
28436        assert_eq!(
28437            render_with_limit(&document, ReportFormat::Json, rendered.len() - 1),
28438            Err(ReportError::OutputLimitExceeded {
28439                limit: rendered.len() - 1
28440            })
28441        );
28442    }
28443
28444    #[test]
28445    fn maximum_u64_values_are_decimal_strings_and_deterministic() {
28446        let report = maximum_accounting_report();
28447        let maximum = u64::MAX.to_string();
28448        let json = ReportGenerator::generate(&report, ReportFormat::Json).unwrap();
28449        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
28450        assert_eq!(value["accounting"]["requests"]["limit"], maximum);
28451        assert_eq!(value["accounting"]["requests"]["consumed"], maximum);
28452        assert_eq!(
28453            value["accounting"]["response_body_bytes"]["consumed"],
28454            maximum
28455        );
28456        assert_eq!(
28457            value["accounting"]["request_body_bytes"]["remaining"],
28458            maximum
28459        );
28460        assert_eq!(value["steps"][0]["duration_ms"], maximum);
28461        assert!(value["steps"][0]["duration_ms"].is_string());
28462
28463        for format in ReportGenerator::available_formats() {
28464            let first = ReportGenerator::generate(&report, *format).unwrap();
28465            let second = ReportGenerator::generate(&report, *format).unwrap();
28466            assert_eq!(first, second);
28467            assert!(first.contains(&maximum));
28468        }
28469    }
28470
28471    #[test]
28472    fn markdown_empty_and_all_space_code_spans_are_unambiguous() {
28473        let render = |value: &str| {
28474            let mut output = RenderBuffer::new(1_024);
28475            write_markdown_code_span(&mut output, value).unwrap();
28476            output.finish()
28477        };
28478        assert_eq!(render(""), "`\\u{EMPTY}`");
28479        assert_eq!(render(" "), "` `");
28480        assert_eq!(render("   "), "`   `");
28481        assert_eq!(render("\\u{EMPTY}"), "`\\\\u{EMPTY}`");
28482        assert_ne!(render(""), render("\\u{EMPTY}"));
28483    }
28484
28485    #[test]
28486    fn output_limit_is_applied_after_escape_expansion_without_partial_return() {
28487        let report = complete_report("<&", "summary");
28488        let document = ReportDocument::from_report(&report).unwrap();
28489        let full_html = render_with_limit(&document, ReportFormat::Html, usize::MAX).unwrap();
28490        let exact = render_with_limit(&document, ReportFormat::Html, full_html.len()).unwrap();
28491        assert_eq!(exact, full_html);
28492        assert_eq!(
28493            render_with_limit(&document, ReportFormat::Html, full_html.len() - 1),
28494            Err(ReportError::OutputLimitExceeded {
28495                limit: full_html.len() - 1
28496            })
28497        );
28498
28499        let escaped_target_offset = full_html.find("&lt;&amp;").unwrap();
28500        let raw_boundary = escaped_target_offset + "<&".len();
28501        assert_eq!(
28502            render_with_limit(&document, ReportFormat::Html, raw_boundary),
28503            Err(ReportError::OutputLimitExceeded {
28504                limit: raw_boundary
28505            })
28506        );
28507    }
28508
28509    #[test]
28510    fn json_cap_and_utf8_boundaries_are_byte_exact() {
28511        let report = complete_report("é", "雪");
28512        let document = ReportDocument::from_report(&report).unwrap();
28513        let full = render_with_limit(&document, ReportFormat::Json, usize::MAX).unwrap();
28514        assert!(full.chars().count() < full.len());
28515        assert!(full.contains("é"));
28516        assert!(full.contains("雪"));
28517        assert_eq!(
28518            render_with_limit(&document, ReportFormat::Json, full.len() - 1),
28519            Err(ReportError::OutputLimitExceeded {
28520                limit: full.len() - 1
28521            })
28522        );
28523        assert_eq!(
28524            render_with_limit(&document, ReportFormat::Json, full.len()).unwrap(),
28525            full
28526        );
28527    }
28528
28529    #[test]
28530    fn every_format_is_byte_deterministic_with_stable_envelopes() {
28531        let report = complete_report("target", "summary");
28532        let document = ReportDocument::from_report(&report).unwrap();
28533        for format in ReportGenerator::available_formats() {
28534            let first = ReportGenerator::generate(&report, *format).unwrap();
28535            let second = ReportGenerator::generate(&report, *format).unwrap();
28536            assert_eq!(first.as_bytes(), second.as_bytes());
28537            assert!(first.len() <= MAX_RENDERED_REPORT_BYTES);
28538            assert_eq!(
28539                render_with_limit(&document, *format, first.len()).unwrap(),
28540                first
28541            );
28542            assert_eq!(
28543                render_with_limit(&document, *format, first.len() - 1),
28544                Err(ReportError::OutputLimitExceeded {
28545                    limit: first.len() - 1
28546                })
28547            );
28548        }
28549        let json = ReportGenerator::generate(&report, ReportFormat::Json).unwrap();
28550        assert!(json.starts_with(
28551            "{\"schema\":\"venom-rendered-run/v1\",\"source_schema\":\"venom-run/v1\""
28552        ));
28553        let csv = ReportGenerator::generate(&report, ReportFormat::Csv).unwrap();
28554        assert!(csv.starts_with("\"record_type\",\"name\",\"value\""));
28555        let markdown = ReportGenerator::generate(&report, ReportFormat::Markdown).unwrap();
28556        assert!(markdown.starts_with("# Termivar run report\n\n- Schema: `venom-rendered-run/v1`"));
28557    }
28558
28559    #[test]
28560    fn error_display_is_opaque_and_bounded() {
28561        let limit = ReportError::OutputLimitExceeded { limit: 7 }.to_string();
28562        let serialization = ReportError::Serialization.to_string();
28563        assert_eq!(limit, "rendered report exceeds the output byte limit");
28564        assert_eq!(serialization, "rendered report serialization failed");
28565        assert!(limit.len() < 80);
28566        assert!(serialization.len() < 80);
28567        assert!(!limit.contains('7'));
28568    }
28569}