1pub 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#[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
203pub const REPORT_DOCUMENT_SCHEMA: &str = "venom-rendered-run/v1";
205#[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";
217pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
249#[non_exhaustive]
250pub enum ReportFormat {
251 Json,
253 Csv,
255 Html,
257 Markdown,
259}
260
261impl ReportFormat {
262 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295#[non_exhaustive]
296pub enum ReportError {
297 OutputLimitExceeded {
299 limit: usize,
301 },
302 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#[derive(Debug, Default, Clone, Copy)]
321pub struct ReportGenerator;
322
323impl ReportGenerator {
324 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 #[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 #[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 #[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 #[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 #[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 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("&")?,
811 '<' => output.push_str("<")?,
812 '>' => output.push_str(">")?,
813 '"' => output.push_str(""")?,
814 '\'' => output.push_str("'")?,
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(¬ice.id))?;
3570 emitter.inline(WordPressPresentationInline::Literal(" — "))?;
3571 emitter.inline(WordPressPresentationInline::Code(¬ice.party))?;
3572 emitter.end_record_title()?;
3573 wordpress_code_field(emitter, "Party", ¬ice.party)?;
3574 wordpress_code_field(emitter, "Notice", ¬ice.notice)?;
3575 wordpress_code_field(emitter, "License", ¬ice.license)?;
3576 wordpress_code_field(
3577 emitter,
3578 "Inert license reference",
3579 ¬ice.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, ¬ice.party)?;
4598 output.push_str("</code><br><strong>Message:</strong> <code>")?;
4599 write_html_text(output, ¬ice.message)?;
4600 output.push_str("</code><br><strong>Notice:</strong> <code>")?;
4601 write_html_text(output, ¬ice.notice)?;
4602 output.push_str("</code><br><strong>License:</strong> <code>")?;
4603 write_html_text(output, ¬ice.license)?;
4604 output.push_str("</code>")?;
4605 if is_strict_https_reference(¬ice.license_url) {
4606 output.push_str(" <a rel=\"noreferrer noopener\" href=\"")?;
4607 write_html_text(output, ¬ice.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, ¬ice.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, ¬ice.party)?;
5312 output.push_str("; message: ")?;
5313 write_markdown_code_span(output, ¬ice.message)?;
5314 output.push_str("; notice: ")?;
5315 write_markdown_code_span(output, ¬ice.notice)?;
5316 output.push_str("; license: ")?;
5317 write_markdown_code_span(output, ¬ice.license)?;
5318 if is_strict_https_reference(¬ice.license_url) {
5319 output.push_str("; [License terms](<")?;
5320 output.push_str(¬ice.license_url)?;
5321 output.push_str(">)")?;
5322 } else {
5323 output.push_str("; license URL: ")?;
5324 write_markdown_code_span(output, ¬ice.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#[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#[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(¬ice.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 ¬ices_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 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 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("<script>inert()</script>"));
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 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 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 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("<script>"));
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("<script>"));
28324 assert!(html.contains(""x""));
28325 assert!(html.contains("&'\\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) \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) \\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("<&").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}