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