1use std::{
8 collections::{BTreeMap, BTreeSet},
9 fmt,
10 sync::Arc,
11 time::Duration,
12};
13
14#[cfg(feature = "reporting")]
15use std::time::SystemTime;
16
17use termivar_core::predicates::WebDiscoveryEvidencePredicate;
18use termivar_core::{
19 DerivationAlgorithm, EntityId, Evidence, EvidenceDerivation, EvidenceId, EvidenceKind,
20 EvidenceOrigin, EvidenceSource, EvidenceValue, HttpEvidencePredicate, PredicateDescriptor,
21};
22use thiserror::Error;
23use tokio::sync::watch;
24use tokio_util::sync::CancellationToken;
25use url::Url;
26
27#[cfg(feature = "graphql-review")]
28use crate::graphql_review::MAX_GRAPHQL_ACTIVE_VERIFICATIONS;
29#[cfg(feature = "graphql-review")]
30use crate::DefenseInteractionClass;
31
32#[cfg(any(
33 feature = "authorization-review",
34 feature = "jwt-target-acceptance-review",
35 feature = "supplied-session-review"
36))]
37use super::authenticated_transport_is_allowed;
38#[cfg(feature = "authorization-review")]
39use crate::authorization_review::{
40 AuthorizationPrincipalPair, AuthorizationReviewOutcome, AuthorizationReviewPolicy,
41};
42#[cfg(feature = "jwt-target-acceptance-review")]
43use crate::jwt_policy_review::JwtTargetAcceptanceRuntimeInput;
44
45#[cfg(feature = "graphql-review")]
46use super::graphql_runtime::{
47 execute_graphql_review, graphql_endpoint_hints, CommittedGraphqlReview, GraphqlResponsePrivacy,
48 GraphqlRuntimeResult, GraphqlRuntimeStop,
49};
50#[cfg(feature = "jwt-target-acceptance-review")]
51use super::jwt_target_acceptance_runtime::JwtTargetAcceptanceRuntimeConfig;
52#[cfg(feature = "openapi-review")]
53use super::openapi_runtime::{
54 select_openapi_candidate, CommittedOpenApiReview, OpenApiReviewConfig, OpenApiRuntimeOutcome,
55 OpenApiRuntimeResult, WebAssessmentOpenApiAudit, MAX_OPENAPI_REVIEW_ACTIVE_VERIFICATIONS,
56};
57#[cfg(feature = "recon-ct-provider")]
58use super::recon_ct_runtime::{
59 execute_recon_ct_provider, recon_ct_provider_scope_is_valid, WebAssessmentReconCtProviderAudit,
60};
61#[cfg(feature = "authorization-review")]
62use super::resource_authorization_runtime::{
63 CommittedResourceAuthorizationReview, ResourceAuthorizationReviewConfig,
64 ResourceAuthorizationRuntimeResult, WebAssessmentAuthorizationAudit,
65 MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS,
66};
67#[cfg(feature = "rest-review")]
68use super::rest_runtime::{
69 CommittedRestReview, RestRuntimeOutcome, RestRuntimeResult, WebAssessmentRestAudit,
70 MAX_REST_REVIEW_ACTIVE_VERIFICATIONS,
71};
72#[cfg(feature = "secret-exposure-review")]
73use super::secret_exposure::{
74 is_secret_exposure_response_lineage, secret_exposure_response_lineage_id,
75 secret_exposure_response_parent_ids, CommittedSecretExposureLedger, SecretExposureCollector,
76 WebAssessmentSecretExposureAudit, RESPONSE_BODY_LINEAGE, SECRET_EXPOSURE_NAMESPACE,
77};
78#[cfg(feature = "ssrf-oast-review")]
79use super::ssrf_oast_runtime::{
80 CommittedSsrfOastReview, SsrfOastReviewConfig, SsrfOastRuntimeResult,
81 WebAssessmentSsrfOastAudit, MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS,
82};
83#[cfg(feature = "supplied-session-review")]
84use super::supplied_session_runtime::{
85 SuppliedSessionCommittedResourceObserver, SuppliedSessionRuntimeConfig,
86 WebAssessmentSuppliedSessionAudit,
87};
88#[cfg(feature = "tls-negotiation-review")]
89use super::tls_negotiation_review::{
90 tls_negotiation_review_target_is_supported, WebAssessmentTlsNegotiationReviewAudit,
91 TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE,
92};
93#[cfg(feature = "tls-observation")]
94use super::tls_observation::WebAssessmentTlsObservationAudit;
95#[cfg(feature = "template-evaluation-review")]
96use super::web_review_execution::enabled_native_web_review_actions_with_template;
97#[cfg(feature = "websocket-review")]
98use super::websocket_runtime::{
99 execute_websocket_review, websocket_review_not_run, WebAssessmentWebSocketReviewAudit,
100 WebSocketReviewFailureKind,
101};
102#[cfg(feature = "wordpress-review")]
103use super::wordpress_fingerprint_runtime::{
104 WordPressAssetFingerprintCollector, WordPressAssetFingerprintStop,
105};
106#[cfg(feature = "wordpress-review")]
107use super::wordpress_runtime::{
108 extract_wordpress_entry_observation_with_asset_fingerprints,
109 extract_wordpress_page_observation_with_asset_fingerprints, CommittedWordPressReview,
110 FrozenWordPressPageLayout, WebAssessmentWordPressAudit, WordPressDiscoveryContext,
111 WordPressDiscoveryStop, WordPressPageCandidate, WordPressReviewBinding,
112 WordPressSignalCollector,
113};
114#[cfg(feature = "xml-external-entity-review")]
115use super::xml_external_entity_runtime::{
116 CommittedXmlExternalEntityReview, XmlExternalEntityRuntimeConfig,
117 XmlExternalEntityRuntimeResult,
118};
119use super::{
120 assessment_api_visibility::{
121 build_root_api_visibility_runtime, CommittedAssessmentApiVisibility,
122 RootApiVisibilityOutcome, RootApiVisibilityRuntime, WebAssessmentRootAuthorizationContext,
123 },
124 assessment_defense::{
125 AssessmentDefenseBodyCoverage, AssessmentDefenseController, ASSESSMENT_DEFENSE_NAMESPACE,
126 },
127 assessment_item::{AssessmentItem, AssessmentItemSet},
128 assessment_passive::{
129 project_assessment_items, project_assessment_passive_response,
130 AssessmentPassiveProjectionContext, AssessmentReviewProjectionSources,
131 CommittedAssessmentPassiveLedger, PassiveAssessmentProjectionIncompleteness,
132 ASSESSMENT_PASSIVE_NAMESPACE,
133 },
134 assessment_review::{AssessmentReviewObserverSet, CommittedAssessmentReviewLedger},
135 web_review_execution::enabled_native_web_review_actions,
136 NativeWebReviewSeeds, SharedWebRuntimeAuthority, StandardWebDecisionAssessmentFailureParts,
137 StandardWebDecisionAssessmentParts, BOOTSTRAP_ACTION_ID, BOOTSTRAP_CASE_ID,
138 BOOTSTRAP_HYPOTHESIS_ID,
139};
140#[cfg(feature = "reporting")]
141use super::{
142 assessment_report::{
143 AssessmentRunReport, AssessmentRunReportError, AssessmentRuntimeLimits,
144 CompletedWebAssessmentTruth,
145 },
146 scan_profile::ScanProfileV1,
147};
148#[cfg(feature = "xml-external-entity-owned-https-test-profile")]
149use crate::http_evidence::OwnedXmlHttpsTestTransportProfile;
150#[cfg(feature = "jwt-target-acceptance-review")]
151use crate::jwt_target_acceptance::{
152 JwtTargetAcceptanceAudit, JwtTargetAcceptancePolicy, MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS,
153};
154#[cfg(feature = "recon-ct-provider")]
155use crate::recon_ct_provider::ReconCtProviderPolicy;
156#[cfg(feature = "ssrf-oast-review")]
157use crate::ssrf_oast_review::{
158 select_observed_query_candidate, SsrfOastAdminToken, SsrfOastReviewPolicy,
159 SsrfOastTerminalState,
160};
161#[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
162use crate::supplied_session_review::SuppliedSessionCredentialMechanism;
163#[cfg(any(
164 all(feature = "wordpress-review", feature = "supplied-session-review"),
165 all(feature = "websocket-review", feature = "supplied-session-review")
166))]
167use crate::supplied_session_review::SuppliedSessionPolicyVersion;
168#[cfg(feature = "supplied-session-review")]
169use crate::supplied_session_review::{SuppliedSessionPolicy, SuppliedSessionRuntimeInput};
170#[cfg(feature = "template-evaluation-review")]
171use crate::template_evaluation_review::{
172 TemplateEvaluationReviewAudit, TemplateEvaluationReviewAuditFacts,
173 TemplateEvaluationReviewOutcome, TEMPLATE_EVALUATION_MAX_REQUESTS,
174 TEMPLATE_EVALUATION_SELECTED_CASES,
175};
176#[cfg(feature = "websocket-review")]
177use crate::websocket_review::WebSocketReviewPolicy;
178#[cfg(feature = "wordpress-review")]
179use crate::wordpress_review::WordPressReviewInputs;
180#[cfg(feature = "xml-external-entity-review")]
181use crate::xml_external_entity_review::{
182 XmlExternalEntityAdminToken, XmlExternalEntityReviewAudit, XmlExternalEntityReviewOutcome,
183 XmlExternalEntityReviewPolicy, XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS,
184};
185#[cfg(feature = "template-evaluation-review")]
186use crate::TransportDispatchOutcome;
187use crate::{
188 http_evidence::{
189 CompleteHttpResponseObservation, CompleteHttpResponseObserver,
190 ResponseBodyDigestDisposition,
191 },
192 web_actions::NativeWebReviewActionKind,
193 AttackPlan, DecisionEvidenceReceipt, DecisionExecutionFailureReceipt, DecisionExecutionStage,
194 DecisionLoopCommand, DecisionStopReason, DefensePosture, DefenseProduct, FingerprintConfidence,
195 HttpEvidenceError, HttpEvidencePolicy, HttpProbe, HttpProbeMethod, KnowledgeBase, LimitsError,
196 RuntimeApiVisibilityExecutionError, RuntimeBudget, RuntimeBudgetDimension,
197 RuntimeLimitExceeded, SemanticExtractionLimits, SemanticExtractionResult, ShadowPlanDelta,
198 StandardWebActionKind, StandardWebDecisionRuntime, StandardWebDecisionRuntimeError,
199 StandardWebDecisionRuntimeTurn, TransportDispatchAudit, HTTP_EVIDENCE_EXECUTOR_ID,
200 MAX_HTTP_BODY_LIMIT,
201};
202
203mod attribute_boundary_matcher;
204mod attribute_source_context;
205mod discovery;
206mod javascript_source_context;
207#[cfg(feature = "normalization-resilience")]
208mod normalization_transform_catalog;
209mod reflection_context;
210mod semantic;
211mod xss_probe_catalog;
212
213#[inline(never)]
217fn boxed_child_analysis(
218 runtime: &mut StandardWebDecisionRuntime,
219) -> std::pin::Pin<
220 Box<
221 impl std::future::Future<
222 Output = Result<
223 crate::StandardWebDecisionRunReport,
224 StandardWebDecisionRuntimeError,
225 >,
226 > + '_,
227 >,
228> {
229 Box::pin(runtime.analyze())
230}
231
232#[inline(never)]
236fn boxed_optional_child<F: std::future::Future>(make: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
237 Box::pin(make())
238}
239
240#[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
241#[inline(never)]
242fn boxed_session_websocket<'a>(
243 session: SuppliedSessionRuntimeConfig,
244 authority: &'a SharedWebRuntimeAuthority,
245 websocket: &'a WebSocketReviewPolicy,
246) -> std::pin::Pin<
247 Box<
248 impl std::future::Future<
249 Output = (
250 WebAssessmentSuppliedSessionAudit,
251 WebAssessmentWebSocketReviewAudit,
252 ),
253 > + 'a,
254 >,
255> {
256 Box::pin(session.execute_with_websocket(authority, websocket))
260}
261
262#[cfg(feature = "supplied-session-review")]
263#[inline(never)]
264fn boxed_session_review<'a>(
265 session: SuppliedSessionRuntimeConfig,
266 authority: &'a SharedWebRuntimeAuthority,
267 observer: Option<&'a dyn SuppliedSessionCommittedResourceObserver>,
268) -> std::pin::Pin<Box<impl std::future::Future<Output = WebAssessmentSuppliedSessionAudit> + 'a>> {
269 Box::pin(session.execute(authority, observer))
270}
271
272#[cfg(feature = "websocket-review")]
273#[inline(never)]
274fn boxed_websocket_review<'a>(
275 authority: &'a SharedWebRuntimeAuthority,
276 policy: &'a WebSocketReviewPolicy,
277) -> std::pin::Pin<Box<impl std::future::Future<Output = WebAssessmentWebSocketReviewAudit> + 'a>> {
278 Box::pin(execute_websocket_review(authority, policy))
279}
280
281pub(super) use attribute_boundary_matcher::{
282 match_exact_xss_attribute_boundary_document, ExactXssAttributeBoundaryMatch,
283};
284pub(super) use attribute_source_context::{
285 cross_validate_attribute_reflection_source, AttributeQuoteMode, AttributeSourceResult,
286};
287use discovery::{canonicalize_root, parse_document, ParsedDocument, ParsedForm, ParsedRoute};
288pub(super) use javascript_source_context::{
289 cross_validate_javascript_reflection_source, match_exact_xss_javascript_boundary_document,
290 validate_exact_xss_javascript_boundary_candidate, ExactJavaScriptBoundaryMatch,
291 JavaScriptScriptKind, JavaScriptSourceResult,
292};
293#[cfg(feature = "normalization-resilience")]
294pub(super) use normalization_transform_catalog::{
295 select_normalization_transform, NormalizationTransformRef, NormalizationTransformSelection,
296};
297#[cfg(all(feature = "normalization-resilience", test))]
298pub(super) use normalization_transform_catalog::{
299 NORMALIZATION_V1_MAX_CHILD_ACTIVE_VERIFICATIONS, NORMALIZATION_V1_MAX_CHILD_REQUESTS,
300};
301pub(super) use reflection_context::{
302 classify_exact_html_reflection, match_exact_xss_html_boundary_document,
303 validate_exact_xss_html_boundary_fragment, ExactHtmlReflectionContext, ExactXssBoundaryMatch,
304};
305use semantic::{assessment_semantic_limits, AssessmentSemanticEvidence};
306pub(super) use xss_probe_catalog::{select_xss_probe_families, XssProbeFamily, XssProbeSelection};
307
308pub const DEFAULT_WEB_ASSESSMENT_MAX_SUBJECTS: usize = 64;
310pub const HARD_MAX_WEB_ASSESSMENT_SUBJECTS: usize = 1_024;
312pub const DEFAULT_WEB_ASSESSMENT_MAX_DEPTH: u16 = 2;
314pub const HARD_MAX_WEB_ASSESSMENT_DEPTH: u16 = 16;
316pub const DEFAULT_WEB_ASSESSMENT_MAX_REFERENCES_PER_DOCUMENT: usize = 128;
318pub const HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT: usize = 2_048;
320pub const DEFAULT_WEB_ASSESSMENT_MAX_CANONICAL_URL_BYTES: usize = 8_192;
322pub const HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES: usize = 8_192;
324pub const DEFAULT_WEB_ASSESSMENT_MAX_RETAINED_URL_BYTES: usize = 512 * 1024;
326pub const HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES: usize = 8 * 1024 * 1024;
328pub const DEFAULT_WEB_ASSESSMENT_MAX_FORMS: usize = 64;
330pub const HARD_MAX_WEB_ASSESSMENT_FORMS: usize = 1_024;
332pub const DEFAULT_WEB_ASSESSMENT_MAX_CONTROLS_PER_FORM: usize = 64;
334pub const HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM: usize = 256;
336pub const DEFAULT_WEB_ASSESSMENT_MAX_QUERY_NAMES: usize = 64;
338pub const HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES: usize = 256;
340pub const DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_REQUESTS: u32 = 256;
342pub const HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS: u32 = 10_000;
344pub const DEFAULT_WEB_ASSESSMENT_MAX_RESPONSE_BODY_BYTES: usize = 256 * 1024;
346pub const HARD_MAX_WEB_ASSESSMENT_RESPONSE_BODY_BYTES: usize = MAX_HTTP_BODY_LIMIT;
348pub const DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
350pub const HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
352pub const DEFAULT_WEB_ASSESSMENT_MAX_WALL_TIME: Duration = Duration::from_secs(300);
354pub const HARD_MAX_WEB_ASSESSMENT_WALL_TIME: Duration = Duration::from_secs(3_600);
356pub const DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS: u16 = 10;
361pub const HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS: u16 = 64;
363#[cfg(feature = "graphql-review")]
365pub(crate) const GRAPHQL_REVIEW_ACTIVE_VERIFICATION_ALLOWANCE: u16 =
366 MAX_GRAPHQL_ACTIVE_VERIFICATIONS as u16;
367#[cfg(feature = "authorization-review")]
369pub(crate) const AUTHORIZATION_REVIEW_ACTIVE_VERIFICATION_ALLOWANCE: u16 =
370 MAX_AUTHORIZATION_REVIEW_ACTIVE_VERIFICATIONS as u16;
371#[cfg(feature = "template-evaluation-review")]
373pub(crate) const TEMPLATE_EVALUATION_ACTIVE_VERIFICATION_ALLOWANCE: u16 = 2;
374pub const WEB_ASSESSMENT_CONCURRENCY: usize = 1;
376
377const REDIRECT_REVIEW_QUERY_PARAMETER_ALLOWLIST: [&str; 11] = [
381 "continue",
382 "dest",
383 "destination",
384 "next",
385 "redirect",
386 "redirect_to",
387 "redirect_uri",
388 "return",
389 "return_to",
390 "return_url",
391 "url",
392];
393
394fn select_redirect_review_query_parameter(names: &[String]) -> Option<String> {
395 names
396 .iter()
397 .find(|name| {
398 REDIRECT_REVIEW_QUERY_PARAMETER_ALLOWLIST
399 .binary_search(&name.as_str())
400 .is_ok()
401 })
402 .cloned()
403}
404
405fn select_sql_review_query_parameter(names: &[String]) -> Option<String> {
406 names
407 .iter()
408 .filter(|name| {
409 !name.is_empty()
410 && name.len() <= 64
411 && name.bytes().all(|byte| {
412 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
413 })
414 })
415 .min()
416 .cloned()
417}
418
419fn select_ssti_review_query_parameter(names: &[String]) -> Option<String> {
420 select_sql_review_query_parameter(names)
421}
422
423fn select_reflection_review_query_parameter(names: &[String]) -> Option<String> {
424 select_sql_review_query_parameter(names)
425}
426
427pub(crate) const HARD_MAX_DISCOVERY_NAME_BYTES: usize =
428 SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAME_BYTES;
429pub(crate) const HARD_MAX_DISCOVERY_NAMES_PER_REFERENCE: usize =
430 SemanticExtractionLimits::HARD_MAX_ASSESSMENT_PARAMETER_NAMES_PER_REFERENCE;
431
432#[derive(Debug, Clone, PartialEq, Eq, Error)]
434#[non_exhaustive]
435pub enum WebAssessmentLimitsError {
436 #[error("web assessment limit {dimension} must be greater than zero")]
438 ZeroRequired { dimension: &'static str },
439 #[error("web assessment limit {dimension}={actual} exceeds compiled maximum {maximum}")]
441 AboveHardMaximum {
442 dimension: &'static str,
443 actual: u64,
444 maximum: u64,
445 },
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub struct WebAssessmentLimits {
451 max_subjects: usize,
452 max_discovery_depth: u16,
453 max_references_per_document: usize,
454 max_canonical_url_bytes: usize,
455 max_retained_url_bytes: usize,
456 max_forms: usize,
457 max_controls_per_form: usize,
458 max_query_parameter_names: usize,
459 max_total_requests: u32,
460 max_response_body_bytes: usize,
461 max_total_response_bytes: u64,
462 max_wall_time: Duration,
463 max_active_verifications: u16,
464}
465
466impl WebAssessmentLimits {
467 pub fn with_max_subjects(mut self, value: usize) -> Result<Self, WebAssessmentLimitsError> {
469 check_nonzero("max_subjects", value)?;
470 check_max("max_subjects", value, HARD_MAX_WEB_ASSESSMENT_SUBJECTS)?;
471 self.max_subjects = value;
472 Ok(self)
473 }
474
475 pub fn with_max_discovery_depth(
477 mut self,
478 value: u16,
479 ) -> Result<Self, WebAssessmentLimitsError> {
480 check_max("max_discovery_depth", value, HARD_MAX_WEB_ASSESSMENT_DEPTH)?;
481 self.max_discovery_depth = value;
482 Ok(self)
483 }
484
485 pub fn with_max_references_per_document(
487 mut self,
488 value: usize,
489 ) -> Result<Self, WebAssessmentLimitsError> {
490 check_max(
491 "max_references_per_document",
492 value,
493 HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT,
494 )?;
495 self.max_references_per_document = value;
496 Ok(self)
497 }
498
499 pub fn with_max_canonical_url_bytes(
501 mut self,
502 value: usize,
503 ) -> Result<Self, WebAssessmentLimitsError> {
504 check_nonzero("max_canonical_url_bytes", value)?;
505 check_max(
506 "max_canonical_url_bytes",
507 value,
508 HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES,
509 )?;
510 self.max_canonical_url_bytes = value;
511 Ok(self)
512 }
513
514 pub fn with_max_retained_url_bytes(
516 mut self,
517 value: usize,
518 ) -> Result<Self, WebAssessmentLimitsError> {
519 check_nonzero("max_retained_url_bytes", value)?;
520 check_max(
521 "max_retained_url_bytes",
522 value,
523 HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES,
524 )?;
525 self.max_retained_url_bytes = value;
526 Ok(self)
527 }
528
529 pub fn with_max_forms(mut self, value: usize) -> Result<Self, WebAssessmentLimitsError> {
531 check_max("max_forms", value, HARD_MAX_WEB_ASSESSMENT_FORMS)?;
532 self.max_forms = value;
533 Ok(self)
534 }
535
536 pub fn with_max_controls_per_form(
538 mut self,
539 value: usize,
540 ) -> Result<Self, WebAssessmentLimitsError> {
541 check_max(
542 "max_controls_per_form",
543 value,
544 HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM,
545 )?;
546 self.max_controls_per_form = value;
547 Ok(self)
548 }
549
550 pub fn with_max_query_parameter_names(
552 mut self,
553 value: usize,
554 ) -> Result<Self, WebAssessmentLimitsError> {
555 check_max(
556 "max_query_parameter_names",
557 value,
558 HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES,
559 )?;
560 self.max_query_parameter_names = value;
561 Ok(self)
562 }
563
564 pub fn with_max_total_requests(mut self, value: u32) -> Result<Self, WebAssessmentLimitsError> {
566 check_max(
567 "max_total_requests",
568 value,
569 HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS,
570 )?;
571 self.max_total_requests = value;
572 Ok(self)
573 }
574
575 pub fn with_max_response_body_bytes(
577 mut self,
578 value: usize,
579 ) -> Result<Self, WebAssessmentLimitsError> {
580 check_nonzero("max_response_body_bytes", value)?;
581 check_max("max_response_body_bytes", value, MAX_HTTP_BODY_LIMIT)?;
582 self.max_response_body_bytes = value;
583 Ok(self)
584 }
585
586 pub fn with_max_total_response_bytes(
588 mut self,
589 value: u64,
590 ) -> Result<Self, WebAssessmentLimitsError> {
591 if value == 0 {
592 return Err(WebAssessmentLimitsError::ZeroRequired {
593 dimension: "max_total_response_bytes",
594 });
595 }
596 check_max(
597 "max_total_response_bytes",
598 value,
599 HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES,
600 )?;
601 self.max_total_response_bytes = value;
602 Ok(self)
603 }
604
605 pub fn with_max_wall_time(mut self, value: Duration) -> Result<Self, WebAssessmentLimitsError> {
607 if value > HARD_MAX_WEB_ASSESSMENT_WALL_TIME {
608 return Err(WebAssessmentLimitsError::AboveHardMaximum {
609 dimension: "max_wall_time_ms",
610 actual: duration_ms(value),
611 maximum: duration_ms(HARD_MAX_WEB_ASSESSMENT_WALL_TIME),
612 });
613 }
614 self.max_wall_time = value;
615 Ok(self)
616 }
617
618 pub fn with_max_active_verifications(
620 mut self,
621 value: u16,
622 ) -> Result<Self, WebAssessmentLimitsError> {
623 check_max(
624 "max_active_verifications",
625 value,
626 HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS,
627 )?;
628 self.max_active_verifications = value;
629 Ok(self)
630 }
631
632 pub const fn max_subjects(self) -> usize {
634 self.max_subjects
635 }
636 pub const fn max_discovery_depth(self) -> u16 {
638 self.max_discovery_depth
639 }
640 pub const fn max_references_per_document(self) -> usize {
642 self.max_references_per_document
643 }
644 pub const fn max_canonical_url_bytes(self) -> usize {
646 self.max_canonical_url_bytes
647 }
648 pub const fn max_retained_url_bytes(self) -> usize {
650 self.max_retained_url_bytes
651 }
652 pub const fn max_forms(self) -> usize {
654 self.max_forms
655 }
656 pub const fn max_controls_per_form(self) -> usize {
658 self.max_controls_per_form
659 }
660 pub const fn max_query_parameter_names(self) -> usize {
662 self.max_query_parameter_names
663 }
664 pub const fn max_total_requests(self) -> u32 {
666 self.max_total_requests
667 }
668 pub const fn max_response_body_bytes(self) -> usize {
670 self.max_response_body_bytes
671 }
672 pub const fn max_total_response_bytes(self) -> u64 {
674 self.max_total_response_bytes
675 }
676 pub const fn max_wall_time(self) -> Duration {
678 self.max_wall_time
679 }
680 pub const fn max_active_verifications(self) -> u16 {
682 self.max_active_verifications
683 }
684 pub const fn concurrency(self) -> usize {
686 WEB_ASSESSMENT_CONCURRENCY
687 }
688
689 fn runtime_budget(self, optional_active_verifications: u16) -> RuntimeBudget {
694 let active_verifications = self
695 .max_active_verifications
696 .checked_add(optional_active_verifications)
697 .expect("compiled optional active-verification allowances fit u16");
698 RuntimeBudget::default()
699 .with_max_total_requests(self.max_total_requests)
700 .with_max_response_bytes(self.max_total_response_bytes)
701 .with_max_wall_time(self.max_wall_time)
702 .with_max_active_verifications(active_verifications)
703 }
704}
705
706impl Default for WebAssessmentLimits {
707 fn default() -> Self {
708 Self {
709 max_subjects: DEFAULT_WEB_ASSESSMENT_MAX_SUBJECTS,
710 max_discovery_depth: DEFAULT_WEB_ASSESSMENT_MAX_DEPTH,
711 max_references_per_document: DEFAULT_WEB_ASSESSMENT_MAX_REFERENCES_PER_DOCUMENT,
712 max_canonical_url_bytes: DEFAULT_WEB_ASSESSMENT_MAX_CANONICAL_URL_BYTES,
713 max_retained_url_bytes: DEFAULT_WEB_ASSESSMENT_MAX_RETAINED_URL_BYTES,
714 max_forms: DEFAULT_WEB_ASSESSMENT_MAX_FORMS,
715 max_controls_per_form: DEFAULT_WEB_ASSESSMENT_MAX_CONTROLS_PER_FORM,
716 max_query_parameter_names: DEFAULT_WEB_ASSESSMENT_MAX_QUERY_NAMES,
717 max_total_requests: DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_REQUESTS,
718 max_response_body_bytes: DEFAULT_WEB_ASSESSMENT_MAX_RESPONSE_BODY_BYTES,
719 max_total_response_bytes: DEFAULT_WEB_ASSESSMENT_MAX_TOTAL_RESPONSE_BYTES,
720 max_wall_time: DEFAULT_WEB_ASSESSMENT_MAX_WALL_TIME,
721 max_active_verifications: DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
722 }
723 }
724}
725
726fn check_nonzero(dimension: &'static str, value: usize) -> Result<(), WebAssessmentLimitsError> {
727 if value == 0 {
728 Err(WebAssessmentLimitsError::ZeroRequired { dimension })
729 } else {
730 Ok(())
731 }
732}
733
734fn check_max<T>(
735 dimension: &'static str,
736 value: T,
737 maximum: T,
738) -> Result<(), WebAssessmentLimitsError>
739where
740 T: Copy + Ord + TryInto<u64>,
741{
742 if value > maximum {
743 Err(WebAssessmentLimitsError::AboveHardMaximum {
744 dimension,
745 actual: value.try_into().unwrap_or(u64::MAX),
746 maximum: maximum.try_into().unwrap_or(u64::MAX),
747 })
748 } else {
749 Ok(())
750 }
751}
752
753fn duration_ms(duration: Duration) -> u64 {
754 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
755}
756
757#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
759#[non_exhaustive]
760pub enum WebAssessmentMethod {
761 Get,
763 Head,
765}
766
767impl WebAssessmentMethod {
768 const fn probe_method(self) -> HttpProbeMethod {
769 match self {
770 Self::Get => HttpProbeMethod::Get,
771 Self::Head => HttpProbeMethod::Head,
772 }
773 }
774}
775
776#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
778#[non_exhaustive]
779pub enum WebAssessmentFormMethod {
780 Get,
782 Post,
784 Dialog,
786}
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
790#[non_exhaustive]
791pub enum WebAssessmentSubjectOrigin {
792 AuthorizedRoot,
794 Discovered,
796}
797
798#[derive(Clone, PartialEq, Eq)]
800pub struct WebAssessmentSubject {
801 url: Url,
802 method: WebAssessmentMethod,
803 depth: u16,
804 origin: WebAssessmentSubjectOrigin,
805 query_parameter_names: Vec<String>,
806 evidence_ids: Vec<EvidenceId>,
807}
808
809impl fmt::Debug for WebAssessmentSubject {
810 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
811 formatter
812 .debug_struct("WebAssessmentSubject")
813 .field("url", &"<redacted>")
814 .field("method", &self.method)
815 .field("depth", &self.depth)
816 .field("origin", &self.origin)
817 .field("query_parameter_count", &self.query_parameter_names.len())
818 .field("evidence_count", &self.evidence_ids.len())
819 .finish()
820 }
821}
822
823impl WebAssessmentSubject {
824 pub fn url(&self) -> &Url {
826 &self.url
827 }
828 pub const fn method(&self) -> WebAssessmentMethod {
830 self.method
831 }
832 pub const fn depth(&self) -> u16 {
834 self.depth
835 }
836 pub const fn origin(&self) -> WebAssessmentSubjectOrigin {
838 self.origin
839 }
840 pub fn query_parameter_names(&self) -> &[String] {
842 &self.query_parameter_names
843 }
844 pub fn evidence_ids(&self) -> &[EvidenceId] {
846 &self.evidence_ids
847 }
848}
849
850#[derive(Clone, PartialEq, Eq)]
852pub struct WebAssessmentForm {
853 document_url: Url,
854 action: Url,
855 method: WebAssessmentFormMethod,
856 query_parameter_names: Vec<String>,
857 control_names: Vec<String>,
858 evidence_ids: Vec<EvidenceId>,
859}
860
861impl fmt::Debug for WebAssessmentForm {
862 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
863 formatter
864 .debug_struct("WebAssessmentForm")
865 .field("document_url", &"<redacted>")
866 .field("action", &"<redacted>")
867 .field("method", &self.method)
868 .field("query_parameter_count", &self.query_parameter_names.len())
869 .field("control_count", &self.control_names.len())
870 .field("evidence_count", &self.evidence_ids.len())
871 .finish()
872 }
873}
874
875impl WebAssessmentForm {
876 pub fn document_url(&self) -> &Url {
878 &self.document_url
879 }
880 pub fn action(&self) -> &Url {
882 &self.action
883 }
884 pub const fn method(&self) -> WebAssessmentFormMethod {
886 self.method
887 }
888 pub fn query_parameter_names(&self) -> &[String] {
890 &self.query_parameter_names
891 }
892 pub fn control_names(&self) -> &[String] {
894 &self.control_names
895 }
896 pub fn evidence_ids(&self) -> &[EvidenceId] {
898 &self.evidence_ids
899 }
900}
901
902pub struct WebAssessmentSubjectReport {
904 subject: WebAssessmentSubject,
905 executed: bool,
906 bootstrap: Option<DecisionEvidenceReceipt>,
907 turns: Vec<StandardWebDecisionRuntimeTurn>,
908 unverified_evidence: Option<DecisionEvidenceReceipt>,
909 terminal: Option<DecisionLoopCommand>,
910 limit_exceeded: Option<RuntimeLimitExceeded>,
911 execution_failure: Option<DecisionExecutionFailureReceipt>,
912}
913
914impl fmt::Debug for WebAssessmentSubjectReport {
915 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
916 formatter
917 .debug_struct("WebAssessmentSubjectReport")
918 .field("subject", &self.subject)
919 .field("executed", &self.executed)
920 .field("bootstrap_committed", &self.bootstrap.is_some())
921 .field("turn_count", &self.turns.len())
922 .field(
923 "unverified_evidence_committed",
924 &self.unverified_evidence.is_some(),
925 )
926 .field("terminal_present", &self.terminal.is_some())
927 .field("limit_exceeded", &self.limit_exceeded.is_some())
928 .field("execution_failed", &self.execution_failure.is_some())
929 .finish()
930 }
931}
932
933impl WebAssessmentSubjectReport {
934 fn pending(subject: WebAssessmentSubject) -> Self {
935 Self {
936 subject,
937 executed: false,
938 bootstrap: None,
939 turns: Vec::new(),
940 unverified_evidence: None,
941 terminal: None,
942 limit_exceeded: None,
943 execution_failure: None,
944 }
945 }
946
947 fn complete(subject: WebAssessmentSubject, parts: StandardWebDecisionAssessmentParts) -> Self {
948 Self {
949 subject,
950 executed: true,
951 bootstrap: parts.bootstrap,
952 turns: parts.turns,
953 unverified_evidence: parts.unverified_evidence,
954 terminal: Some(parts.terminal),
955 limit_exceeded: parts.limit_exceeded,
956 execution_failure: parts.execution_failure,
957 }
958 }
959
960 fn failed(
961 subject: WebAssessmentSubject,
962 parts: StandardWebDecisionAssessmentFailureParts,
963 executed: bool,
964 ) -> Self {
965 Self {
966 subject,
967 executed,
968 bootstrap: parts.bootstrap,
969 turns: parts.turns,
970 unverified_evidence: None,
971 terminal: None,
972 limit_exceeded: None,
973 execution_failure: None,
974 }
975 }
976
977 pub fn subject(&self) -> &WebAssessmentSubject {
979 &self.subject
980 }
981 pub fn was_executed(&self) -> bool {
983 self.executed
984 }
985 pub fn bootstrap(&self) -> Option<&DecisionEvidenceReceipt> {
987 self.bootstrap.as_ref()
988 }
989 pub fn turns(&self) -> &[StandardWebDecisionRuntimeTurn] {
991 &self.turns
992 }
993 pub fn unverified_evidence(&self) -> Option<&DecisionEvidenceReceipt> {
995 self.unverified_evidence.as_ref()
996 }
997 pub fn terminal(&self) -> Option<&DecisionLoopCommand> {
999 self.terminal.as_ref()
1000 }
1001 pub fn limit_exceeded(&self) -> Option<&RuntimeLimitExceeded> {
1003 self.limit_exceeded.as_ref()
1004 }
1005 pub fn execution_failure(&self) -> Option<&DecisionExecutionFailureReceipt> {
1007 self.execution_failure.as_ref()
1008 }
1009}
1010
1011#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1013pub struct WebAssessmentUsage {
1014 retained_subjects: usize,
1015 executed_subjects: usize,
1016 retained_forms: usize,
1017 retained_unique_url_bytes: usize,
1018 total_requests: u32,
1019 active_verifications: u16,
1020 request_body_bytes: u64,
1021 response_bytes: u64,
1022 elapsed_ms: u64,
1023}
1024
1025#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1027#[non_exhaustive]
1028pub enum WebAssessmentProgressCheckpoint {
1029 AssessmentStarted,
1030 SubjectExecutionStarted,
1031 SubjectStateCommitted,
1032 InventoryReconciled,
1033 ProjectionStarted,
1034 FinishedSnapshot,
1035}
1036
1037#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1044pub struct WebAssessmentProgressSnapshot {
1045 checkpoint: WebAssessmentProgressCheckpoint,
1046 executed_subjects: usize,
1047 subjects_processed: usize,
1048 requests_accounted: u32,
1049 active_verifications_accounted: u16,
1050}
1051
1052impl WebAssessmentProgressSnapshot {
1053 pub const fn checkpoint(self) -> WebAssessmentProgressCheckpoint {
1055 self.checkpoint
1056 }
1057
1058 pub const fn executed_subjects(self) -> usize {
1060 self.executed_subjects
1061 }
1062
1063 pub const fn subjects_processed(self) -> usize {
1065 self.subjects_processed
1066 }
1067
1068 pub const fn requests_accounted(self) -> u32 {
1070 self.requests_accounted
1071 }
1072
1073 pub const fn active_verifications_accounted(self) -> u16 {
1076 self.active_verifications_accounted
1077 }
1078}
1079
1080pub struct WebAssessmentProgressObserver {
1086 receiver: watch::Receiver<Option<WebAssessmentProgressSnapshot>>,
1087}
1088
1089impl WebAssessmentProgressObserver {
1090 pub fn latest(&self) -> Option<WebAssessmentProgressSnapshot> {
1092 *self.receiver.borrow()
1093 }
1094}
1095
1096impl WebAssessmentUsage {
1097 pub const fn retained_subjects(self) -> usize {
1098 self.retained_subjects
1099 }
1100 pub const fn executed_subjects(self) -> usize {
1101 self.executed_subjects
1102 }
1103 pub const fn retained_forms(self) -> usize {
1104 self.retained_forms
1105 }
1106 pub const fn retained_unique_url_bytes(self) -> usize {
1108 self.retained_unique_url_bytes
1109 }
1110 pub const fn total_requests(self) -> u32 {
1111 self.total_requests
1112 }
1113 pub const fn active_verifications(self) -> u16 {
1114 self.active_verifications
1115 }
1116 pub const fn request_body_bytes(self) -> u64 {
1117 self.request_body_bytes
1118 }
1119 pub const fn response_bytes(self) -> u64 {
1120 self.response_bytes
1121 }
1122 pub const fn elapsed_ms(self) -> u64 {
1123 self.elapsed_ms
1124 }
1125}
1126
1127#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1129#[non_exhaustive]
1130pub enum WebAssessmentIncompleteReason {
1131 SubjectLimit,
1132 DiscoveryDepthLimit,
1133 DocumentReferenceLimit,
1134 CanonicalUrlBytesLimit,
1135 RetainedUrlBytesLimit,
1136 FormLimit,
1137 FormControlLimit,
1138 QueryParameterNameLimit,
1139 ResponseBodyIncomplete,
1140 PartialRepresentation,
1141 InvalidUtf8,
1142 TotalRequestLimit,
1143 ResponseBytesLimit,
1144 RequestBodyBytesLimit,
1145 WallTimeLimit,
1146 ActiveVerificationLimit,
1147 SameActionAttemptLimit,
1148 ConsecutiveNoProgressLimit,
1149 ActionCycleLimit,
1150 AdaptationLimit,
1151 HumanReviewRequired,
1152 SubjectExecutionIncomplete,
1153 HostCancellation,
1154 SemanticExtractionLimit,
1155 PassiveResponseProjectionLimit,
1156 AssessmentSubjectIdentityUnavailable,
1157 DifferentialReviewIncomplete,
1160 ApiVisibilityReviewIncomplete,
1163 #[cfg(feature = "graphql-review")]
1166 GraphqlReviewIncomplete,
1167 #[cfg(feature = "authorization-review")]
1170 AuthorizationReviewIncomplete,
1171 #[cfg(feature = "openapi-review")]
1173 OpenApiReviewIncomplete,
1174 #[cfg(feature = "rest-review")]
1177 RestReviewIncomplete,
1178 #[cfg(feature = "ssrf-oast-review")]
1181 SsrfOastReviewIncomplete,
1182 #[cfg(feature = "xml-external-entity-review")]
1185 XmlExternalEntityReviewIncomplete,
1186 #[cfg(feature = "tls-negotiation-review")]
1190 TlsNegotiationReviewIncomplete,
1191 #[cfg(feature = "wordpress-review")]
1194 WordPressReviewIncomplete,
1195}
1196
1197#[derive(Debug, Clone, PartialEq, Eq)]
1199#[non_exhaustive]
1200pub enum WebAssessmentCompletion {
1201 Complete,
1202 Incomplete {
1203 reasons: BTreeSet<WebAssessmentIncompleteReason>,
1204 },
1205}
1206
1207impl WebAssessmentCompletion {
1208 pub fn reasons(&self) -> BTreeSet<WebAssessmentIncompleteReason> {
1210 match self {
1211 Self::Complete => BTreeSet::new(),
1212 Self::Incomplete { reasons } => reasons.clone(),
1213 }
1214 }
1215}
1216
1217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1220pub enum WebAssessmentDefenseBodyCoverage {
1221 MetadataOnly,
1222 CompleteUtf8Prefix,
1223}
1224
1225#[derive(Clone, PartialEq, Eq)]
1228pub struct WebAssessmentDefenseObservation {
1229 subject: termivar_core::EntityId,
1230 case_id: String,
1231 stage: DecisionExecutionStage,
1232 status: u16,
1233 posture: Option<DefensePosture>,
1234 challenge_observed: bool,
1235 rate_limit_observed: bool,
1236 fingerprint_hint: Option<(DefenseProduct, FingerprintConfidence)>,
1237 body_coverage: WebAssessmentDefenseBodyCoverage,
1238 input_limit_reached: bool,
1239 evidence_ids: Vec<EvidenceId>,
1240}
1241
1242impl fmt::Debug for WebAssessmentDefenseObservation {
1243 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1244 formatter
1245 .debug_struct("WebAssessmentDefenseObservation")
1246 .field("subject", &"<redacted>")
1247 .field("case_id", &"<redacted>")
1248 .field("stage", &self.stage)
1249 .field("status", &self.status)
1250 .field("posture", &self.posture)
1251 .field("challenge_observed", &self.challenge_observed)
1252 .field("rate_limit_observed", &self.rate_limit_observed)
1253 .field("fingerprint_hint", &self.fingerprint_hint)
1254 .field("body_coverage", &self.body_coverage)
1255 .field("input_limit_reached", &self.input_limit_reached)
1256 .field("evidence_count", &self.evidence_ids.len())
1257 .finish()
1258 }
1259}
1260
1261impl WebAssessmentDefenseObservation {
1262 pub fn subject(&self) -> &termivar_core::EntityId {
1263 &self.subject
1264 }
1265 pub fn case_id(&self) -> &str {
1266 &self.case_id
1267 }
1268 pub const fn stage(&self) -> DecisionExecutionStage {
1269 self.stage
1270 }
1271 pub const fn status(&self) -> u16 {
1272 self.status
1273 }
1274 pub const fn posture(&self) -> Option<DefensePosture> {
1279 self.posture
1280 }
1281 pub const fn challenge_observed(&self) -> bool {
1282 self.challenge_observed
1283 }
1284 pub const fn rate_limit_observed(&self) -> bool {
1285 self.rate_limit_observed
1286 }
1287 pub const fn fingerprint_hint(&self) -> Option<(DefenseProduct, FingerprintConfidence)> {
1288 self.fingerprint_hint
1289 }
1290 pub const fn body_coverage(&self) -> WebAssessmentDefenseBodyCoverage {
1291 self.body_coverage
1292 }
1293 pub const fn input_limit_reached(&self) -> bool {
1294 self.input_limit_reached
1295 }
1296 pub fn evidence_ids(&self) -> &[EvidenceId] {
1297 &self.evidence_ids
1298 }
1299}
1300
1301#[derive(Clone, PartialEq, Eq)]
1303pub struct WebAssessmentDefenseTransition {
1304 case_id: String,
1305 candidate_block_status_appeared: bool,
1306 newly_rate_limited: bool,
1307 candidate_fingerprint_hint: Option<(DefenseProduct, FingerprintConfidence)>,
1308 control_evidence_ids: Vec<EvidenceId>,
1309 candidate_evidence_ids: Vec<EvidenceId>,
1310}
1311
1312impl fmt::Debug for WebAssessmentDefenseTransition {
1313 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1314 formatter
1315 .debug_struct("WebAssessmentDefenseTransition")
1316 .field("case_id", &"<redacted>")
1317 .field(
1318 "candidate_block_status_appeared",
1319 &self.candidate_block_status_appeared,
1320 )
1321 .field("newly_rate_limited", &self.newly_rate_limited)
1322 .field(
1323 "candidate_fingerprint_hint",
1324 &self.candidate_fingerprint_hint,
1325 )
1326 .field("control_evidence_count", &self.control_evidence_ids.len())
1327 .field(
1328 "candidate_evidence_count",
1329 &self.candidate_evidence_ids.len(),
1330 )
1331 .finish()
1332 }
1333}
1334
1335impl WebAssessmentDefenseTransition {
1336 pub fn case_id(&self) -> &str {
1337 &self.case_id
1338 }
1339 pub const fn candidate_block_status_appeared(&self) -> bool {
1340 self.candidate_block_status_appeared
1341 }
1342 pub const fn newly_rate_limited(&self) -> bool {
1343 self.newly_rate_limited
1344 }
1345 pub const fn candidate_fingerprint_hint(
1346 &self,
1347 ) -> Option<(DefenseProduct, FingerprintConfidence)> {
1348 self.candidate_fingerprint_hint
1349 }
1350 pub fn control_evidence_ids(&self) -> &[EvidenceId] {
1351 &self.control_evidence_ids
1352 }
1353 pub fn candidate_evidence_ids(&self) -> &[EvidenceId] {
1354 &self.candidate_evidence_ids
1355 }
1356}
1357
1358#[derive(Clone)]
1360pub struct WebAssessmentDefenseShadowPlan {
1361 policy_authorized: AttackPlan,
1362 shadow: AttackPlan,
1363 delta: ShadowPlanDelta,
1364}
1365
1366impl fmt::Debug for WebAssessmentDefenseShadowPlan {
1367 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1368 formatter
1369 .debug_struct("WebAssessmentDefenseShadowPlan")
1370 .field("policy_authorized", &"<redacted-plan>")
1371 .field("shadow", &"<redacted-plan>")
1372 .field("delta", &"<redacted-delta>")
1373 .finish()
1374 }
1375}
1376
1377impl WebAssessmentDefenseShadowPlan {
1378 pub fn policy_authorized(&self) -> &AttackPlan {
1379 &self.policy_authorized
1380 }
1381 pub fn shadow(&self) -> &AttackPlan {
1382 &self.shadow
1383 }
1384 pub fn delta(&self) -> &ShadowPlanDelta {
1385 &self.delta
1386 }
1387}
1388
1389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1391pub enum WebAssessmentDefenseMode {
1392 ObservationOnly,
1393 Enforced,
1394}
1395
1396#[derive(Clone)]
1398pub struct WebAssessmentDefenseAudit {
1399 mode: WebAssessmentDefenseMode,
1400 observations: Vec<WebAssessmentDefenseObservation>,
1401 transitions: Vec<WebAssessmentDefenseTransition>,
1402 shadow_plans: Vec<WebAssessmentDefenseShadowPlan>,
1403}
1404
1405impl fmt::Debug for WebAssessmentDefenseAudit {
1406 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1407 formatter
1408 .debug_struct("WebAssessmentDefenseAudit")
1409 .field("mode", &self.mode)
1410 .field("observation_count", &self.observations.len())
1411 .field("transition_count", &self.transitions.len())
1412 .field("shadow_plan_count", &self.shadow_plans.len())
1413 .finish()
1414 }
1415}
1416
1417impl WebAssessmentDefenseAudit {
1418 fn new(mode: WebAssessmentDefenseMode) -> Self {
1419 Self {
1420 mode,
1421 observations: Vec::new(),
1422 transitions: Vec::new(),
1423 shadow_plans: Vec::new(),
1424 }
1425 }
1426
1427 pub const fn mode(&self) -> WebAssessmentDefenseMode {
1428 self.mode
1429 }
1430 pub fn observations(&self) -> &[WebAssessmentDefenseObservation] {
1431 &self.observations
1432 }
1433 pub fn transitions(&self) -> &[WebAssessmentDefenseTransition] {
1434 &self.transitions
1435 }
1436 pub fn shadow_plans(&self) -> &[WebAssessmentDefenseShadowPlan] {
1437 &self.shadow_plans
1438 }
1439
1440 fn append_controller(&mut self, controller: &AssessmentDefenseController) -> Result<(), ()> {
1441 let enforced = self.mode == WebAssessmentDefenseMode::Enforced;
1442 if controller.enforcement_enabled() != enforced {
1443 return Err(());
1444 }
1445 self.observations.extend(
1446 controller
1447 .ledger()
1448 .observations()
1449 .iter()
1450 .map(|observation| {
1451 let hint = observation
1452 .state()
1453 .fingerprint()
1454 .map(|hint| (hint.product(), hint.confidence()));
1455 WebAssessmentDefenseObservation {
1456 subject: observation.case().subject().clone(),
1457 case_id: observation.case().id().to_owned(),
1458 stage: observation.stage(),
1459 status: observation.state().status(),
1460 posture: if observation.state().posture() == DefensePosture::Open
1461 && (observation.body_coverage()
1462 != AssessmentDefenseBodyCoverage::CompleteUtf8Prefix
1463 || observation.input_limit_reached())
1464 {
1465 None
1466 } else {
1467 Some(observation.state().posture())
1468 },
1469 challenge_observed: observation.state().is_challenged(),
1470 rate_limit_observed: observation.state().is_rate_limited(),
1471 fingerprint_hint: hint,
1472 body_coverage: match observation.body_coverage() {
1473 AssessmentDefenseBodyCoverage::MetadataOnly => {
1474 WebAssessmentDefenseBodyCoverage::MetadataOnly
1475 },
1476 AssessmentDefenseBodyCoverage::CompleteUtf8Prefix => {
1477 WebAssessmentDefenseBodyCoverage::CompleteUtf8Prefix
1478 },
1479 },
1480 input_limit_reached: observation.input_limit_reached(),
1481 evidence_ids: observation.evidence_ids().to_vec(),
1482 }
1483 }),
1484 );
1485 self.transitions
1486 .extend(controller.ledger().transitions().iter().map(|transition| {
1487 let hint = transition
1488 .candidate_fingerprint_hint()
1489 .map(|hint| (hint.product(), hint.confidence()));
1490 WebAssessmentDefenseTransition {
1491 case_id: transition.case().id().to_owned(),
1492 candidate_block_status_appeared: transition.candidate_block_status_appeared(),
1493 newly_rate_limited: transition.newly_rate_limited(),
1494 candidate_fingerprint_hint: hint,
1495 control_evidence_ids: transition.control_evidence_ids().to_vec(),
1496 candidate_evidence_ids: transition.candidate_evidence_ids().to_vec(),
1497 }
1498 }));
1499 self.shadow_plans
1500 .extend(
1501 controller
1502 .shadows()
1503 .iter()
1504 .map(|shadow| WebAssessmentDefenseShadowPlan {
1505 policy_authorized: shadow.current().clone(),
1506 shadow: shadow.shadow().clone(),
1507 delta: shadow.delta().clone(),
1508 }),
1509 );
1510 Ok(())
1511 }
1512}
1513
1514pub struct WebAssessmentRunReport {
1516 #[cfg(feature = "reporting")]
1517 run_started_at: SystemTime,
1518 authorized_root: WebAssessmentSubject,
1519 limits: WebAssessmentLimits,
1520 runtime_active_verification_limit: u16,
1521 runtime_optional_active_verification_allowance: u16,
1522 subjects: Vec<WebAssessmentSubjectReport>,
1523 forms: Vec<WebAssessmentForm>,
1524 semantics: SemanticExtractionResult,
1525 defense: WebAssessmentDefenseAudit,
1526 #[cfg(feature = "supplied-session-review")]
1527 supplied_session: Option<WebAssessmentSuppliedSessionAudit>,
1528 #[cfg(feature = "websocket-review")]
1529 websocket_review: Option<WebAssessmentWebSocketReviewAudit>,
1530 #[cfg(feature = "jwt-target-acceptance-review")]
1531 jwt_target_acceptance: Option<JwtTargetAcceptanceAudit>,
1532 #[cfg(feature = "xml-external-entity-review")]
1533 xml_external_entity_review: Option<XmlExternalEntityReviewAudit>,
1534 #[cfg(feature = "template-evaluation-review")]
1535 template_evaluation_review: Option<TemplateEvaluationReviewAudit>,
1536 #[cfg(feature = "authorization-review")]
1537 authorization_review: Option<WebAssessmentAuthorizationAudit>,
1538 #[cfg(feature = "openapi-review")]
1539 openapi_review: Option<WebAssessmentOpenApiAudit>,
1540 #[cfg(feature = "rest-review")]
1541 rest_review: Option<WebAssessmentRestAudit>,
1542 #[cfg(feature = "ssrf-oast-review")]
1543 ssrf_oast_review: Option<WebAssessmentSsrfOastAudit>,
1544 #[cfg(feature = "wordpress-review")]
1545 wordpress_review: Option<WebAssessmentWordPressAudit>,
1546 #[cfg(feature = "secret-exposure-review")]
1547 secret_exposure_review: Option<WebAssessmentSecretExposureAudit>,
1548 #[cfg(feature = "tls-observation")]
1549 tls_observation: Option<WebAssessmentTlsObservationAudit>,
1550 #[cfg(feature = "tls-negotiation-review")]
1551 tls_negotiation_review: Option<WebAssessmentTlsNegotiationReviewAudit>,
1552 #[cfg(feature = "recon-ct-provider")]
1553 recon_ct_provider: Option<WebAssessmentReconCtProviderAudit>,
1554 assessment_items: AssessmentItemSet,
1555 assessment_projection_incompleteness: PassiveAssessmentProjectionIncompleteness,
1556 completion: WebAssessmentCompletion,
1557 usage: WebAssessmentUsage,
1558 transport: TransportDispatchAudit,
1559}
1560
1561impl fmt::Debug for WebAssessmentRunReport {
1562 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1563 let mut debug = formatter.debug_struct("WebAssessmentRunReport");
1564 #[cfg(feature = "reporting")]
1565 debug.field("run_started_at", &"<runtime-owned>");
1566 debug
1567 .field("authorized_root", &"<redacted>")
1568 .field("limits", &self.limits)
1569 .field(
1570 "runtime_active_verification_limit",
1571 &self.runtime_active_verification_limit,
1572 )
1573 .field("subject_count", &self.subjects.len())
1574 .field("form_count", &self.forms.len())
1575 .field("semantics", &"<redacted>")
1576 .field("defense", &self.defense);
1577 #[cfg(feature = "authorization-review")]
1578 debug.field("authorization_review", &self.authorization_review);
1579 #[cfg(feature = "supplied-session-review")]
1580 debug.field("supplied_session", &self.supplied_session);
1581 #[cfg(feature = "websocket-review")]
1582 debug.field(
1583 "websocket_review",
1584 &self.websocket_review.as_ref().map(|_| "<redacted-audit>"),
1585 );
1586 #[cfg(feature = "jwt-target-acceptance-review")]
1587 debug.field("jwt_target_acceptance", &self.jwt_target_acceptance);
1588 #[cfg(feature = "xml-external-entity-review")]
1589 debug.field(
1590 "xml_external_entity_review",
1591 &self.xml_external_entity_review,
1592 );
1593 #[cfg(feature = "template-evaluation-review")]
1594 debug.field(
1595 "template_evaluation_review",
1596 &self.template_evaluation_review,
1597 );
1598 #[cfg(feature = "openapi-review")]
1599 debug.field("openapi_review", &self.openapi_review);
1600 #[cfg(feature = "rest-review")]
1601 debug.field("rest_review", &self.rest_review);
1602 #[cfg(feature = "ssrf-oast-review")]
1603 debug.field("ssrf_oast_review", &self.ssrf_oast_review);
1604 #[cfg(feature = "wordpress-review")]
1605 debug.field("wordpress_review", &self.wordpress_review);
1606 #[cfg(feature = "secret-exposure-review")]
1607 debug.field("secret_exposure_review", &self.secret_exposure_review);
1608 #[cfg(feature = "tls-observation")]
1609 debug.field("tls_observation", &self.tls_observation);
1610 #[cfg(feature = "tls-negotiation-review")]
1611 debug.field("tls_negotiation_review", &self.tls_negotiation_review);
1612 #[cfg(feature = "recon-ct-provider")]
1613 debug.field(
1614 "recon_ct_provider",
1615 &self.recon_ct_provider.as_ref().map(|_| "<redacted-audit>"),
1616 );
1617 debug
1618 .field("assessment_items", &self.assessment_items)
1619 .field(
1620 "unprojected_subject_count",
1621 &self
1622 .assessment_projection_incompleteness
1623 .non_root_observations(),
1624 )
1625 .field(
1626 "unprojected_condition_count",
1627 &self
1628 .assessment_projection_incompleteness
1629 .non_root_conditions(),
1630 )
1631 .field("completion", &self.completion)
1632 .field("usage", &self.usage)
1633 .field("transport", &"<redacted-audit>")
1634 .finish()
1635 }
1636}
1637
1638impl WebAssessmentRunReport {
1639 pub fn authorized_root(&self) -> &WebAssessmentSubject {
1644 &self.authorized_root
1645 }
1646 pub const fn limits(&self) -> WebAssessmentLimits {
1649 self.limits
1650 }
1651 pub const fn runtime_active_verification_limit(&self) -> u16 {
1653 self.runtime_active_verification_limit
1654 }
1655 pub fn subjects(&self) -> &[WebAssessmentSubjectReport] {
1656 &self.subjects
1657 }
1658 pub fn forms(&self) -> &[WebAssessmentForm] {
1659 &self.forms
1660 }
1661 pub fn semantics(&self) -> &SemanticExtractionResult {
1664 &self.semantics
1665 }
1666 pub fn defense(&self) -> &WebAssessmentDefenseAudit {
1667 &self.defense
1668 }
1669 #[cfg(feature = "supplied-session-review")]
1671 pub const fn supplied_session_audit(&self) -> Option<&WebAssessmentSuppliedSessionAudit> {
1672 self.supplied_session.as_ref()
1673 }
1674 #[cfg(feature = "authorization-review")]
1676 pub const fn authorization_review_audit(&self) -> Option<&WebAssessmentAuthorizationAudit> {
1677 self.authorization_review.as_ref()
1678 }
1679 #[cfg(feature = "websocket-review")]
1681 pub const fn websocket_review_audit(&self) -> Option<&WebAssessmentWebSocketReviewAudit> {
1682 self.websocket_review.as_ref()
1683 }
1684 #[cfg(feature = "jwt-target-acceptance-review")]
1686 pub const fn jwt_target_acceptance_audit(&self) -> Option<&JwtTargetAcceptanceAudit> {
1687 self.jwt_target_acceptance.as_ref()
1688 }
1689 #[cfg(feature = "xml-external-entity-review")]
1691 pub const fn xml_external_entity_review_audit(&self) -> Option<&XmlExternalEntityReviewAudit> {
1692 self.xml_external_entity_review.as_ref()
1693 }
1694 #[cfg(feature = "template-evaluation-review")]
1696 pub const fn template_evaluation_review_audit(&self) -> Option<&TemplateEvaluationReviewAudit> {
1697 self.template_evaluation_review.as_ref()
1698 }
1699 #[cfg(feature = "openapi-review")]
1700 pub const fn openapi_review_audit(&self) -> Option<&WebAssessmentOpenApiAudit> {
1701 self.openapi_review.as_ref()
1702 }
1703 #[cfg(feature = "rest-review")]
1704 pub const fn rest_review_audit(&self) -> Option<&WebAssessmentRestAudit> {
1705 self.rest_review.as_ref()
1706 }
1707 #[cfg(feature = "ssrf-oast-review")]
1708 pub const fn ssrf_oast_review_audit(&self) -> Option<&WebAssessmentSsrfOastAudit> {
1709 self.ssrf_oast_review.as_ref()
1710 }
1711 #[cfg(feature = "wordpress-review")]
1713 pub const fn wordpress_review_audit(&self) -> Option<&WebAssessmentWordPressAudit> {
1714 self.wordpress_review.as_ref()
1715 }
1716 #[cfg(feature = "secret-exposure-review")]
1719 pub const fn secret_exposure_review_audit(&self) -> Option<&WebAssessmentSecretExposureAudit> {
1720 self.secret_exposure_review.as_ref()
1721 }
1722 #[cfg(feature = "tls-observation")]
1724 pub const fn tls_observation_audit(&self) -> Option<&WebAssessmentTlsObservationAudit> {
1725 self.tls_observation.as_ref()
1726 }
1727 #[cfg(feature = "tls-negotiation-review")]
1729 pub const fn tls_negotiation_review_audit(
1730 &self,
1731 ) -> Option<&WebAssessmentTlsNegotiationReviewAudit> {
1732 self.tls_negotiation_review.as_ref()
1733 }
1734 #[cfg(feature = "recon-ct-provider")]
1736 pub const fn recon_ct_provider_audit(&self) -> Option<&WebAssessmentReconCtProviderAudit> {
1737 self.recon_ct_provider.as_ref()
1738 }
1739 pub fn assessment_items(&self) -> &[AssessmentItem] {
1741 self.assessment_items.items()
1742 }
1743 pub const fn unprojected_assessment_subjects(&self) -> u16 {
1746 self.assessment_projection_incompleteness
1747 .non_root_observations()
1748 }
1749 pub const fn unprojected_assessment_conditions(&self) -> u16 {
1751 self.assessment_projection_incompleteness
1752 .non_root_conditions()
1753 }
1754 pub fn completion(&self) -> &WebAssessmentCompletion {
1755 &self.completion
1756 }
1757 pub const fn usage(&self) -> WebAssessmentUsage {
1758 self.usage
1759 }
1760 pub fn transport(&self) -> &TransportDispatchAudit {
1761 &self.transport
1762 }
1763
1764 #[cfg(feature = "reporting")]
1772 pub(crate) fn into_assessment_report(
1773 self,
1774 profile: ScanProfileV1,
1775 ) -> Result<AssessmentRunReport, AssessmentRunReportError> {
1776 let truth = CompletedWebAssessmentTruth::new(
1777 self.run_started_at,
1778 &self.authorized_root,
1779 AssessmentRuntimeLimits::new(
1780 self.limits,
1781 self.runtime_active_verification_limit,
1782 self.runtime_optional_active_verification_allowance,
1783 ),
1784 self.usage,
1785 &self.completion,
1786 self.defense.mode(),
1787 profile,
1788 )?;
1789 AssessmentRunReport::from_completed_truth(
1790 self.assessment_items,
1791 truth,
1792 #[cfg(feature = "supplied-session-review")]
1793 self.supplied_session,
1794 #[cfg(feature = "authorization-review")]
1795 self.authorization_review,
1796 #[cfg(feature = "websocket-review")]
1797 self.websocket_review,
1798 #[cfg(feature = "jwt-target-acceptance-review")]
1799 self.jwt_target_acceptance,
1800 #[cfg(feature = "openapi-review")]
1801 self.openapi_review,
1802 #[cfg(feature = "rest-review")]
1803 self.rest_review,
1804 #[cfg(feature = "ssrf-oast-review")]
1805 self.ssrf_oast_review,
1806 #[cfg(feature = "xml-external-entity-review")]
1807 self.xml_external_entity_review,
1808 #[cfg(feature = "template-evaluation-review")]
1809 self.template_evaluation_review,
1810 #[cfg(feature = "wordpress-review")]
1811 self.wordpress_review,
1812 #[cfg(feature = "secret-exposure-review")]
1813 self.secret_exposure_review,
1814 #[cfg(feature = "tls-observation")]
1815 self.tls_observation,
1816 #[cfg(feature = "tls-negotiation-review")]
1817 self.tls_negotiation_review,
1818 #[cfg(feature = "recon-ct-provider")]
1819 self.recon_ct_provider,
1820 &self.transport,
1821 )
1822 }
1823}
1824
1825pub struct WebAssessmentFailureReceipt {
1827 completed_subjects: Vec<WebAssessmentSubjectReport>,
1828 pending_subjects: Vec<WebAssessmentSubject>,
1829 forms: Vec<WebAssessmentForm>,
1830 semantics: SemanticExtractionResult,
1831 defense: WebAssessmentDefenseAudit,
1832 #[cfg(feature = "supplied-session-review")]
1833 supplied_session: Option<WebAssessmentSuppliedSessionAudit>,
1834 #[cfg(feature = "websocket-review")]
1835 websocket_review: Option<WebAssessmentWebSocketReviewAudit>,
1836 #[cfg(feature = "jwt-target-acceptance-review")]
1837 jwt_target_acceptance: Option<JwtTargetAcceptanceAudit>,
1838 #[cfg(feature = "xml-external-entity-review")]
1839 xml_external_entity_review: Option<XmlExternalEntityReviewAudit>,
1840 current_subject: WebAssessmentSubjectReport,
1841 incomplete_reasons: BTreeSet<WebAssessmentIncompleteReason>,
1842 inventory_consistent: bool,
1843 unrepresented_ledger_subjects: usize,
1844 committed_passive_observations: usize,
1845 usage: WebAssessmentUsage,
1846 transport: TransportDispatchAudit,
1847}
1848
1849impl fmt::Debug for WebAssessmentFailureReceipt {
1850 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1851 let mut debug = formatter.debug_struct("WebAssessmentFailureReceipt");
1852 debug
1853 .field("completed_subject_count", &self.completed_subjects.len())
1854 .field("pending_subject_count", &self.pending_subjects.len())
1855 .field("form_count", &self.forms.len())
1856 .field("semantics", &"<redacted>")
1857 .field("defense", &self.defense);
1858 #[cfg(feature = "supplied-session-review")]
1859 debug.field("supplied_session", &self.supplied_session);
1860 #[cfg(feature = "websocket-review")]
1861 debug.field(
1862 "websocket_review",
1863 &self.websocket_review.as_ref().map(|_| "<redacted-audit>"),
1864 );
1865 #[cfg(feature = "jwt-target-acceptance-review")]
1866 debug.field("jwt_target_acceptance", &self.jwt_target_acceptance);
1867 #[cfg(feature = "xml-external-entity-review")]
1868 debug.field(
1869 "xml_external_entity_review",
1870 &self.xml_external_entity_review,
1871 );
1872 debug
1873 .field("current_subject", &self.current_subject)
1874 .field("incomplete_reasons", &self.incomplete_reasons)
1875 .field("inventory_consistent", &self.inventory_consistent)
1876 .field(
1877 "unrepresented_ledger_subjects",
1878 &self.unrepresented_ledger_subjects,
1879 )
1880 .field(
1881 "committed_passive_observations",
1882 &self.committed_passive_observations,
1883 )
1884 .field("usage", &self.usage)
1885 .field("transport", &"<redacted-audit>")
1886 .finish()
1887 }
1888}
1889
1890impl WebAssessmentFailureReceipt {
1891 pub fn completed_subjects(&self) -> &[WebAssessmentSubjectReport] {
1892 &self.completed_subjects
1893 }
1894 pub fn pending_subjects(&self) -> &[WebAssessmentSubject] {
1896 &self.pending_subjects
1897 }
1898 pub fn forms(&self) -> &[WebAssessmentForm] {
1899 &self.forms
1900 }
1901 pub fn semantics(&self) -> &SemanticExtractionResult {
1904 &self.semantics
1905 }
1906 pub fn defense(&self) -> &WebAssessmentDefenseAudit {
1907 &self.defense
1908 }
1909 #[cfg(feature = "supplied-session-review")]
1911 pub const fn supplied_session_audit(&self) -> Option<&WebAssessmentSuppliedSessionAudit> {
1912 self.supplied_session.as_ref()
1913 }
1914 #[cfg(feature = "websocket-review")]
1916 pub const fn websocket_review_audit(&self) -> Option<&WebAssessmentWebSocketReviewAudit> {
1917 self.websocket_review.as_ref()
1918 }
1919 #[cfg(feature = "jwt-target-acceptance-review")]
1921 pub const fn jwt_target_acceptance_audit(&self) -> Option<&JwtTargetAcceptanceAudit> {
1922 self.jwt_target_acceptance.as_ref()
1923 }
1924 #[cfg(feature = "xml-external-entity-review")]
1926 pub const fn xml_external_entity_review_audit(&self) -> Option<&XmlExternalEntityReviewAudit> {
1927 self.xml_external_entity_review.as_ref()
1928 }
1929 pub fn current_subject(&self) -> &WebAssessmentSubject {
1930 &self.current_subject.subject
1931 }
1932 pub fn current_subject_report(&self) -> &WebAssessmentSubjectReport {
1935 &self.current_subject
1936 }
1937 pub fn incomplete_reasons(&self) -> &BTreeSet<WebAssessmentIncompleteReason> {
1939 &self.incomplete_reasons
1940 }
1941 pub const fn inventory_consistent(&self) -> bool {
1943 self.inventory_consistent
1944 }
1945 pub const fn unrepresented_ledger_subjects(&self) -> usize {
1947 self.unrepresented_ledger_subjects
1948 }
1949 pub const fn committed_passive_observations(&self) -> usize {
1955 self.committed_passive_observations
1956 }
1957 pub const fn usage(&self) -> WebAssessmentUsage {
1958 self.usage
1959 }
1960 pub fn transport(&self) -> &TransportDispatchAudit {
1961 &self.transport
1962 }
1963}
1964
1965#[derive(Error)]
1967#[non_exhaustive]
1968pub enum WebAssessmentRuntimeError {
1969 #[error("web assessment runtime has already started")]
1970 AlreadyStarted,
1971 #[error(transparent)]
1972 Limits(#[from] WebAssessmentLimitsError),
1973 #[error(transparent)]
1974 SemanticLimits(#[from] LimitsError),
1975 #[error(transparent)]
1976 Http(#[from] HttpEvidenceError),
1977 #[error("authorized web assessment target cannot be retained safely")]
1978 InvalidCanonicalTarget,
1979 #[error("authorized root exceeds the total retained URL byte limit")]
1980 RootRetentionLimit,
1981 #[error("native low-risk web review could not be composed safely")]
1982 NativeReviewComposition,
1983 #[cfg(feature = "recon-ct-provider")]
1984 #[error("certificate-transparency provider policy is not bound to the selected assessment")]
1985 ReconCtProviderComposition,
1986 #[error("root authorization-context review requires an exact origin root target")]
1987 RootAuthorizationContextRequiresOriginRoot,
1988 #[error("root API authorization-context review could not be composed safely")]
1989 ApiVisibilityComposition,
1990 #[cfg(feature = "authorization-review")]
1991 #[error("resource authorization review could not be composed safely")]
1992 AuthorizationReviewComposition,
1993 #[cfg(feature = "authorization-review")]
1994 #[error("resource authorization review conflicts with root authorization context")]
1995 AuthorizationReviewConflict,
1996 #[cfg(feature = "authorization-review")]
1997 #[error("resource authorization review requires protected authenticated transport")]
1998 InsecureAuthorizationReviewTransport,
1999 #[cfg(feature = "websocket-review")]
2000 #[error("WebSocket review policy does not match the exact assessment application")]
2001 WebSocketReviewApplicationMismatch,
2002 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2003 #[error("WebSocket supplied-session integration requires both selected reviews")]
2004 WebSocketSuppliedSessionRequiresInputs,
2005 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2006 #[error("WebSocket supplied-session integration supports one V1 Authorization resource")]
2007 WebSocketSuppliedSessionUnsupportedPolicy,
2008 #[cfg(all(
2009 feature = "websocket-review",
2010 feature = "supplied-session-review",
2011 feature = "wordpress-review"
2012 ))]
2013 #[error("WebSocket and WordPress supplied-session integrations cannot share one session")]
2014 WebSocketSuppliedSessionWordPressConflict,
2015 #[cfg(feature = "jwt-target-acceptance-review")]
2016 #[error("JWT target-acceptance policy does not match the exact assessment application")]
2017 JwtTargetAcceptanceApplicationMismatch,
2018 #[cfg(feature = "jwt-target-acceptance-review")]
2019 #[error("JWT target-acceptance review requires protected authenticated transport")]
2020 InsecureJwtTargetAcceptanceTransport,
2021 #[cfg(feature = "jwt-target-acceptance-review")]
2022 #[error("JWT target-acceptance review could not be composed safely")]
2023 JwtTargetAcceptanceComposition,
2024 #[cfg(feature = "jwt-target-acceptance-review")]
2025 #[error("JWT target-acceptance review failed after assessment start")]
2026 JwtTargetAcceptanceExecution {
2027 receipt: Box<WebAssessmentFailureReceipt>,
2028 },
2029 #[cfg(feature = "xml-external-entity-review")]
2030 #[error("controlled XML review policy does not match the selected assessment application")]
2031 XmlExternalEntityApplicationMismatch,
2032 #[cfg(all(feature = "xml-external-entity-review", feature = "ssrf-oast-review"))]
2033 #[error(
2034 "controlled XML review and SSRF/OAST review cannot share one native provider lifecycle"
2035 )]
2036 XmlExternalEntitySsrfOastConflict,
2037 #[cfg(feature = "xml-external-entity-review")]
2038 #[error("controlled XML review failed after assessment start")]
2039 XmlExternalEntityExecution {
2040 receipt: Box<WebAssessmentFailureReceipt>,
2041 },
2042 #[cfg(feature = "supplied-session-review")]
2043 #[error("supplied-session policy does not match the exact assessment application")]
2044 SuppliedSessionApplicationMismatch,
2045 #[cfg(feature = "supplied-session-review")]
2046 #[error("supplied-session review requires protected authenticated transport")]
2047 InsecureSuppliedSessionTransport,
2048 #[cfg(feature = "supplied-session-review")]
2049 #[error("supplied-session review could not be composed safely")]
2050 SuppliedSessionComposition,
2051 #[cfg(feature = "secret-exposure-review")]
2052 #[error(
2053 "passive secret-exposure review conflicts with a response source that lacks its value-free body-digest boundary"
2054 )]
2055 SecretExposureResponseSourceConflict,
2056 #[cfg(feature = "tls-negotiation-review")]
2057 #[error("active TLS negotiation requires one credential-free DNS HTTPS target")]
2058 TlsNegotiationReviewComposition,
2059 #[cfg(feature = "rest-review")]
2060 #[error("REST review requires OpenAPI review in the same assessment")]
2061 RestReviewRequiresOpenApiReview,
2062 #[cfg(feature = "wordpress-review")]
2063 #[error(
2064 "WordPress evidence review requires an exact root or trailing-slash application target"
2065 )]
2066 WordPressReviewRequiresOriginRoot,
2067 #[cfg(feature = "wordpress-review")]
2068 #[error("a non-root WordPress application target requires explicit metadata discovery")]
2069 WordPressNonRootRequiresDiscovery,
2070 #[cfg(feature = "wordpress-review")]
2071 #[error("WordPress metadata discovery requires WordPress review in the same assessment")]
2072 WordPressDiscoveryRequiresReview,
2073 #[cfg(feature = "wordpress-review")]
2074 #[error("WordPress page scope requires metadata discovery in the same assessment")]
2075 WordPressPageScopeRequiresDiscovery,
2076 #[cfg(feature = "wordpress-review")]
2077 #[error("WordPress layout declarations require metadata discovery in the same assessment")]
2078 WordPressLayoutRequiresDiscovery,
2079 #[cfg(feature = "wordpress-review")]
2080 #[error("WordPress asset fingerprints require metadata discovery in the same assessment")]
2081 WordPressAssetFingerprintsRequireDiscovery,
2082 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2083 #[error(
2084 "WordPress supplied-session integration requires WordPress review, metadata discovery, and a supplied session"
2085 )]
2086 WordPressSuppliedSessionRequiresInputs,
2087 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2088 #[error("WordPress supplied-session integration does not support renewal or OAuth policies")]
2089 WordPressSuppliedSessionUnsupportedPolicy,
2090 #[cfg(feature = "wordpress-review")]
2091 #[error("WordPress context root does not match the exact assessment target")]
2092 WordPressContextRootMismatch,
2093 #[error(transparent)]
2094 Standard(#[from] StandardWebDecisionRuntimeError),
2095 #[error("web assessment failed after it started")]
2096 RunFailed {
2097 receipt: Box<WebAssessmentFailureReceipt>,
2098 #[source]
2099 source: Box<StandardWebDecisionRuntimeError>,
2100 },
2101 #[error("root API authorization-context review failed after it started")]
2102 ApiVisibilityRunFailed {
2103 receipt: Box<WebAssessmentFailureReceipt>,
2104 #[source]
2105 source: Box<RuntimeApiVisibilityExecutionError>,
2106 },
2107 #[error("committed web discovery evidence violated its typed projection contract")]
2108 ProjectionInvariant {
2109 receipt: Box<WebAssessmentFailureReceipt>,
2110 },
2111}
2112
2113impl fmt::Debug for WebAssessmentRuntimeError {
2114 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2115 match self {
2116 Self::AlreadyStarted => {
2117 formatter.write_str("WebAssessmentRuntimeError::AlreadyStarted")
2118 },
2119 Self::Limits(_) => formatter.write_str("WebAssessmentRuntimeError::Limits(<redacted>)"),
2120 Self::SemanticLimits(_) => {
2121 formatter.write_str("WebAssessmentRuntimeError::SemanticLimits(<redacted>)")
2122 },
2123 Self::Http(_) => formatter.write_str("WebAssessmentRuntimeError::Http(<redacted>)"),
2124 Self::InvalidCanonicalTarget => {
2125 formatter.write_str("WebAssessmentRuntimeError::InvalidCanonicalTarget")
2126 },
2127 Self::RootRetentionLimit => {
2128 formatter.write_str("WebAssessmentRuntimeError::RootRetentionLimit")
2129 },
2130 Self::NativeReviewComposition => {
2131 formatter.write_str("WebAssessmentRuntimeError::NativeReviewComposition")
2132 },
2133 #[cfg(feature = "recon-ct-provider")]
2134 Self::ReconCtProviderComposition => {
2135 formatter.write_str("WebAssessmentRuntimeError::ReconCtProviderComposition")
2136 },
2137 Self::RootAuthorizationContextRequiresOriginRoot => formatter
2138 .write_str("WebAssessmentRuntimeError::RootAuthorizationContextRequiresOriginRoot"),
2139 Self::ApiVisibilityComposition => {
2140 formatter.write_str("WebAssessmentRuntimeError::ApiVisibilityComposition")
2141 },
2142 #[cfg(feature = "authorization-review")]
2143 Self::AuthorizationReviewComposition => {
2144 formatter.write_str("WebAssessmentRuntimeError::AuthorizationReviewComposition")
2145 },
2146 #[cfg(feature = "authorization-review")]
2147 Self::AuthorizationReviewConflict => {
2148 formatter.write_str("WebAssessmentRuntimeError::AuthorizationReviewConflict")
2149 },
2150 #[cfg(feature = "authorization-review")]
2151 Self::InsecureAuthorizationReviewTransport => formatter
2152 .write_str("WebAssessmentRuntimeError::InsecureAuthorizationReviewTransport"),
2153 #[cfg(feature = "websocket-review")]
2154 Self::WebSocketReviewApplicationMismatch => {
2155 formatter.write_str("WebAssessmentRuntimeError::WebSocketReviewApplicationMismatch")
2156 },
2157 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2158 Self::WebSocketSuppliedSessionRequiresInputs => formatter
2159 .write_str("WebAssessmentRuntimeError::WebSocketSuppliedSessionRequiresInputs"),
2160 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2161 Self::WebSocketSuppliedSessionUnsupportedPolicy => formatter
2162 .write_str("WebAssessmentRuntimeError::WebSocketSuppliedSessionUnsupportedPolicy"),
2163 #[cfg(all(
2164 feature = "websocket-review",
2165 feature = "supplied-session-review",
2166 feature = "wordpress-review"
2167 ))]
2168 Self::WebSocketSuppliedSessionWordPressConflict => formatter
2169 .write_str("WebAssessmentRuntimeError::WebSocketSuppliedSessionWordPressConflict"),
2170 #[cfg(feature = "jwt-target-acceptance-review")]
2171 Self::JwtTargetAcceptanceApplicationMismatch => formatter
2172 .write_str("WebAssessmentRuntimeError::JwtTargetAcceptanceApplicationMismatch"),
2173 #[cfg(feature = "jwt-target-acceptance-review")]
2174 Self::InsecureJwtTargetAcceptanceTransport => formatter
2175 .write_str("WebAssessmentRuntimeError::InsecureJwtTargetAcceptanceTransport"),
2176 #[cfg(feature = "jwt-target-acceptance-review")]
2177 Self::JwtTargetAcceptanceComposition => {
2178 formatter.write_str("WebAssessmentRuntimeError::JwtTargetAcceptanceComposition")
2179 },
2180 #[cfg(feature = "jwt-target-acceptance-review")]
2181 Self::JwtTargetAcceptanceExecution { receipt } => formatter
2182 .debug_struct("WebAssessmentRuntimeError::JwtTargetAcceptanceExecution")
2183 .field("receipt", receipt)
2184 .finish(),
2185 #[cfg(feature = "xml-external-entity-review")]
2186 Self::XmlExternalEntityApplicationMismatch => formatter
2187 .write_str("WebAssessmentRuntimeError::XmlExternalEntityApplicationMismatch"),
2188 #[cfg(all(feature = "xml-external-entity-review", feature = "ssrf-oast-review"))]
2189 Self::XmlExternalEntitySsrfOastConflict => {
2190 formatter.write_str("WebAssessmentRuntimeError::XmlExternalEntitySsrfOastConflict")
2191 },
2192 #[cfg(feature = "xml-external-entity-review")]
2193 Self::XmlExternalEntityExecution { receipt } => formatter
2194 .debug_struct("WebAssessmentRuntimeError::XmlExternalEntityExecution")
2195 .field("receipt", receipt)
2196 .finish(),
2197 #[cfg(feature = "supplied-session-review")]
2198 Self::SuppliedSessionApplicationMismatch => {
2199 formatter.write_str("WebAssessmentRuntimeError::SuppliedSessionApplicationMismatch")
2200 },
2201 #[cfg(feature = "supplied-session-review")]
2202 Self::InsecureSuppliedSessionTransport => {
2203 formatter.write_str("WebAssessmentRuntimeError::InsecureSuppliedSessionTransport")
2204 },
2205 #[cfg(feature = "supplied-session-review")]
2206 Self::SuppliedSessionComposition => {
2207 formatter.write_str("WebAssessmentRuntimeError::SuppliedSessionComposition")
2208 },
2209 #[cfg(feature = "secret-exposure-review")]
2210 Self::SecretExposureResponseSourceConflict => formatter
2211 .write_str("WebAssessmentRuntimeError::SecretExposureResponseSourceConflict"),
2212 #[cfg(feature = "tls-negotiation-review")]
2213 Self::TlsNegotiationReviewComposition => {
2214 formatter.write_str("WebAssessmentRuntimeError::TlsNegotiationReviewComposition")
2215 },
2216 #[cfg(feature = "rest-review")]
2217 Self::RestReviewRequiresOpenApiReview => {
2218 formatter.write_str("WebAssessmentRuntimeError::RestReviewRequiresOpenApiReview")
2219 },
2220 #[cfg(feature = "wordpress-review")]
2221 Self::WordPressReviewRequiresOriginRoot => {
2222 formatter.write_str("WebAssessmentRuntimeError::WordPressReviewRequiresOriginRoot")
2223 },
2224 #[cfg(feature = "wordpress-review")]
2225 Self::WordPressNonRootRequiresDiscovery => {
2226 formatter.write_str("WebAssessmentRuntimeError::WordPressNonRootRequiresDiscovery")
2227 },
2228 #[cfg(feature = "wordpress-review")]
2229 Self::WordPressDiscoveryRequiresReview => {
2230 formatter.write_str("WebAssessmentRuntimeError::WordPressDiscoveryRequiresReview")
2231 },
2232 #[cfg(feature = "wordpress-review")]
2233 Self::WordPressPageScopeRequiresDiscovery => formatter
2234 .write_str("WebAssessmentRuntimeError::WordPressPageScopeRequiresDiscovery"),
2235 #[cfg(feature = "wordpress-review")]
2236 Self::WordPressLayoutRequiresDiscovery => {
2237 formatter.write_str("WebAssessmentRuntimeError::WordPressLayoutRequiresDiscovery")
2238 },
2239 #[cfg(feature = "wordpress-review")]
2240 Self::WordPressAssetFingerprintsRequireDiscovery => formatter
2241 .write_str("WebAssessmentRuntimeError::WordPressAssetFingerprintsRequireDiscovery"),
2242 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2243 Self::WordPressSuppliedSessionRequiresInputs => formatter
2244 .write_str("WebAssessmentRuntimeError::WordPressSuppliedSessionRequiresInputs"),
2245 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2246 Self::WordPressSuppliedSessionUnsupportedPolicy => formatter
2247 .write_str("WebAssessmentRuntimeError::WordPressSuppliedSessionUnsupportedPolicy"),
2248 #[cfg(feature = "wordpress-review")]
2249 Self::WordPressContextRootMismatch => {
2250 formatter.write_str("WebAssessmentRuntimeError::WordPressContextRootMismatch")
2251 },
2252 Self::Standard(_) => {
2253 formatter.write_str("WebAssessmentRuntimeError::Standard(<redacted>)")
2254 },
2255 Self::RunFailed { receipt, .. } => formatter
2256 .debug_struct("WebAssessmentRuntimeError::RunFailed")
2257 .field("receipt", receipt)
2258 .field("source", &"<redacted>")
2259 .finish(),
2260 Self::ApiVisibilityRunFailed { receipt, .. } => formatter
2261 .debug_struct("WebAssessmentRuntimeError::ApiVisibilityRunFailed")
2262 .field("receipt", receipt)
2263 .field("source", &"<redacted>")
2264 .finish(),
2265 Self::ProjectionInvariant { receipt } => formatter
2266 .debug_struct("WebAssessmentRuntimeError::ProjectionInvariant")
2267 .field("receipt", receipt)
2268 .finish(),
2269 }
2270 }
2271}
2272
2273#[cfg(feature = "wordpress-review")]
2274fn wordpress_application_target_is_valid(target: &Url) -> bool {
2275 if !matches!(target.scheme(), "http" | "https")
2276 || !target.has_host()
2277 || !target.username().is_empty()
2278 || target.password().is_some()
2279 || target.query().is_some()
2280 || target.fragment().is_some()
2281 || !target.path().starts_with('/')
2282 || !target.path().ends_with('/')
2283 || target.path().contains('\\')
2284 {
2285 return false;
2286 }
2287 let lowercase = target.path().to_ascii_lowercase();
2288 !["%2e", "%2f", "%5c", "%25"]
2289 .iter()
2290 .any(|encoded| lowercase.contains(encoded))
2291 && !target
2292 .path()
2293 .split('/')
2294 .enumerate()
2295 .any(|(index, segment)| {
2296 matches!(segment, "." | "..")
2297 || (segment.is_empty()
2298 && index != 0
2299 && index + 1 != target.path().split('/').count())
2300 })
2301}
2302
2303impl WebAssessmentRuntimeError {
2304 pub fn failure_receipt(&self) -> Option<&WebAssessmentFailureReceipt> {
2305 match self {
2306 Self::RunFailed { receipt, .. }
2307 | Self::ApiVisibilityRunFailed { receipt, .. }
2308 | Self::ProjectionInvariant { receipt } => Some(receipt),
2309 #[cfg(feature = "jwt-target-acceptance-review")]
2310 Self::JwtTargetAcceptanceExecution { receipt } => Some(receipt),
2311 #[cfg(feature = "xml-external-entity-review")]
2312 Self::XmlExternalEntityExecution { receipt } => Some(receipt),
2313 _ => None,
2314 }
2315 }
2316}
2317
2318pub struct WebAssessmentRuntimeBuilder {
2320 target: Url,
2321 limits: WebAssessmentLimits,
2322 http_policy: Option<HttpEvidencePolicy>,
2323 cancellation: CancellationToken,
2324 defense_enforcement: bool,
2325 low_risk_differential_review: bool,
2326 #[cfg(feature = "normalization-resilience")]
2327 normalization_resilience: bool,
2328 #[cfg(feature = "template-evaluation-review")]
2329 template_evaluation_review: bool,
2330 #[cfg(feature = "tls-negotiation-review")]
2331 tls_negotiation_review: bool,
2332 #[cfg(feature = "graphql-review")]
2333 graphql_review: bool,
2334 #[cfg(feature = "authorization-review")]
2335 resource_authorization_review: Option<(AuthorizationReviewPolicy, AuthorizationPrincipalPair)>,
2336 #[cfg(feature = "jwt-target-acceptance-review")]
2337 jwt_target_acceptance_review:
2338 Option<(JwtTargetAcceptancePolicy, JwtTargetAcceptanceRuntimeInput)>,
2339 #[cfg(feature = "openapi-review")]
2340 openapi_review: bool,
2341 #[cfg(feature = "rest-review")]
2342 rest_review: bool,
2343 #[cfg(feature = "ssrf-oast-review")]
2344 ssrf_oast_review: Option<(SsrfOastReviewPolicy, SsrfOastAdminToken)>,
2345 #[cfg(feature = "xml-external-entity-review")]
2346 xml_external_entity_review:
2347 Option<(XmlExternalEntityReviewPolicy, XmlExternalEntityAdminToken)>,
2348 #[cfg(feature = "supplied-session-review")]
2349 supplied_session_review: Option<(SuppliedSessionPolicy, SuppliedSessionRuntimeInput)>,
2350 #[cfg(feature = "wordpress-review")]
2351 wordpress_review: Option<WordPressReviewInputs>,
2352 #[cfg(feature = "wordpress-review")]
2353 wordpress_discovery: bool,
2354 #[cfg(feature = "wordpress-review")]
2355 wordpress_page_scope: Option<super::wordpress_runtime::WordPressPageScope>,
2356 #[cfg(feature = "secret-exposure-review")]
2357 secret_exposure_review: bool,
2358 #[cfg(feature = "tls-observation")]
2359 tls_observation: bool,
2360 #[cfg(feature = "recon-ct-provider")]
2361 recon_ct_provider: Option<ReconCtProviderPolicy>,
2362 #[cfg(feature = "websocket-review")]
2363 websocket_review: Option<WebSocketReviewPolicy>,
2364 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2365 websocket_supplied_session: bool,
2366 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2367 wordpress_supplied_session: bool,
2368 root_authorization_context: Option<WebAssessmentRootAuthorizationContext>,
2369}
2370
2371impl WebAssessmentRuntimeBuilder {
2372 pub fn new(target: Url) -> Self {
2373 Self {
2374 target,
2375 limits: WebAssessmentLimits::default(),
2376 http_policy: None,
2377 cancellation: CancellationToken::new(),
2378 defense_enforcement: false,
2379 low_risk_differential_review: false,
2380 #[cfg(feature = "normalization-resilience")]
2381 normalization_resilience: false,
2382 #[cfg(feature = "template-evaluation-review")]
2383 template_evaluation_review: false,
2384 #[cfg(feature = "graphql-review")]
2385 graphql_review: false,
2386 #[cfg(feature = "authorization-review")]
2387 resource_authorization_review: None,
2388 #[cfg(feature = "jwt-target-acceptance-review")]
2389 jwt_target_acceptance_review: None,
2390 #[cfg(feature = "openapi-review")]
2391 openapi_review: false,
2392 #[cfg(feature = "rest-review")]
2393 rest_review: false,
2394 #[cfg(feature = "ssrf-oast-review")]
2395 ssrf_oast_review: None,
2396 #[cfg(feature = "xml-external-entity-review")]
2397 xml_external_entity_review: None,
2398 #[cfg(feature = "supplied-session-review")]
2399 supplied_session_review: None,
2400 #[cfg(feature = "wordpress-review")]
2401 wordpress_review: None,
2402 #[cfg(feature = "wordpress-review")]
2403 wordpress_discovery: false,
2404 #[cfg(feature = "wordpress-review")]
2405 wordpress_page_scope: None,
2406 #[cfg(feature = "secret-exposure-review")]
2407 secret_exposure_review: false,
2408 #[cfg(feature = "tls-observation")]
2409 tls_observation: false,
2410 #[cfg(feature = "tls-negotiation-review")]
2411 tls_negotiation_review: false,
2412 #[cfg(feature = "recon-ct-provider")]
2413 recon_ct_provider: None,
2414 #[cfg(feature = "websocket-review")]
2415 websocket_review: None,
2416 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2417 websocket_supplied_session: false,
2418 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2419 wordpress_supplied_session: false,
2420 root_authorization_context: None,
2421 }
2422 }
2423 pub fn limits(mut self, limits: WebAssessmentLimits) -> Self {
2424 self.limits = limits;
2425 self
2426 }
2427 pub fn http_policy(mut self, policy: HttpEvidencePolicy) -> Self {
2428 self.http_policy = Some(policy);
2429 self
2430 }
2431 pub fn cancellation_token(mut self, cancellation: CancellationToken) -> Self {
2432 self.cancellation = cancellation;
2433 self
2434 }
2435 pub fn enable_defense_enforcement(mut self) -> Self {
2437 self.defense_enforcement = true;
2438 self
2439 }
2440 pub fn enable_low_risk_differential_review(mut self) -> Self {
2446 self.low_risk_differential_review = true;
2447 self
2448 }
2449 #[cfg(feature = "secret-exposure-review")]
2453 pub fn enable_secret_exposure_review(mut self) -> Self {
2454 self.secret_exposure_review = true;
2455 self
2456 }
2457 #[cfg(feature = "tls-observation")]
2460 pub fn enable_tls_observation(mut self) -> Self {
2461 self.tls_observation = true;
2462 self
2463 }
2464 #[cfg(feature = "tls-negotiation-review")]
2469 pub fn enable_tls_negotiation_review(mut self) -> Self {
2470 self.tls_negotiation_review = true;
2471 self
2472 }
2473 #[cfg(feature = "normalization-resilience")]
2480 pub fn enable_normalization_resilience(mut self) -> Self {
2481 self.normalization_resilience = true;
2482 self
2483 }
2484 #[cfg(feature = "template-evaluation-review")]
2492 pub fn enable_template_evaluation_review(mut self) -> Self {
2493 self.template_evaluation_review = true;
2494 self
2495 }
2496 #[cfg(feature = "graphql-review")]
2502 pub fn enable_graphql_review(mut self) -> Self {
2503 self.graphql_review = true;
2504 self
2505 }
2506 #[cfg(feature = "openapi-review")]
2508 pub fn enable_openapi_review(mut self) -> Self {
2509 self.openapi_review = true;
2510 self
2511 }
2512 #[cfg(feature = "rest-review")]
2518 pub fn enable_rest_review(mut self) -> Self {
2519 self.rest_review = true;
2520 self
2521 }
2522 #[cfg(feature = "ssrf-oast-review")]
2524 pub fn with_ssrf_oast_review(
2525 mut self,
2526 policy: SsrfOastReviewPolicy,
2527 administrator: SsrfOastAdminToken,
2528 ) -> Self {
2529 self.ssrf_oast_review = Some((policy, administrator));
2530 self
2531 }
2532 #[cfg(feature = "xml-external-entity-review")]
2538 pub fn with_xml_external_entity_review(
2539 mut self,
2540 policy: XmlExternalEntityReviewPolicy,
2541 administrator: XmlExternalEntityAdminToken,
2542 ) -> Self {
2543 self.xml_external_entity_review = Some((policy, administrator));
2544 self
2545 }
2546 #[cfg(feature = "supplied-session-review")]
2552 pub fn with_supplied_session_review<C>(
2553 mut self,
2554 policy: SuppliedSessionPolicy,
2555 input: C,
2556 ) -> Self
2557 where
2558 C: Into<SuppliedSessionRuntimeInput>,
2559 {
2560 self.supplied_session_review = Some((policy, input.into()));
2561 self
2562 }
2563 #[cfg(feature = "wordpress-review")]
2566 pub fn with_wordpress_review(mut self, inputs: WordPressReviewInputs) -> Self {
2567 self.wordpress_review = Some(inputs);
2568 self
2569 }
2570 #[cfg(feature = "wordpress-review")]
2576 pub fn with_wordpress_discovery(mut self) -> Self {
2577 self.wordpress_discovery = true;
2578 self
2579 }
2580 #[cfg(feature = "wordpress-review")]
2583 pub fn with_wordpress_page_scope(
2584 mut self,
2585 scope: super::wordpress_runtime::WordPressPageScope,
2586 ) -> Self {
2587 self.wordpress_page_scope = Some(scope);
2588 self
2589 }
2590 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2595 pub fn with_wordpress_supplied_session(mut self) -> Self {
2596 self.wordpress_supplied_session = true;
2597 self
2598 }
2599 #[cfg(feature = "recon-ct-provider")]
2605 pub fn with_recon_ct_provider(mut self, policy: ReconCtProviderPolicy) -> Self {
2606 self.recon_ct_provider = Some(policy);
2607 self
2608 }
2609 #[cfg(feature = "websocket-review")]
2616 pub fn with_websocket_review(mut self, policy: WebSocketReviewPolicy) -> Self {
2617 self.websocket_review = Some(policy);
2618 self
2619 }
2620 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2624 pub fn with_websocket_supplied_session(mut self) -> Self {
2625 self.websocket_supplied_session = true;
2626 self
2627 }
2628 #[cfg(feature = "authorization-review")]
2634 pub fn with_resource_authorization_review(
2635 mut self,
2636 policy: AuthorizationReviewPolicy,
2637 principals: AuthorizationPrincipalPair,
2638 ) -> Self {
2639 self.resource_authorization_review = Some((policy, principals));
2640 self
2641 }
2642 #[cfg(feature = "jwt-target-acceptance-review")]
2650 pub fn with_jwt_target_acceptance_review(
2651 mut self,
2652 policy: JwtTargetAcceptancePolicy,
2653 runtime_input: JwtTargetAcceptanceRuntimeInput,
2654 ) -> Self {
2655 self.jwt_target_acceptance_review = Some((policy, runtime_input));
2656 self
2657 }
2658 pub fn with_root_authorization_context(
2664 mut self,
2665 context: WebAssessmentRootAuthorizationContext,
2666 ) -> Self {
2667 self.root_authorization_context = Some(context);
2668 self
2669 }
2670 #[cfg(feature = "secret-exposure-review")]
2671 fn secret_exposure_response_source_conflict(&self) -> bool {
2672 let conflict = false;
2673 #[cfg(all(feature = "openapi-review", feature = "ssrf-oast-review"))]
2674 let conflict = conflict || (self.openapi_review && self.ssrf_oast_review.is_some());
2675 #[cfg(feature = "wordpress-review")]
2676 let conflict = conflict || self.wordpress_discovery;
2677 self.secret_exposure_review && conflict
2678 }
2679 pub fn build(self) -> Result<WebAssessmentRuntime, WebAssessmentRuntimeError> {
2680 #[cfg(feature = "secret-exposure-review")]
2681 if self.secret_exposure_response_source_conflict() {
2682 return Err(WebAssessmentRuntimeError::SecretExposureResponseSourceConflict);
2683 }
2684 #[cfg(all(feature = "xml-external-entity-review", feature = "ssrf-oast-review"))]
2685 if self.xml_external_entity_review.is_some() && self.ssrf_oast_review.is_some() {
2686 return Err(WebAssessmentRuntimeError::XmlExternalEntitySsrfOastConflict);
2687 }
2688 #[cfg(feature = "ssrf-oast-review")]
2689 let ssrf_oast_review = self.ssrf_oast_review.map(|(policy, administrator)| {
2690 let selection = select_observed_query_candidate(
2691 policy.target_origin(),
2692 &self.target,
2693 "authorized-root@1",
2694 true,
2695 true,
2696 true,
2697 );
2698 SsrfOastReviewConfig::new(policy, administrator, selection)
2699 });
2700 #[cfg(feature = "recon-ct-provider")]
2701 if self
2702 .recon_ct_provider
2703 .as_ref()
2704 .is_some_and(|policy| !recon_ct_provider_scope_is_valid(policy, &self.target))
2705 {
2706 return Err(WebAssessmentRuntimeError::ReconCtProviderComposition);
2707 }
2708 #[cfg(feature = "rest-review")]
2709 if self.rest_review && !self.openapi_review {
2710 return Err(WebAssessmentRuntimeError::RestReviewRequiresOpenApiReview);
2711 }
2712 #[cfg(feature = "wordpress-review")]
2713 if self.wordpress_discovery && self.wordpress_review.is_none() {
2714 return Err(WebAssessmentRuntimeError::WordPressDiscoveryRequiresReview);
2715 }
2716 #[cfg(feature = "wordpress-review")]
2717 if self.wordpress_page_scope.is_some() && !self.wordpress_discovery {
2718 return Err(WebAssessmentRuntimeError::WordPressPageScopeRequiresDiscovery);
2719 }
2720 #[cfg(feature = "wordpress-review")]
2721 if self
2722 .wordpress_review
2723 .as_ref()
2724 .is_some_and(|inputs| inputs.discovery_layout().is_some())
2725 && !self.wordpress_discovery
2726 {
2727 return Err(WebAssessmentRuntimeError::WordPressLayoutRequiresDiscovery);
2728 }
2729 #[cfg(feature = "wordpress-review")]
2730 if self
2731 .wordpress_review
2732 .as_ref()
2733 .is_some_and(|inputs| inputs.asset_fingerprint_catalog().is_some())
2734 && !self.wordpress_discovery
2735 {
2736 return Err(WebAssessmentRuntimeError::WordPressAssetFingerprintsRequireDiscovery);
2737 }
2738 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2739 if self.wordpress_supplied_session
2740 && (self.wordpress_review.is_none()
2741 || !self.wordpress_discovery
2742 || self.supplied_session_review.is_none())
2743 {
2744 return Err(WebAssessmentRuntimeError::WordPressSuppliedSessionRequiresInputs);
2745 }
2746 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
2747 if self.wordpress_supplied_session
2748 && self
2749 .supplied_session_review
2750 .as_ref()
2751 .is_some_and(|(policy, _)| {
2752 !matches!(
2753 policy.version(),
2754 SuppliedSessionPolicyVersion::V1
2755 | SuppliedSessionPolicyVersion::V2
2756 | SuppliedSessionPolicyVersion::V3
2757 )
2758 })
2759 {
2760 return Err(WebAssessmentRuntimeError::WordPressSuppliedSessionUnsupportedPolicy);
2761 }
2762 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2763 if self.websocket_supplied_session
2764 && (self.websocket_review.is_none() || self.supplied_session_review.is_none())
2765 {
2766 return Err(WebAssessmentRuntimeError::WebSocketSuppliedSessionRequiresInputs);
2767 }
2768 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
2769 if self.websocket_supplied_session
2770 && self
2771 .supplied_session_review
2772 .as_ref()
2773 .is_some_and(|(policy, _)| {
2774 policy.version() != SuppliedSessionPolicyVersion::V1
2775 || policy.credential_mechanism()
2776 != SuppliedSessionCredentialMechanism::AuthorizationHeader
2777 || policy.resource_count() != 1
2778 })
2779 {
2780 return Err(WebAssessmentRuntimeError::WebSocketSuppliedSessionUnsupportedPolicy);
2781 }
2782 #[cfg(all(
2783 feature = "websocket-review",
2784 feature = "supplied-session-review",
2785 feature = "wordpress-review"
2786 ))]
2787 if self.websocket_supplied_session && self.wordpress_supplied_session {
2788 return Err(WebAssessmentRuntimeError::WebSocketSuppliedSessionWordPressConflict);
2789 }
2790 #[cfg(feature = "wordpress-review")]
2791 if self.wordpress_review.is_some() && !wordpress_application_target_is_valid(&self.target) {
2792 return Err(WebAssessmentRuntimeError::WordPressReviewRequiresOriginRoot);
2793 }
2794 #[cfg(feature = "wordpress-review")]
2795 if self.wordpress_review.is_some() && self.target.path() != "/" && !self.wordpress_discovery
2796 {
2797 return Err(WebAssessmentRuntimeError::WordPressNonRootRequiresDiscovery);
2798 }
2799 #[cfg(feature = "authorization-review")]
2800 if self.root_authorization_context.is_some() && self.resource_authorization_review.is_some()
2801 {
2802 return Err(WebAssessmentRuntimeError::AuthorizationReviewConflict);
2803 }
2804 if self.root_authorization_context.is_some()
2805 && (!self.target.username().is_empty()
2806 || self.target.password().is_some()
2807 || self.target.path() != "/"
2808 || self.target.query().is_some()
2809 || self.target.fragment().is_some())
2810 {
2811 return Err(WebAssessmentRuntimeError::RootAuthorizationContextRequiresOriginRoot);
2812 }
2813 let semantic_limits = assessment_semantic_limits(self.limits)?;
2814 HttpProbe::new(self.target.clone(), HttpProbeMethod::Get)?;
2818 let root = canonicalize_root(&self.target, self.limits)
2819 .ok_or(WebAssessmentRuntimeError::InvalidCanonicalTarget)?;
2820 #[cfg(feature = "tls-negotiation-review")]
2821 if self.tls_negotiation_review && !tls_negotiation_review_target_is_supported(&root.url) {
2822 return Err(WebAssessmentRuntimeError::TlsNegotiationReviewComposition);
2823 }
2824 #[cfg(feature = "supplied-session-review")]
2825 if self
2826 .supplied_session_review
2827 .as_ref()
2828 .is_some_and(|(policy, _)| policy.application_url() != &root.url)
2829 {
2830 return Err(WebAssessmentRuntimeError::SuppliedSessionApplicationMismatch);
2831 }
2832 #[cfg(feature = "websocket-review")]
2833 if self
2834 .websocket_review
2835 .as_ref()
2836 .is_some_and(|policy| !policy.is_bound_to_application(&root.url))
2837 {
2838 return Err(WebAssessmentRuntimeError::WebSocketReviewApplicationMismatch);
2839 }
2840 #[cfg(feature = "xml-external-entity-review")]
2841 if self
2842 .xml_external_entity_review
2843 .as_ref()
2844 .is_some_and(|(policy, _)| !policy.is_bound_to_application(&root.url))
2845 {
2846 return Err(WebAssessmentRuntimeError::XmlExternalEntityApplicationMismatch);
2847 }
2848 #[cfg(feature = "supplied-session-review")]
2849 if self.supplied_session_review.is_some() && !authenticated_transport_is_allowed(&root.url)
2850 {
2851 return Err(WebAssessmentRuntimeError::InsecureSuppliedSessionTransport);
2852 }
2853 #[cfg(feature = "jwt-target-acceptance-review")]
2854 if self
2855 .jwt_target_acceptance_review
2856 .as_ref()
2857 .is_some_and(|(policy, _)| policy.application() != &root.url)
2858 {
2859 return Err(WebAssessmentRuntimeError::JwtTargetAcceptanceApplicationMismatch);
2860 }
2861 #[cfg(feature = "jwt-target-acceptance-review")]
2862 if self
2863 .jwt_target_acceptance_review
2864 .as_ref()
2865 .is_some_and(|(policy, _)| {
2866 !authenticated_transport_is_allowed(&root.url)
2867 || !authenticated_transport_is_allowed(policy.resource())
2868 })
2869 {
2870 return Err(WebAssessmentRuntimeError::InsecureJwtTargetAcceptanceTransport);
2871 }
2872 #[cfg(feature = "wordpress-review")]
2873 if self
2874 .wordpress_review
2875 .as_ref()
2876 .and_then(WordPressReviewInputs::context)
2877 .is_some_and(|context| context.root_url() != &root.url)
2878 {
2879 return Err(WebAssessmentRuntimeError::WordPressContextRootMismatch);
2880 }
2881 #[cfg(feature = "wordpress-review")]
2882 if self
2883 .wordpress_review
2884 .as_ref()
2885 .and_then(WordPressReviewInputs::discovery_layout)
2886 .is_some_and(|layout| layout.application_url() != &root.url)
2887 {
2888 return Err(WebAssessmentRuntimeError::WordPressContextRootMismatch);
2889 }
2890 #[cfg(feature = "wordpress-review")]
2891 let wordpress_supplied_session = {
2892 #[cfg(feature = "supplied-session-review")]
2893 {
2894 self.wordpress_supplied_session
2895 }
2896 #[cfg(not(feature = "supplied-session-review"))]
2897 {
2898 false
2899 }
2900 };
2901 #[cfg(feature = "wordpress-review")]
2902 let wordpress_review = self.wordpress_review.map(|inputs| {
2903 WordPressReviewBinding::new(
2904 inputs,
2905 self.wordpress_discovery,
2906 self.wordpress_page_scope,
2907 root.url.clone(),
2908 wordpress_supplied_session,
2909 )
2910 });
2911 #[cfg(feature = "authorization-review")]
2912 if self
2913 .resource_authorization_review
2914 .as_ref()
2915 .is_some_and(|(policy, _)| {
2916 !authenticated_transport_is_allowed(&root.url)
2917 || !authenticated_transport_is_allowed(policy.execution_resource())
2918 })
2919 {
2920 return Err(WebAssessmentRuntimeError::InsecureAuthorizationReviewTransport);
2921 }
2922 if root.url.as_str().len() > self.limits.max_retained_url_bytes() {
2923 return Err(WebAssessmentRuntimeError::RootRetentionLimit);
2924 }
2925 let policy = match self.http_policy {
2926 Some(policy) => policy,
2927 None => HttpEvidencePolicy::for_origin(root.url.clone())?,
2928 }
2929 .restricted_for_web_assessment(&root.url, self.limits.max_response_body_bytes())?;
2930 let discovery_policy = policy.clone();
2931 let optional_active_verifications = {
2932 let allowance = 0_u16;
2933 #[cfg(feature = "graphql-review")]
2934 let allowance = allowance
2935 .checked_add(
2936 u16::from(
2937 self.graphql_review
2938 && self.limits.max_active_verifications()
2939 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2940 ) * GRAPHQL_REVIEW_ACTIVE_VERIFICATION_ALLOWANCE,
2941 )
2942 .expect("compiled GraphQL allowance fits u16");
2943 #[cfg(feature = "authorization-review")]
2944 let allowance = allowance
2945 .checked_add(
2946 u16::from(
2947 self.resource_authorization_review.is_some()
2948 && self.limits.max_active_verifications()
2949 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2950 ) * AUTHORIZATION_REVIEW_ACTIVE_VERIFICATION_ALLOWANCE,
2951 )
2952 .expect("compiled authorization allowance fits u16");
2953 #[cfg(feature = "template-evaluation-review")]
2954 let allowance = allowance
2955 .checked_add(
2956 u16::from(
2957 self.template_evaluation_review
2958 && self.limits.max_active_verifications()
2959 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2960 ) * TEMPLATE_EVALUATION_ACTIVE_VERIFICATION_ALLOWANCE,
2961 )
2962 .expect("compiled template-evaluation allowance fits u16");
2963 #[cfg(feature = "jwt-target-acceptance-review")]
2964 let allowance = allowance
2965 .checked_add(
2966 u16::from(
2967 self.jwt_target_acceptance_review.is_some()
2968 && self.limits.max_active_verifications()
2969 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2970 ) * u16::from(MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS),
2971 )
2972 .expect("compiled JWT target-acceptance allowance fits u16");
2973 #[cfg(feature = "openapi-review")]
2974 let allowance = allowance
2975 .checked_add(
2976 u16::from(
2977 self.openapi_review
2978 && self.limits.max_active_verifications()
2979 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2980 ) * u16::try_from(MAX_OPENAPI_REVIEW_ACTIVE_VERIFICATIONS)
2981 .expect("OpenAPI active-verification allowance fits u16"),
2982 )
2983 .expect("compiled OpenAPI allowance fits u16");
2984 #[cfg(feature = "rest-review")]
2985 let allowance = allowance
2986 .checked_add(
2987 u16::from(
2988 self.rest_review
2989 && self.limits.max_active_verifications()
2990 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
2991 ) * u16::try_from(MAX_REST_REVIEW_ACTIVE_VERIFICATIONS)
2992 .expect("REST active-verification allowance fits u16"),
2993 )
2994 .expect("compiled REST allowance fits u16");
2995 #[cfg(feature = "ssrf-oast-review")]
2996 let allowance = allowance
2997 .checked_add(
2998 u16::from(
2999 ssrf_oast_review.is_some()
3000 && self.limits.max_active_verifications()
3001 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
3002 ) * u16::try_from(MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS)
3003 .expect("SSRF OAST active-verification allowance fits u16"),
3004 )
3005 .expect("compiled SSRF OAST allowance fits u16");
3006 #[cfg(feature = "xml-external-entity-review")]
3007 let allowance = allowance
3008 .checked_add(
3009 u16::from(
3010 self.xml_external_entity_review.is_some()
3011 && self.limits.max_active_verifications()
3012 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
3013 ) * u16::try_from(XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS)
3014 .expect("controlled XML active-verification allowance fits u16"),
3015 )
3016 .expect("compiled controlled XML allowance fits u16");
3017 #[cfg(feature = "tls-negotiation-review")]
3018 let allowance = allowance
3019 .checked_add(
3020 u16::from(
3021 self.tls_negotiation_review
3022 && self.limits.max_active_verifications()
3023 == DEFAULT_WEB_ASSESSMENT_MAX_ACTIVE_VERIFICATIONS,
3024 ) * TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE,
3025 )
3026 .expect("compiled active TLS negotiation allowance fits u16");
3027 allowance
3028 };
3029 let runtime_active_verification_limit = self
3030 .limits
3031 .max_active_verifications()
3032 .checked_add(optional_active_verifications)
3033 .expect("compiled optional active-verification allowances fit u16");
3034 #[cfg(feature = "xml-external-entity-owned-https-test-profile")]
3035 let owned_xml_https_test_profile =
3036 self.xml_external_entity_review
3037 .as_ref()
3038 .and_then(|(policy, _)| {
3039 OwnedXmlHttpsTestTransportProfile::for_application_and_policy(&root.url, policy)
3040 });
3041 #[cfg(all(
3042 feature = "tls-observation",
3043 feature = "xml-external-entity-owned-https-test-profile"
3044 ))]
3045 let authority = match (self.tls_observation, owned_xml_https_test_profile) {
3046 (true, Some(profile)) => SharedWebRuntimeAuthority::new_exact_origin_with_tls_observation_and_owned_xml_https_test_profile(
3047 &root.url,
3048 policy,
3049 self.limits.runtime_budget(optional_active_verifications),
3050 self.cancellation,
3051 profile,
3052 )?,
3053 (false, Some(profile)) => SharedWebRuntimeAuthority::new_exact_origin_with_owned_xml_https_test_profile(
3054 &root.url,
3055 policy,
3056 self.limits.runtime_budget(optional_active_verifications),
3057 self.cancellation,
3058 profile,
3059 )?,
3060 (true, None) => SharedWebRuntimeAuthority::new_exact_origin_with_tls_observation(
3061 &root.url,
3062 policy,
3063 self.limits.runtime_budget(optional_active_verifications),
3064 self.cancellation,
3065 )?,
3066 (false, None) => SharedWebRuntimeAuthority::new_exact_origin(
3067 &root.url,
3068 policy,
3069 self.limits.runtime_budget(optional_active_verifications),
3070 self.cancellation,
3071 )?,
3072 };
3073 #[cfg(all(
3074 feature = "tls-observation",
3075 not(feature = "xml-external-entity-owned-https-test-profile")
3076 ))]
3077 let authority = if self.tls_observation {
3078 SharedWebRuntimeAuthority::new_exact_origin_with_tls_observation(
3079 &root.url,
3080 policy,
3081 self.limits.runtime_budget(optional_active_verifications),
3082 self.cancellation,
3083 )?
3084 } else {
3085 SharedWebRuntimeAuthority::new_exact_origin(
3086 &root.url,
3087 policy,
3088 self.limits.runtime_budget(optional_active_verifications),
3089 self.cancellation,
3090 )?
3091 };
3092 #[cfg(all(
3093 not(feature = "tls-observation"),
3094 feature = "xml-external-entity-owned-https-test-profile"
3095 ))]
3096 let authority = match owned_xml_https_test_profile {
3097 Some(profile) => {
3098 SharedWebRuntimeAuthority::new_exact_origin_with_owned_xml_https_test_profile(
3099 &root.url,
3100 policy,
3101 self.limits.runtime_budget(optional_active_verifications),
3102 self.cancellation,
3103 profile,
3104 )?
3105 },
3106 None => SharedWebRuntimeAuthority::new_exact_origin(
3107 &root.url,
3108 policy,
3109 self.limits.runtime_budget(optional_active_verifications),
3110 self.cancellation,
3111 )?,
3112 };
3113 #[cfg(all(
3114 not(feature = "tls-observation"),
3115 not(feature = "xml-external-entity-owned-https-test-profile")
3116 ))]
3117 let authority = SharedWebRuntimeAuthority::new_exact_origin(
3118 &root.url,
3119 policy,
3120 self.limits.runtime_budget(optional_active_verifications),
3121 self.cancellation,
3122 )?;
3123 #[cfg(feature = "xml-external-entity-review")]
3124 let xml_external_entity_review = self
3125 .xml_external_entity_review
3126 .map(|(policy, administrator)| {
3127 authority
3128 .authorize_target(policy.endpoint())
3129 .map_err(|_| WebAssessmentRuntimeError::XmlExternalEntityApplicationMismatch)?;
3130 Ok::<_, WebAssessmentRuntimeError>(XmlExternalEntityRuntimeConfig::new(
3131 policy,
3132 administrator,
3133 ))
3134 })
3135 .transpose()?;
3136 #[cfg(feature = "supplied-session-review")]
3137 let supplied_session_review = self
3138 .supplied_session_review
3139 .map(|(policy, credential)| {
3140 SuppliedSessionRuntimeConfig::new(policy, credential, &authority)
3141 .map_err(|()| WebAssessmentRuntimeError::SuppliedSessionComposition)
3142 })
3143 .transpose()?;
3144 #[cfg(feature = "jwt-target-acceptance-review")]
3145 let jwt_target_acceptance_review = self
3146 .jwt_target_acceptance_review
3147 .map(|(policy, runtime_input)| {
3148 JwtTargetAcceptanceRuntimeConfig::new(policy, runtime_input)
3149 .map_err(|_| WebAssessmentRuntimeError::JwtTargetAcceptanceComposition)
3150 })
3151 .transpose()?;
3152 let root_subject = WebAssessmentSubject {
3153 url: root.url.clone(),
3154 method: WebAssessmentMethod::Get,
3155 depth: 0,
3156 origin: WebAssessmentSubjectOrigin::AuthorizedRoot,
3157 query_parameter_names: root.query_parameter_names,
3158 evidence_ids: Vec::new(),
3159 };
3160 let root_api_visibility = self
3161 .root_authorization_context
3162 .map(|context| {
3163 let resource_scope = EntityId::new(format!("endpoint:{}", root.url))
3164 .map_err(|_| WebAssessmentRuntimeError::ApiVisibilityComposition)?;
3165 build_root_api_visibility_runtime(
3166 &root.url,
3167 resource_scope,
3168 authority.clone(),
3169 context,
3170 )
3171 .map_err(|_| WebAssessmentRuntimeError::ApiVisibilityComposition)
3172 })
3173 .transpose()?;
3174 #[cfg(feature = "authorization-review")]
3175 let resource_authorization_review =
3176 self.resource_authorization_review
3177 .map(|(policy, principals)| {
3178 let config = ResourceAuthorizationReviewConfig::new(policy, principals);
3179 #[cfg(feature = "secret-exposure-review")]
3180 let config = if self.secret_exposure_review {
3181 config.with_value_free_response_privacy()
3182 } else {
3183 config
3184 };
3185 config
3186 });
3187 let native_review = if self.low_risk_differential_review {
3188 let seeds = NativeWebReviewSeeds::from_authorized_origin(&root.url)?;
3189 let redirect_query_parameter =
3190 select_redirect_review_query_parameter(&root_subject.query_parameter_names);
3191 let sql_query_parameter =
3192 select_sql_review_query_parameter(&root_subject.query_parameter_names);
3193 let ssti_query_parameter =
3194 select_ssti_review_query_parameter(&root_subject.query_parameter_names);
3195 #[cfg(feature = "template-evaluation-review")]
3196 let template_evaluation_query_parameter = self
3197 .template_evaluation_review
3198 .then(|| ssti_query_parameter.clone())
3199 .flatten();
3200 let reflection_query_parameter =
3201 select_reflection_review_query_parameter(&root_subject.query_parameter_names);
3202 let observer = AssessmentReviewObserverSet::new_with_sql(
3203 root.url.clone(),
3204 seeds.clone(),
3205 redirect_query_parameter.as_deref(),
3206 reflection_query_parameter.as_deref(),
3207 sql_query_parameter.as_deref(),
3208 ssti_query_parameter.as_deref(),
3209 )
3210 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3211 #[cfg(feature = "template-evaluation-review")]
3212 let observer = match template_evaluation_query_parameter.as_deref() {
3213 Some(parameter) => observer
3214 .with_template_evaluation(parameter)
3215 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?,
3216 None => observer,
3217 };
3218 #[cfg(feature = "secret-exposure-review")]
3219 let observer = if self.secret_exposure_review {
3220 observer.with_secret_exposure_privacy_guard()
3221 } else {
3222 observer
3223 };
3224 let observer = Arc::new(observer);
3225 let ledger = CommittedAssessmentReviewLedger::new_with_sql(
3226 root.url.clone(),
3227 seeds.clone(),
3228 redirect_query_parameter.as_deref(),
3229 reflection_query_parameter.as_deref(),
3230 sql_query_parameter.as_deref(),
3231 ssti_query_parameter.as_deref(),
3232 )
3233 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3234 #[cfg(feature = "template-evaluation-review")]
3235 let ledger = match template_evaluation_query_parameter.as_deref() {
3236 Some(parameter) => ledger
3237 .with_template_evaluation(parameter)
3238 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?,
3239 None => ledger,
3240 };
3241 #[cfg(feature = "template-evaluation-review")]
3242 let enabled_actions = enabled_native_web_review_actions_with_template(
3243 true,
3244 redirect_query_parameter.is_some(),
3245 reflection_query_parameter.is_some(),
3246 sql_query_parameter.is_some(),
3247 ssti_query_parameter.is_some(),
3248 template_evaluation_query_parameter.is_some(),
3249 None,
3250 );
3251 #[cfg(not(feature = "template-evaluation-review"))]
3252 let enabled_actions = enabled_native_web_review_actions(
3253 true,
3254 redirect_query_parameter.is_some(),
3255 reflection_query_parameter.is_some(),
3256 sql_query_parameter.is_some(),
3257 ssti_query_parameter.is_some(),
3258 None,
3259 );
3260 Some(AssessmentNativeReviewRuntime {
3261 target: root.url.clone(),
3262 seeds,
3263 enabled_actions,
3264 redirect_query_parameter,
3265 reflection_query_parameter,
3266 sql_query_parameter,
3267 ssti_query_parameter,
3268 #[cfg(feature = "template-evaluation-review")]
3269 template_evaluation_query_parameter,
3270 observer,
3271 ledger,
3272 })
3273 } else {
3274 None
3275 };
3276 let ledger = AssessmentLedger::new(&root_subject);
3277 let mut initial_reasons = BTreeSet::new();
3278 if root.query_name_limit_reached {
3279 initial_reasons.insert(WebAssessmentIncompleteReason::QueryParameterNameLimit);
3280 }
3281 Ok(WebAssessmentRuntime {
3282 limits: self.limits,
3283 runtime_active_verification_limit,
3284 runtime_optional_active_verification_allowance: optional_active_verifications,
3285 semantic_limits,
3286 semantic_evidence: AssessmentSemanticEvidence::default(),
3287 authority,
3288 discovery_policy,
3289 ledger,
3290 root: root_subject,
3291 initial_reasons,
3292 started: false,
3293 progress: None,
3294 defense_enforcement: self.defense_enforcement,
3295 #[cfg(feature = "normalization-resilience")]
3296 normalization_resilience: self.normalization_resilience,
3297 #[cfg(feature = "template-evaluation-review")]
3298 template_evaluation_review: self.template_evaluation_review,
3299 #[cfg(feature = "tls-negotiation-review")]
3300 tls_negotiation_review: self.tls_negotiation_review,
3301 #[cfg(feature = "graphql-review")]
3302 graphql_review: self.graphql_review,
3303 #[cfg(feature = "authorization-review")]
3304 resource_authorization_review,
3305 #[cfg(feature = "jwt-target-acceptance-review")]
3306 jwt_target_acceptance_review,
3307 #[cfg(feature = "openapi-review")]
3308 openapi_review: self.openapi_review.then(|| {
3309 let config = OpenApiReviewConfig::new(
3310 select_openapi_candidate(&root.url, []).expect("fixed exact-origin fallback"),
3311 );
3312 #[cfg(feature = "secret-exposure-review")]
3313 let config = if self.secret_exposure_review {
3314 config.with_value_free_response_privacy()
3315 } else {
3316 config
3317 };
3318 config
3319 }),
3320 #[cfg(feature = "rest-review")]
3321 rest_review: self.rest_review,
3322 #[cfg(feature = "ssrf-oast-review")]
3323 ssrf_oast_review,
3324 #[cfg(feature = "xml-external-entity-review")]
3325 xml_external_entity_review,
3326 #[cfg(feature = "supplied-session-review")]
3327 supplied_session_review,
3328 #[cfg(feature = "wordpress-review")]
3329 wordpress_review,
3330 #[cfg(feature = "recon-ct-provider")]
3331 recon_ct_provider: self.recon_ct_provider,
3332 #[cfg(feature = "websocket-review")]
3333 websocket_review: self.websocket_review,
3334 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
3335 websocket_supplied_session: self.websocket_supplied_session,
3336 #[cfg(feature = "secret-exposure-review")]
3337 secret_exposure_collector: self
3338 .secret_exposure_review
3339 .then(SecretExposureCollector::default),
3340 #[cfg(feature = "secret-exposure-review")]
3341 secret_exposure_ledger: CommittedSecretExposureLedger::default(),
3342 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
3343 wordpress_supplied_session,
3344 native_review,
3345 non_root_structural_review: None,
3346 xss_structural_review: None,
3347 #[cfg(feature = "normalization-resilience")]
3348 normalization_review: None,
3349 #[cfg(feature = "template-evaluation-review")]
3350 template_evaluation_review_audit: None,
3351 #[cfg(feature = "tls-negotiation-review")]
3352 tls_negotiation_review_audit: None,
3353 root_api_visibility,
3354 committed_api_visibility: None,
3355 #[cfg(feature = "graphql-review")]
3356 committed_graphql_review: None,
3357 #[cfg(feature = "authorization-review")]
3358 committed_resource_authorization_review: None,
3359 #[cfg(feature = "authorization-review")]
3360 authorization_review_audit: None,
3361 #[cfg(feature = "jwt-target-acceptance-review")]
3362 jwt_target_acceptance_audit: None,
3363 #[cfg(feature = "openapi-review")]
3364 committed_openapi_review: None,
3365 #[cfg(feature = "openapi-review")]
3366 openapi_review_audit: None,
3367 #[cfg(feature = "rest-review")]
3368 committed_rest_review: None,
3369 #[cfg(feature = "rest-review")]
3370 rest_review_audit: None,
3371 #[cfg(feature = "ssrf-oast-review")]
3372 committed_ssrf_oast_review: None,
3373 #[cfg(feature = "ssrf-oast-review")]
3374 ssrf_oast_review_audit: None,
3375 #[cfg(feature = "xml-external-entity-review")]
3376 committed_xml_external_entity_review: None,
3377 #[cfg(feature = "xml-external-entity-review")]
3378 xml_external_entity_review_audit: None,
3379 #[cfg(feature = "supplied-session-review")]
3380 supplied_session_audit: None,
3381 #[cfg(feature = "websocket-review")]
3382 websocket_review_audit: None,
3383 #[cfg(feature = "wordpress-review")]
3384 committed_wordpress_review: None,
3385 #[cfg(feature = "graphql-review")]
3386 optional_child_parent_defense: None,
3387 defense_audit: WebAssessmentDefenseAudit::new(if self.defense_enforcement {
3388 WebAssessmentDefenseMode::Enforced
3389 } else {
3390 WebAssessmentDefenseMode::ObservationOnly
3391 }),
3392 passive_ledger: CommittedAssessmentPassiveLedger::default(),
3393 })
3394 }
3395}
3396
3397pub struct WebAssessmentRuntime {
3399 limits: WebAssessmentLimits,
3400 runtime_active_verification_limit: u16,
3401 runtime_optional_active_verification_allowance: u16,
3402 semantic_limits: SemanticExtractionLimits,
3403 semantic_evidence: AssessmentSemanticEvidence,
3404 authority: SharedWebRuntimeAuthority,
3405 discovery_policy: HttpEvidencePolicy,
3406 ledger: AssessmentLedger,
3407 root: WebAssessmentSubject,
3408 initial_reasons: BTreeSet<WebAssessmentIncompleteReason>,
3409 started: bool,
3410 progress: Option<watch::Sender<Option<WebAssessmentProgressSnapshot>>>,
3411 defense_enforcement: bool,
3412 #[cfg(feature = "normalization-resilience")]
3413 normalization_resilience: bool,
3414 #[cfg(feature = "template-evaluation-review")]
3415 template_evaluation_review: bool,
3416 #[cfg(feature = "tls-negotiation-review")]
3417 tls_negotiation_review: bool,
3418 #[cfg(feature = "graphql-review")]
3419 graphql_review: bool,
3420 #[cfg(feature = "authorization-review")]
3421 resource_authorization_review: Option<ResourceAuthorizationReviewConfig>,
3422 #[cfg(feature = "jwt-target-acceptance-review")]
3423 jwt_target_acceptance_review: Option<JwtTargetAcceptanceRuntimeConfig>,
3424 #[cfg(feature = "openapi-review")]
3425 openapi_review: Option<OpenApiReviewConfig>,
3426 #[cfg(feature = "rest-review")]
3427 rest_review: bool,
3428 #[cfg(feature = "ssrf-oast-review")]
3429 ssrf_oast_review: Option<SsrfOastReviewConfig>,
3430 #[cfg(feature = "xml-external-entity-review")]
3431 xml_external_entity_review: Option<XmlExternalEntityRuntimeConfig>,
3432 #[cfg(feature = "supplied-session-review")]
3433 supplied_session_review: Option<SuppliedSessionRuntimeConfig>,
3434 #[cfg(feature = "wordpress-review")]
3435 wordpress_review: Option<WordPressReviewBinding>,
3436 #[cfg(feature = "recon-ct-provider")]
3437 recon_ct_provider: Option<ReconCtProviderPolicy>,
3438 #[cfg(feature = "websocket-review")]
3439 websocket_review: Option<WebSocketReviewPolicy>,
3440 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
3441 websocket_supplied_session: bool,
3442 #[cfg(feature = "secret-exposure-review")]
3443 secret_exposure_collector: Option<SecretExposureCollector>,
3444 #[cfg(feature = "secret-exposure-review")]
3445 secret_exposure_ledger: CommittedSecretExposureLedger,
3446 #[cfg(all(feature = "wordpress-review", feature = "supplied-session-review"))]
3447 wordpress_supplied_session: bool,
3448 native_review: Option<AssessmentNativeReviewRuntime>,
3449 non_root_structural_review: Option<AssessmentNativeReviewRuntime>,
3450 xss_structural_review: Option<AssessmentNativeReviewRuntime>,
3451 #[cfg(feature = "normalization-resilience")]
3452 normalization_review: Option<AssessmentNativeReviewRuntime>,
3453 #[cfg(feature = "template-evaluation-review")]
3454 template_evaluation_review_audit: Option<TemplateEvaluationReviewAudit>,
3455 #[cfg(feature = "tls-negotiation-review")]
3456 tls_negotiation_review_audit: Option<WebAssessmentTlsNegotiationReviewAudit>,
3457 root_api_visibility: Option<RootApiVisibilityRuntime>,
3458 committed_api_visibility: Option<CommittedAssessmentApiVisibility>,
3459 #[cfg(feature = "graphql-review")]
3460 committed_graphql_review: Option<CommittedGraphqlReview>,
3461 #[cfg(feature = "authorization-review")]
3462 committed_resource_authorization_review: Option<CommittedResourceAuthorizationReview>,
3463 #[cfg(feature = "openapi-review")]
3464 committed_openapi_review: Option<CommittedOpenApiReview>,
3465 #[cfg(feature = "authorization-review")]
3466 authorization_review_audit: Option<WebAssessmentAuthorizationAudit>,
3467 #[cfg(feature = "jwt-target-acceptance-review")]
3468 jwt_target_acceptance_audit: Option<JwtTargetAcceptanceAudit>,
3469 #[cfg(feature = "openapi-review")]
3470 openapi_review_audit: Option<WebAssessmentOpenApiAudit>,
3471 #[cfg(feature = "rest-review")]
3472 committed_rest_review: Option<CommittedRestReview>,
3473 #[cfg(feature = "rest-review")]
3474 rest_review_audit: Option<WebAssessmentRestAudit>,
3475 #[cfg(feature = "ssrf-oast-review")]
3476 committed_ssrf_oast_review: Option<CommittedSsrfOastReview>,
3477 #[cfg(feature = "ssrf-oast-review")]
3478 ssrf_oast_review_audit: Option<WebAssessmentSsrfOastAudit>,
3479 #[cfg(feature = "xml-external-entity-review")]
3480 committed_xml_external_entity_review: Option<CommittedXmlExternalEntityReview>,
3481 #[cfg(feature = "xml-external-entity-review")]
3482 xml_external_entity_review_audit: Option<XmlExternalEntityReviewAudit>,
3483 #[cfg(feature = "supplied-session-review")]
3484 supplied_session_audit: Option<WebAssessmentSuppliedSessionAudit>,
3485 #[cfg(feature = "websocket-review")]
3486 websocket_review_audit: Option<WebAssessmentWebSocketReviewAudit>,
3487 #[cfg(feature = "wordpress-review")]
3488 committed_wordpress_review: Option<CommittedWordPressReview>,
3489 #[cfg(feature = "graphql-review")]
3490 optional_child_parent_defense: Option<AssessmentDefenseController>,
3491 defense_audit: WebAssessmentDefenseAudit,
3492 passive_ledger: CommittedAssessmentPassiveLedger,
3493}
3494
3495struct AssessmentNativeReviewRuntime {
3496 target: Url,
3497 seeds: NativeWebReviewSeeds,
3498 redirect_query_parameter: Option<String>,
3499 reflection_query_parameter: Option<String>,
3500 sql_query_parameter: Option<String>,
3501 ssti_query_parameter: Option<String>,
3502 #[cfg(feature = "template-evaluation-review")]
3503 template_evaluation_query_parameter: Option<String>,
3504 observer: Arc<AssessmentReviewObserverSet>,
3505 ledger: CommittedAssessmentReviewLedger,
3506 enabled_actions: Vec<NativeWebReviewActionKind>,
3507}
3508
3509struct FailedSubjectBoundary {
3510 subject: WebAssessmentSubject,
3511 envelope: AssessmentEnvelope,
3512 source: StandardWebDecisionRuntimeError,
3513 started: bool,
3514 defense: Option<AssessmentDefenseController>,
3515 planner: Option<crate::AttackPlanner>,
3516}
3517
3518fn receipt_requires_defense_projection(receipt: &DecisionEvidenceReceipt) -> bool {
3519 receipt.case().action_id() != StandardWebActionKind::LaravelInputAnalysis.action_id()
3520}
3521
3522fn replay_defense_parts(
3523 bootstrap: Option<&DecisionEvidenceReceipt>,
3524 turns: &[StandardWebDecisionRuntimeTurn],
3525 unverified: Option<&DecisionEvidenceReceipt>,
3526 planner: &crate::AttackPlanner,
3527 knowledge: &KnowledgeBase,
3528 enforcement_enabled: bool,
3529) -> Result<AssessmentDefenseController, ()> {
3530 let mut replay = AssessmentDefenseController::new(enforcement_enabled);
3531 if let Some(receipt) = bootstrap {
3532 replay.ingest_receipt(receipt, knowledge, true)?;
3533 }
3534 for turn in turns {
3535 match turn {
3536 StandardWebDecisionRuntimeTurn::Planning(report) => {
3537 replay.record_shadow(report, planner)?;
3538 },
3539 StandardWebDecisionRuntimeTurn::Outcome { evidence, .. } => {
3540 replay.ingest_receipt(
3541 evidence,
3542 knowledge,
3543 receipt_requires_defense_projection(evidence),
3544 )?;
3545 },
3546 }
3547 }
3548 if let Some(receipt) = unverified {
3549 replay.ingest_receipt(
3550 receipt,
3551 knowledge,
3552 receipt_requires_defense_projection(receipt),
3553 )?;
3554 }
3555 Ok(replay)
3556}
3557
3558fn replay_standard_defense(
3559 report: &crate::StandardWebDecisionRunReport,
3560 planner: &crate::AttackPlanner,
3561 knowledge: &KnowledgeBase,
3562 enforcement_enabled: bool,
3563) -> Result<AssessmentDefenseController, ()> {
3564 replay_defense_parts(
3565 report.bootstrap(),
3566 report.turns(),
3567 report.unverified_evidence(),
3568 planner,
3569 knowledge,
3570 enforcement_enabled,
3571 )
3572}
3573
3574#[cfg(test)]
3575fn is_native_review_action(action_id: &str) -> bool {
3576 NativeWebReviewActionKind::all()
3577 .into_iter()
3578 .any(|kind| kind.action_id() == action_id)
3579}
3580
3581fn replay_native_review(
3582 report: &crate::StandardWebDecisionRunReport,
3583 review: &mut AssessmentNativeReviewRuntime,
3584 knowledge: &KnowledgeBase,
3585) -> Result<bool, ()> {
3586 for turn in report.turns() {
3587 if let StandardWebDecisionRuntimeTurn::Outcome { evidence, decision } = turn {
3588 if review
3589 .enabled_actions
3590 .iter()
3591 .any(|kind| kind.action_id() == evidence.case().action_id())
3592 {
3593 review
3594 .ledger
3595 .ingest_outcome(evidence, decision, knowledge)
3596 .map_err(|_| ())?;
3597 }
3598 }
3599 }
3600
3601 let enabled_complete = review
3602 .enabled_actions
3603 .iter()
3604 .copied()
3605 .all(|kind| review.ledger.pair_is_complete(kind));
3606 let expected_observations = review.enabled_actions.len() * 2;
3607 Ok(enabled_complete
3608 && !review.ledger.has_incomplete_reflection_observation()
3609 && !review.ledger.has_incomplete_sql_observation()
3610 && !review.ledger.has_incomplete_ssti_observation()
3611 && review.ledger.observations().len() == expected_observations)
3612}
3613
3614#[cfg(feature = "template-evaluation-review")]
3615fn template_evaluation_audit_from_committed_runtime(
3616 review: Option<&AssessmentNativeReviewRuntime>,
3617 transport: &TransportDispatchAudit,
3618) -> Result<TemplateEvaluationReviewAudit, ()> {
3619 if transport.omitted_receipt_count() != 0 {
3620 return Err(());
3621 }
3622 let primary = NativeWebReviewActionKind::TemplateJinjaUppercaseQueryPair.action_id();
3623 let replay = NativeWebReviewActionKind::TemplateJinjaUppercaseQueryReplayPair.action_id();
3624 let receipts = transport
3625 .receipts()
3626 .iter()
3627 .filter(|receipt| {
3628 let action = receipt.action_id();
3629 action == primary || action == replay
3630 })
3631 .collect::<Vec<_>>();
3632 if receipts.len() > usize::from(TEMPLATE_EVALUATION_MAX_REQUESTS) {
3633 return Err(());
3634 }
3635 let attempted_request_count = u8::try_from(receipts.len()).map_err(|_| ())?;
3636 let active_request_count = u8::try_from(
3637 receipts
3638 .iter()
3639 .filter(|receipt| receipt.stage() == DecisionExecutionStage::Active)
3640 .count(),
3641 )
3642 .map_err(|_| ())?;
3643 let completed_response_count = u8::try_from(
3644 receipts
3645 .iter()
3646 .filter(|receipt| receipt.outcome() == TransportDispatchOutcome::Completed)
3647 .count(),
3648 )
3649 .map_err(|_| ())?;
3650
3651 let summary = review.and_then(|review| review.ledger.template_evaluation_summary());
3652 let facts = match summary {
3653 Some(summary) => TemplateEvaluationReviewAuditFacts {
3654 outcome: summary.outcome(),
3655 selected_case_count: TEMPLATE_EVALUATION_SELECTED_CASES,
3656 attempted_request_count,
3657 active_request_count,
3658 completed_response_count,
3659 committed_response_count: summary.committed_response_count(),
3660 projected_item_count: summary.projected_item_count(),
3661 },
3662 None if receipts.is_empty() => TemplateEvaluationReviewAuditFacts {
3663 outcome: TemplateEvaluationReviewOutcome::ParentNotObserved,
3664 selected_case_count: 0,
3665 attempted_request_count: 0,
3666 active_request_count: 0,
3667 completed_response_count: 0,
3668 committed_response_count: 0,
3669 projected_item_count: 0,
3670 },
3671 None => return Err(()),
3672 };
3673 TemplateEvaluationReviewAudit::from_runtime(facts).map_err(|_| ())
3674}
3675
3676impl WebAssessmentRuntime {
3677 pub fn builder(target: Url) -> WebAssessmentRuntimeBuilder {
3678 WebAssessmentRuntimeBuilder::new(target)
3679 }
3680 pub fn limits(&self) -> WebAssessmentLimits {
3681 self.limits
3682 }
3683 pub fn authorized_root(&self) -> &WebAssessmentSubject {
3684 &self.root
3685 }
3686 pub fn knowledge(&self) -> &KnowledgeBase {
3687 self.authority.knowledge()
3688 }
3689 pub fn cancellation_token(&self) -> CancellationToken {
3690 self.authority.cancellation_token()
3691 }
3692 pub fn has_started(&self) -> bool {
3693 self.started
3694 }
3695
3696 pub fn progress_observer(&mut self) -> Option<WebAssessmentProgressObserver> {
3702 if self.started || self.progress.is_some() {
3703 return None;
3704 }
3705 let (sender, receiver) = watch::channel(None);
3706 self.progress = Some(sender);
3707 Some(WebAssessmentProgressObserver { receiver })
3708 }
3709
3710 fn publish_progress(
3711 &self,
3712 checkpoint: WebAssessmentProgressCheckpoint,
3713 executed_subjects: usize,
3714 subjects_processed: usize,
3715 ) {
3716 let Some(progress) = &self.progress else {
3717 return;
3718 };
3719 let accounting = self.authority.request_accounting().snapshot();
3720 progress.send_replace(Some(WebAssessmentProgressSnapshot {
3721 checkpoint,
3722 executed_subjects,
3723 subjects_processed,
3724 requests_accounted: accounting.total_requests(),
3725 active_verifications_accounted: accounting.active_verifications(),
3726 }));
3727 }
3728
3729 pub async fn analyze(&mut self) -> Result<WebAssessmentRunReport, WebAssessmentRuntimeError> {
3730 if self.started {
3731 return Err(WebAssessmentRuntimeError::AlreadyStarted);
3732 }
3733 self.started = true;
3734 let child_authority = self.authority.clone();
3735 let compose_child = |builder: super::StandardWebDecisionRuntimeBuilder| {
3736 builder.build_with_shared_authority(child_authority.clone())
3737 };
3738 #[cfg(feature = "reporting")]
3739 let run_started_at = SystemTime::now();
3740 let timing = self.authority.start();
3741 let started_at = timing.started_at();
3742 let mut reasons = self.initial_reasons.clone();
3743 let mut forms = Vec::new();
3744 let mut subject_reports = Vec::new();
3745 let mut known_subjects = BTreeMap::from([(self.root.url.to_string(), self.root.clone())]);
3746 let mut current_layer = vec![self.root.clone()];
3747 let mut stop = false;
3748 let mut progress_executed_subjects = 0_usize;
3749 let mut progress_processed_subjects = 0_usize;
3750 self.publish_progress(
3751 WebAssessmentProgressCheckpoint::AssessmentStarted,
3752 progress_executed_subjects,
3753 progress_processed_subjects,
3754 );
3755
3756 #[cfg(feature = "supplied-session-review")]
3762 let defer_supplied_session = {
3763 let defer_for_wordpress = {
3764 #[cfg(feature = "wordpress-review")]
3765 {
3766 self.wordpress_supplied_session
3767 }
3768 #[cfg(not(feature = "wordpress-review"))]
3769 {
3770 false
3771 }
3772 };
3773 #[cfg(feature = "websocket-review")]
3774 {
3775 defer_for_wordpress || self.websocket_supplied_session
3776 }
3777 #[cfg(not(feature = "websocket-review"))]
3778 {
3779 defer_for_wordpress
3780 }
3781 };
3782 #[cfg(feature = "supplied-session-review")]
3783 if !defer_supplied_session {
3784 if let Some(session) = self.supplied_session_review.take() {
3785 self.supplied_session_audit =
3786 Some(boxed_session_review(session, &self.authority, None).await);
3787 }
3788 }
3789 #[cfg(all(feature = "websocket-review", feature = "supplied-session-review"))]
3790 if self.websocket_supplied_session {
3791 let session = self
3792 .supplied_session_review
3793 .take()
3794 .expect("combined supplied-session inputs were validated during build");
3795 let websocket = self
3796 .websocket_review
3797 .take()
3798 .expect("combined WebSocket inputs were validated during build");
3799 let (session_audit, websocket_audit) =
3800 boxed_session_websocket(session, &self.authority, &websocket).await;
3801 self.supplied_session_audit = Some(session_audit);
3802 self.websocket_review_audit = Some(websocket_audit);
3803 }
3804
3805 #[cfg(feature = "jwt-target-acceptance-review")]
3811 if let Some(review) = self.jwt_target_acceptance_review.take() {
3812 match boxed_optional_child(|| review.execute(&self.authority)).await {
3813 Ok(audit) => self.jwt_target_acceptance_audit = Some(audit),
3814 Err(failure) => {
3815 self.jwt_target_acceptance_audit = failure.into_partial_audit();
3816 return Err(WebAssessmentRuntimeError::JwtTargetAcceptanceExecution {
3817 receipt: self.failure_receipt(
3818 &known_subjects,
3819 subject_reports,
3820 forms,
3821 WebAssessmentSubjectReport::failed(
3822 self.root.clone(),
3823 StandardWebDecisionAssessmentFailureParts::default(),
3824 false,
3825 ),
3826 failed_reasons(&reasons),
3827 started_at,
3828 ),
3829 });
3830 },
3831 }
3832 }
3833
3834 #[cfg(feature = "xml-external-entity-review")]
3839 if let Some(review) = self.xml_external_entity_review.take() {
3840 let subject = EntityId::new(format!("endpoint:{}", self.root.url()))
3841 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3842 let selected_application = self.root.url().clone();
3843 match boxed_optional_child(|| {
3844 review.execute(&self.authority, subject, &selected_application)
3845 })
3846 .await
3847 {
3848 Ok(XmlExternalEntityRuntimeResult::Complete { audit, committed }) => {
3849 self.xml_external_entity_review_audit = Some(audit);
3850 self.committed_xml_external_entity_review = committed;
3851 },
3852 Ok(XmlExternalEntityRuntimeResult::Stopped { audit }) => {
3853 let outcome = audit.outcome();
3854 self.xml_external_entity_review_audit = Some(audit);
3855 if outcome == XmlExternalEntityReviewOutcome::Cancelled {
3856 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
3857 }
3858 reasons
3859 .insert(WebAssessmentIncompleteReason::XmlExternalEntityReviewIncomplete);
3860 },
3861 Err(_) => {
3862 return Err(WebAssessmentRuntimeError::XmlExternalEntityExecution {
3863 receipt: self.failure_receipt(
3864 &known_subjects,
3865 subject_reports,
3866 forms,
3867 WebAssessmentSubjectReport::failed(
3868 self.root.clone(),
3869 StandardWebDecisionAssessmentFailureParts::default(),
3870 false,
3871 ),
3872 failed_reasons(&reasons),
3873 started_at,
3874 ),
3875 });
3876 },
3877 }
3878 }
3879
3880 while !current_layer.is_empty() && !stop {
3881 current_layer.sort_by(|left, right| left.url.as_str().cmp(right.url.as_str()));
3882 let mut next_candidates = BTreeMap::<String, PendingRoute>::new();
3883 for queued_subject in current_layer.drain(..) {
3884 let subject = known_subjects
3885 .get(queued_subject.url.as_str())
3886 .cloned()
3887 .unwrap_or(queued_subject);
3888 if self.authority.cancellation().is_cancelled() {
3889 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
3890 stop = true;
3891 break;
3892 }
3893 if started_at.elapsed() >= self.limits.max_wall_time() {
3894 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
3895 stop = true;
3896 break;
3897 }
3898
3899 let mut envelope = self.ledger.snapshot(self.limits, subject.depth);
3900 let Some(current_envelope_subject) =
3901 envelope.subjects.get_mut(subject.url.as_str())
3902 else {
3903 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
3904 receipt: self.failure_receipt(
3905 &known_subjects,
3906 subject_reports,
3907 forms,
3908 WebAssessmentSubjectReport::failed(
3909 subject,
3910 StandardWebDecisionAssessmentFailureParts::default(),
3911 false,
3912 ),
3913 failed_reasons(&reasons),
3914 started_at,
3915 ),
3916 });
3917 };
3918 current_envelope_subject.executed = true;
3919 if subject.origin == WebAssessmentSubjectOrigin::Discovered
3920 && subject.method == WebAssessmentMethod::Get
3921 && self.non_root_structural_review.is_none()
3922 && self.native_review.as_ref().is_some_and(|review| {
3923 review.sql_query_parameter.is_none()
3924 && review.ssti_query_parameter.is_none()
3925 })
3926 {
3927 if let Some(parameter) =
3928 select_sql_review_query_parameter(&subject.query_parameter_names)
3929 {
3930 let seeds = NativeWebReviewSeeds::from_authorized_origin(&subject.url)?;
3931 let observer = AssessmentReviewObserverSet::new_with_sql(
3932 subject.url.clone(),
3933 seeds.clone(),
3934 None,
3935 Some(¶meter),
3936 Some(¶meter),
3937 Some(¶meter),
3938 )
3939 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3940 #[cfg(feature = "secret-exposure-review")]
3941 let observer = if self.secret_exposure_collector.is_some() {
3942 observer.with_secret_exposure_privacy_guard()
3943 } else {
3944 observer
3945 };
3946 let observer = Arc::new(observer);
3947 let ledger = CommittedAssessmentReviewLedger::new_with_sql(
3948 subject.url.clone(),
3949 seeds.clone(),
3950 None,
3951 Some(¶meter),
3952 Some(¶meter),
3953 Some(¶meter),
3954 )
3955 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
3956 self.non_root_structural_review = Some(AssessmentNativeReviewRuntime {
3957 target: subject.url.clone(),
3958 seeds,
3959 enabled_actions: enabled_native_web_review_actions(
3960 false, false, true, true, true, None,
3961 ),
3962 redirect_query_parameter: None,
3963 reflection_query_parameter: Some(parameter.clone()),
3964 sql_query_parameter: Some(parameter),
3965 ssti_query_parameter: select_ssti_review_query_parameter(
3966 &subject.query_parameter_names,
3967 ),
3968 #[cfg(feature = "template-evaluation-review")]
3969 template_evaluation_query_parameter: None,
3970 observer,
3971 ledger,
3972 });
3973 }
3974 }
3975 let subject_observer = AssessmentDiscoveryObserver::new(
3976 self.discovery_policy.clone(),
3977 self.limits,
3978 envelope.clone(),
3979 &subject,
3980 self.authority.cancellation_token(),
3981 timing.deadline(),
3982 );
3983 #[cfg(feature = "wordpress-review")]
3984 let subject_observer = if subject.url == self.root.url && subject.depth == 0 {
3985 if let Some(review) = self.wordpress_review.as_ref() {
3986 subject_observer.with_wordpress_signals(
3987 review.collector(),
3988 review.discovery_enabled(),
3989 review.discovery_context(),
3990 review.page_scope().is_some(),
3991 review.supplied_session_selected(),
3992 review.asset_fingerprint_collector(),
3993 )
3994 } else {
3995 subject_observer
3996 }
3997 } else if subject.method == WebAssessmentMethod::Get {
3998 if let Some(review) = self.wordpress_review.as_ref() {
3999 let subject_observer = if let Some((candidate, frozen_layout)) =
4000 review.collector().selected_page(&subject.url)
4001 {
4002 subject_observer.with_wordpress_page_observation(
4003 review.collector(),
4004 candidate,
4005 frozen_layout,
4006 review.asset_fingerprint_collector(),
4007 )
4008 } else {
4009 subject_observer
4010 };
4011 if review
4012 .asset_fingerprint_collector()
4013 .is_some_and(|collector| {
4014 collector.selected_observed_asset(&subject.url).is_some()
4015 })
4016 {
4017 subject_observer.with_wordpress_asset_observation(
4018 review
4019 .asset_fingerprint_collector()
4020 .expect("selected fingerprint collector remains present"),
4021 )
4022 } else {
4023 subject_observer
4024 }
4025 } else {
4026 subject_observer
4027 }
4028 } else {
4029 subject_observer
4030 };
4031 #[cfg(feature = "secret-exposure-review")]
4032 let subject_observer =
4033 if let Some(collector) = self.secret_exposure_collector.as_ref() {
4034 subject_observer.with_secret_exposure(collector.clone())
4035 } else {
4036 subject_observer
4037 };
4038 let subject_observer: Arc<dyn CompleteHttpResponseObserver> =
4039 Arc::new(subject_observer);
4040 let builder = StandardWebDecisionRuntime::builder(subject.url.clone())
4041 .with_assessment_response_observer(
4042 subject.method.probe_method(),
4043 subject_observer,
4044 )
4045 .with_assessment_defense_enforcement(self.defense_enforcement);
4046 let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4047 match self.native_review.as_ref() {
4048 Some(review) => {
4049 let observer: Arc<dyn CompleteHttpResponseObserver> =
4050 review.observer.clone();
4051 let builder = builder.with_native_web_review(
4052 review.seeds.clone(),
4053 observer,
4054 review.redirect_query_parameter.clone(),
4055 review.reflection_query_parameter.clone(),
4056 review.sql_query_parameter.clone(),
4057 review.ssti_query_parameter.clone(),
4058 );
4059 #[cfg(feature = "template-evaluation-review")]
4060 let builder = match review.template_evaluation_query_parameter.clone() {
4061 Some(parameter) => {
4062 builder.with_native_template_evaluation_parameter(parameter)
4063 },
4064 None => builder,
4065 };
4066 builder
4067 },
4068 None => builder,
4069 }
4070 } else if let Some(review) = self
4071 .non_root_structural_review
4072 .as_ref()
4073 .filter(|review| review.target == subject.url)
4074 {
4075 let observer: Arc<dyn CompleteHttpResponseObserver> = review.observer.clone();
4076 builder.with_native_structural_review(
4077 review.seeds.clone(),
4078 observer,
4079 review.sql_query_parameter.clone(),
4080 review.ssti_query_parameter.clone(),
4081 review.reflection_query_parameter.clone(),
4082 )
4083 } else {
4084 builder
4085 };
4086 #[cfg(feature = "authorization-review")]
4087 let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4088 match self.resource_authorization_review.take() {
4089 Some(config) => builder.with_resource_authorization_review(config),
4090 None => builder,
4091 }
4092 } else {
4093 builder
4094 };
4095 #[cfg(feature = "openapi-review")]
4096 let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4097 match self.openapi_review.take() {
4098 Some(config) => builder.with_openapi_review(config),
4099 None => builder,
4100 }
4101 } else {
4102 builder
4103 };
4104 #[cfg(feature = "rest-review")]
4105 let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot
4106 && self.rest_review
4107 {
4108 builder.with_rest_review()
4109 } else {
4110 builder
4111 };
4112 #[cfg(feature = "ssrf-oast-review")]
4113 let builder = if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4114 match self.ssrf_oast_review.take() {
4115 Some(config) => builder.with_ssrf_oast_review(config),
4116 None => builder,
4117 }
4118 } else {
4119 builder
4120 };
4121 let mut runtime = match compose_child(builder) {
4122 Ok(runtime) => runtime,
4123 Err(source) => {
4124 return Err(self.run_failed(
4125 &mut known_subjects,
4126 subject_reports,
4127 forms,
4128 FailedSubjectBoundary {
4129 subject,
4130 envelope,
4131 source,
4132 started: false,
4133 defense: None,
4134 planner: None,
4135 },
4136 &reasons,
4137 started_at,
4138 ));
4139 },
4140 };
4141 if self.ledger.mark_executed(&subject).is_err() {
4142 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4143 receipt: self.failure_receipt(
4144 &known_subjects,
4145 subject_reports,
4146 forms,
4147 WebAssessmentSubjectReport::failed(
4148 subject,
4149 StandardWebDecisionAssessmentFailureParts::default(),
4150 false,
4151 ),
4152 failed_reasons(&reasons),
4153 started_at,
4154 ),
4155 });
4156 }
4157 progress_executed_subjects = progress_executed_subjects.saturating_add(1);
4158 self.publish_progress(
4159 WebAssessmentProgressCheckpoint::SubjectExecutionStarted,
4160 progress_executed_subjects,
4161 progress_processed_subjects,
4162 );
4163 let standard_result = boxed_child_analysis(&mut runtime).await;
4164 #[cfg(feature = "authorization-review")]
4165 let resource_authorization_binding = runtime.take_resource_authorization_review();
4166 #[cfg(feature = "openapi-review")]
4167 let openapi_binding = runtime.take_openapi_review();
4168 #[cfg(feature = "rest-review")]
4169 let rest_binding = runtime.take_rest_review();
4170 #[cfg(feature = "ssrf-oast-review")]
4171 let ssrf_oast_binding = runtime.take_ssrf_oast_review();
4172 let defense = runtime.assessment_defense_controller().cloned();
4173 let planner = runtime.assessment_planner().clone();
4174 let standard = match standard_result {
4175 Ok(report) => report,
4176 Err(source) => {
4177 return Err(self.run_failed(
4178 &mut known_subjects,
4179 subject_reports,
4180 forms,
4181 FailedSubjectBoundary {
4182 subject,
4183 envelope,
4184 source,
4185 started: true,
4186 defense,
4187 planner: Some(planner),
4188 },
4189 &reasons,
4190 started_at,
4191 ));
4192 },
4193 };
4194 let replay = replay_standard_defense(
4195 &standard,
4196 &planner,
4197 self.authority.knowledge(),
4198 self.defense_enforcement,
4199 );
4200 let defense_valid = match (defense.as_ref(), replay) {
4201 (Some(expected), Ok(replayed)) if replayed.exact_audit_eq(expected) => {
4202 #[cfg(feature = "graphql-review")]
4203 if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4204 self.optional_child_parent_defense = Some(replayed.clone());
4205 }
4206 self.defense_audit.append_controller(&replayed).is_ok()
4207 },
4208 _ => false,
4209 };
4210 if !defense_valid {
4211 let parts = standard.into_assessment_parts();
4212 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4213 receipt: self.failure_receipt(
4214 &known_subjects,
4215 subject_reports,
4216 forms,
4217 WebAssessmentSubjectReport::complete(subject, parts),
4218 failed_reasons(&reasons),
4219 started_at,
4220 ),
4221 });
4222 }
4223 #[cfg(feature = "authorization-review")]
4224 let mut authorization_hard_stop = false;
4225 #[cfg(feature = "authorization-review")]
4226 if let Some(binding) = resource_authorization_binding {
4227 #[cfg(feature = "secret-exposure-review")]
4228 let expected_value_free = self.secret_exposure_collector.is_some();
4229 #[cfg(not(feature = "secret-exposure-review"))]
4230 let expected_value_free = false;
4231 let forced_outcome = if self.authority.cancellation().is_cancelled() {
4232 Some(AuthorizationReviewOutcome::Cancelled)
4233 } else if standard.limit_exceeded().is_some() {
4234 Some(AuthorizationReviewOutcome::BudgetExhausted)
4235 } else {
4236 None
4237 };
4238 let forced_runtime_limit = standard.limit_exceeded().cloned();
4239 match binding.finalize(
4240 self.authority.knowledge(),
4241 &standard,
4242 expected_value_free,
4243 forced_outcome,
4244 forced_runtime_limit,
4245 ) {
4246 Ok(ResourceAuthorizationRuntimeResult::Complete(committed)) => {
4247 let committed = *committed;
4248 self.authorization_review_audit = Some(committed.audit().clone());
4249 self.committed_resource_authorization_review = Some(committed);
4250 },
4251 Ok(ResourceAuthorizationRuntimeResult::Stopped {
4252 audit,
4253 runtime_limit,
4254 }) => {
4255 let outcome = audit.outcome();
4256 self.authorization_review_audit = Some(audit);
4257 authorization_hard_stop = true;
4258 if let Some(limit) = runtime_limit {
4259 reasons.insert(reason_for_runtime_dimension(limit.dimension()));
4260 }
4261 if outcome == AuthorizationReviewOutcome::Cancelled {
4262 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4263 }
4264 reasons.insert(
4265 WebAssessmentIncompleteReason::AuthorizationReviewIncomplete,
4266 );
4267 },
4268 Err(_) => {
4269 let parts = standard.into_assessment_parts();
4270 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4271 receipt: self.failure_receipt(
4272 &known_subjects,
4273 subject_reports,
4274 forms,
4275 WebAssessmentSubjectReport::complete(subject, parts),
4276 failed_reasons(&reasons),
4277 started_at,
4278 ),
4279 });
4280 },
4281 }
4282 }
4283 #[cfg(feature = "openapi-review")]
4284 if let Some(binding) = openapi_binding {
4285 let forced_outcome = if self.authority.cancellation().is_cancelled() {
4286 Some(OpenApiRuntimeOutcome::Cancelled)
4287 } else if standard.limit_exceeded().is_some() {
4288 Some(OpenApiRuntimeOutcome::BudgetExhausted)
4289 } else {
4290 None
4291 };
4292 let forced_runtime_limit = standard.limit_exceeded().cloned();
4293 match binding.finalize(
4294 self.authority.knowledge(),
4295 &standard,
4296 forced_outcome,
4297 forced_runtime_limit,
4298 ) {
4299 Ok(OpenApiRuntimeResult::Complete(committed)) => {
4300 self.openapi_review_audit = Some(committed.audit().clone());
4301 self.committed_openapi_review = Some(committed);
4302 },
4303 Ok(OpenApiRuntimeResult::Stopped {
4304 audit,
4305 runtime_limit,
4306 }) => {
4307 let outcome = audit.outcome();
4308 self.openapi_review_audit = Some(audit);
4309 if let Some(limit) = runtime_limit {
4310 reasons.insert(reason_for_runtime_dimension(limit.dimension()));
4311 }
4312 if outcome == OpenApiRuntimeOutcome::Cancelled {
4313 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4314 }
4315 reasons.insert(WebAssessmentIncompleteReason::OpenApiReviewIncomplete);
4316 },
4317 Err(_) => {
4318 let parts = standard.into_assessment_parts();
4319 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4320 receipt: self.failure_receipt(
4321 &known_subjects,
4322 subject_reports,
4323 forms,
4324 WebAssessmentSubjectReport::complete(subject, parts),
4325 failed_reasons(&reasons),
4326 started_at,
4327 ),
4328 });
4329 },
4330 }
4331 }
4332 #[cfg(feature = "rest-review")]
4333 if let Some(binding) = rest_binding {
4334 let forced_outcome = if self.authority.cancellation().is_cancelled() {
4335 Some(RestRuntimeOutcome::Cancelled)
4336 } else if standard.limit_exceeded().is_some() {
4337 Some(RestRuntimeOutcome::BudgetExhausted)
4338 } else {
4339 None
4340 };
4341 let forced_runtime_limit = standard.limit_exceeded().cloned();
4342 match binding.finalize(
4343 self.authority.knowledge(),
4344 &standard,
4345 forced_outcome,
4346 forced_runtime_limit,
4347 ) {
4348 Ok(RestRuntimeResult::Complete(committed)) => {
4349 self.rest_review_audit = Some(committed.audit().clone());
4350 self.committed_rest_review = Some(committed);
4351 },
4352 Ok(RestRuntimeResult::Stopped {
4353 audit,
4354 runtime_limit,
4355 }) => {
4356 let outcome = audit.outcome();
4357 self.rest_review_audit = Some(audit);
4358 if let Some(limit) = runtime_limit {
4359 reasons.insert(reason_for_runtime_dimension(limit.dimension()));
4360 }
4361 if outcome == RestRuntimeOutcome::Cancelled {
4362 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4363 }
4364 reasons.insert(WebAssessmentIncompleteReason::RestReviewIncomplete);
4365 },
4366 Err(_) => {
4367 let parts = standard.into_assessment_parts();
4368 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4369 receipt: self.failure_receipt(
4370 &known_subjects,
4371 subject_reports,
4372 forms,
4373 WebAssessmentSubjectReport::complete(subject, parts),
4374 failed_reasons(&reasons),
4375 started_at,
4376 ),
4377 });
4378 },
4379 }
4380 }
4381 #[cfg(feature = "ssrf-oast-review")]
4382 if let Some(binding) = ssrf_oast_binding {
4383 let forced_terminal = if self.authority.cancellation().is_cancelled() {
4384 Some(SsrfOastTerminalState::Cancelled)
4385 } else if standard.limit_exceeded().is_some() {
4386 Some(SsrfOastTerminalState::BudgetExhausted)
4387 } else {
4388 None
4389 };
4390 match binding.finalize(
4391 self.authority.knowledge(),
4392 standard.transport(),
4393 forced_terminal,
4394 standard.limit_exceeded().cloned(),
4395 ) {
4396 Ok(SsrfOastRuntimeResult::Complete(committed)) => {
4397 self.ssrf_oast_review_audit = Some(committed.audit().clone());
4398 self.committed_ssrf_oast_review = Some(committed);
4399 },
4400 Ok(SsrfOastRuntimeResult::Stopped {
4401 audit,
4402 runtime_limit,
4403 }) => {
4404 let cancelled = audit.outcome()
4405 == super::ssrf_oast_runtime::SsrfOastRuntimeOutcome::Cancelled;
4406 self.ssrf_oast_review_audit = Some(audit);
4407 if let Some(limit) = runtime_limit {
4408 reasons.insert(reason_for_runtime_dimension(limit.dimension()));
4409 }
4410 if cancelled {
4411 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4412 }
4413 reasons.insert(WebAssessmentIncompleteReason::SsrfOastReviewIncomplete);
4414 },
4415 Err(_) => {
4416 let parts = standard.into_assessment_parts();
4417 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4418 receipt: self.failure_receipt(
4419 &known_subjects,
4420 subject_reports,
4421 forms,
4422 WebAssessmentSubjectReport::complete(subject, parts),
4423 failed_reasons(&reasons),
4424 started_at,
4425 ),
4426 });
4427 },
4428 }
4429 }
4430 let selected_review =
4431 if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot {
4432 self.native_review.as_mut()
4433 } else {
4434 self.non_root_structural_review
4435 .as_mut()
4436 .filter(|review| review.target == subject.url)
4437 };
4438 #[cfg(feature = "authorization-review")]
4439 let allow_structural_followup = !authorization_hard_stop;
4440 #[cfg(not(feature = "authorization-review"))]
4441 let allow_structural_followup = true;
4442 let mut pending_xss_review = None;
4443 #[cfg(feature = "normalization-resilience")]
4444 let mut pending_normalization_review = None;
4445 if let Some(review) = selected_review {
4446 let native_review_complete =
4447 replay_native_review(&standard, review, self.authority.knowledge());
4448 match native_review_complete {
4449 Ok(true) => {
4450 if allow_structural_followup && self.xss_structural_review.is_none() {
4451 pending_xss_review = review
4452 .ledger
4453 .xss_selection_inputs()
4454 .into_iter()
4455 .find_map(|input| {
4456 select_xss_probe_families(
4457 input.context,
4458 &input.attribute_source,
4459 &input.javascript_source,
4460 )
4461 .into_iter()
4462 .next()
4463 .map(|selection| {
4464 (
4465 review.target.clone(),
4466 review.seeds.clone(),
4467 input.query_parameter,
4468 input.source_evidence_ids,
4469 selection,
4470 )
4471 })
4472 });
4473 }
4474 },
4475 Ok(false) => {
4476 reasons.insert(
4477 WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4478 );
4479 },
4480 Err(()) => {
4481 let parts = standard.into_assessment_parts();
4482 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4483 receipt: self.failure_receipt(
4484 &known_subjects,
4485 subject_reports,
4486 forms,
4487 WebAssessmentSubjectReport::complete(subject, parts),
4488 failed_reasons(&reasons),
4489 started_at,
4490 ),
4491 });
4492 },
4493 }
4494 }
4495 if let Some((target, seeds, parameter, source_evidence_ids, selection)) =
4496 pending_xss_review
4497 {
4498 let xss_action = selection.action_kind();
4499 let observer = AssessmentReviewObserverSet::new_xss_with_source_evidence(
4500 target.clone(),
4501 seeds.clone(),
4502 ¶meter,
4503 selection.clone(),
4504 source_evidence_ids.clone(),
4505 )
4506 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4507 #[cfg(feature = "secret-exposure-review")]
4508 let observer = if self.secret_exposure_collector.is_some() {
4509 observer.with_secret_exposure_privacy_guard()
4510 } else {
4511 observer
4512 };
4513 let observer = Arc::new(observer);
4514 let ledger = CommittedAssessmentReviewLedger::new_xss_with_source_evidence(
4515 target.clone(),
4516 seeds.clone(),
4517 ¶meter,
4518 selection.clone(),
4519 source_evidence_ids,
4520 )
4521 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4522 let mut review = AssessmentNativeReviewRuntime {
4523 target: target.clone(),
4524 seeds: seeds.clone(),
4525 redirect_query_parameter: None,
4526 reflection_query_parameter: None,
4527 sql_query_parameter: None,
4528 ssti_query_parameter: None,
4529 #[cfg(feature = "template-evaluation-review")]
4530 template_evaluation_query_parameter: None,
4531 observer: observer.clone(),
4532 ledger,
4533 enabled_actions: vec![xss_action],
4534 };
4535 let response_observer: Arc<dyn CompleteHttpResponseObserver> = observer;
4536 let xss_builder = StandardWebDecisionRuntime::builder(target)
4537 .with_native_xss_structural_review(
4538 seeds,
4539 response_observer,
4540 parameter,
4541 selection,
4542 )
4543 .with_assessment_defense_enforcement(self.defense_enforcement);
4544 let mut xss_runtime = compose_child(xss_builder)?;
4545 let xss_result = boxed_child_analysis(&mut xss_runtime).await;
4546 let xss_defense = xss_runtime.assessment_defense_controller().cloned();
4547 let xss_planner = xss_runtime.assessment_planner().clone();
4548 match xss_result {
4549 Ok(xss_report) => {
4550 let replay = replay_standard_defense(
4551 &xss_report,
4552 &xss_planner,
4553 self.authority.knowledge(),
4554 self.defense_enforcement,
4555 );
4556 let replayed_defense = match (xss_defense.as_ref(), replay) {
4557 (Some(expected), Ok(replayed))
4558 if replayed.exact_audit_eq(expected) =>
4559 {
4560 replayed
4561 },
4562 _ => {
4563 return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4564 },
4565 };
4566 if self
4567 .defense_audit
4568 .append_controller(&replayed_defense)
4569 .is_err()
4570 {
4571 return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4572 }
4573 match replay_native_review(
4574 &xss_report,
4575 &mut review,
4576 self.authority.knowledge(),
4577 ) {
4578 Ok(true) if !review.ledger.has_incomplete_xss_observation() =>
4579 {
4580 #[cfg(feature = "normalization-resilience")]
4581 if self.normalization_resilience
4582 && self.normalization_review.is_none()
4583 {
4584 let parent = review
4585 .ledger
4586 .normalization_parent_evidence(
4587 replayed_defense.ledger(),
4588 )
4589 .map_err(|_| {
4590 WebAssessmentRuntimeError::NativeReviewComposition
4591 })?;
4592 if let Some(parent) = parent {
4593 if let Some(selection) = select_normalization_transform(
4594 parent.selection(),
4595 None,
4596 ) {
4597 pending_normalization_review = Some((
4598 review.target.clone(),
4599 review.seeds.clone(),
4600 parent,
4601 selection,
4602 ));
4603 }
4604 }
4605 }
4606 },
4607 Ok(_) => {
4608 reasons.insert(
4609 WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4610 );
4611 },
4612 Err(()) => {
4613 return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4614 },
4615 }
4616 },
4617 Err(_) => {
4618 reasons.insert(
4619 WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4620 );
4621 },
4622 }
4623 self.xss_structural_review = Some(review);
4624 }
4625 #[cfg(feature = "normalization-resilience")]
4626 if let Some((target, seeds, parent, selection)) = pending_normalization_review {
4627 let observer = AssessmentReviewObserverSet::new_normalization(
4628 target.clone(),
4629 seeds.clone(),
4630 selection.clone(),
4631 &parent,
4632 )
4633 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4634 #[cfg(feature = "secret-exposure-review")]
4635 let observer = if self.secret_exposure_collector.is_some() {
4636 observer.with_secret_exposure_privacy_guard()
4637 } else {
4638 observer
4639 };
4640 let observer = Arc::new(observer);
4641 let ledger = CommittedAssessmentReviewLedger::new_normalization(
4642 target.clone(),
4643 seeds.clone(),
4644 selection.clone(),
4645 &parent,
4646 )
4647 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4648 let mut review = AssessmentNativeReviewRuntime {
4649 target: target.clone(),
4650 seeds: seeds.clone(),
4651 redirect_query_parameter: None,
4652 reflection_query_parameter: None,
4653 sql_query_parameter: None,
4654 ssti_query_parameter: None,
4655 #[cfg(feature = "template-evaluation-review")]
4656 template_evaluation_query_parameter: None,
4657 observer: observer.clone(),
4658 ledger,
4659 enabled_actions: vec![
4660 NativeWebReviewActionKind::NormalizationResilienceQueryPair,
4661 ],
4662 };
4663 let response_observer: Arc<dyn CompleteHttpResponseObserver> = observer;
4664 let normalization_builder = StandardWebDecisionRuntime::builder(target)
4665 .with_native_normalization_resilience_review(
4666 seeds,
4667 response_observer,
4668 parent.query_parameter().to_owned(),
4669 selection,
4670 )
4671 .with_assessment_defense_enforcement(self.defense_enforcement);
4672 let mut normalization_runtime = compose_child(normalization_builder)?;
4673 let normalization_result =
4674 boxed_child_analysis(&mut normalization_runtime).await;
4675 let normalization_defense = normalization_runtime
4676 .assessment_defense_controller()
4677 .cloned();
4678 let normalization_planner = normalization_runtime.assessment_planner().clone();
4679 match normalization_result {
4680 Ok(normalization_report) => {
4681 let replayed_defense = match (
4682 normalization_defense.as_ref(),
4683 replay_standard_defense(
4684 &normalization_report,
4685 &normalization_planner,
4686 self.authority.knowledge(),
4687 self.defense_enforcement,
4688 ),
4689 ) {
4690 (Some(expected), Ok(replayed))
4691 if replayed.exact_audit_eq(expected) =>
4692 {
4693 replayed
4694 },
4695 _ => {
4696 return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4697 },
4698 };
4699 if self
4700 .defense_audit
4701 .append_controller(&replayed_defense)
4702 .is_err()
4703 {
4704 return Err(WebAssessmentRuntimeError::NativeReviewComposition);
4705 }
4706 match replay_native_review(
4707 &normalization_report,
4708 &mut review,
4709 self.authority.knowledge(),
4710 ) {
4711 Ok(true) => match review
4712 .ledger
4713 .finalize_normalization(replayed_defense.ledger())
4714 .map_err(|_| {
4715 WebAssessmentRuntimeError::NativeReviewComposition
4716 })? {
4717 super::assessment_review::NormalizationReviewOutcome::Incomplete => {
4718 reasons.insert(
4719 WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4720 );
4721 },
4722 super::assessment_review::NormalizationReviewOutcome::SemanticNormalizationGapObserved
4723 | super::assessment_review::NormalizationReviewOutcome::VariantStillBlocked
4724 | super::assessment_review::NormalizationReviewOutcome::VariantAcceptedSemanticsUnknown
4725 | super::assessment_review::NormalizationReviewOutcome::ReplayMismatch => {},
4726 },
4727 Ok(false) => {
4728 reasons.insert(
4729 WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4730 );
4731 },
4732 Err(()) => {
4733 return Err(
4734 WebAssessmentRuntimeError::NativeReviewComposition,
4735 );
4736 },
4737 }
4738 },
4739 Err(_) => {
4740 reasons.insert(
4741 WebAssessmentIncompleteReason::DifferentialReviewIncomplete,
4742 );
4743 },
4744 }
4745 self.normalization_review = Some(review);
4746 }
4747 classify_standard_completion(&standard, &mut reasons);
4748 #[cfg(feature = "authorization-review")]
4749 if authorization_hard_stop
4750 && matches!(
4751 standard.terminal(),
4752 DecisionLoopCommand::Halt {
4753 reason: DecisionStopReason::AdaptationLimit
4754 }
4755 )
4756 {
4757 reasons.remove(&WebAssessmentIncompleteReason::AdaptationLimit);
4763 }
4764 let cancelled_at_subject_boundary = self.authority.cancellation().is_cancelled();
4765 let wall_time_reached_at_subject_boundary =
4766 started_at.elapsed() >= self.limits.max_wall_time();
4767 if cancelled_at_subject_boundary {
4768 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
4769 }
4770 if wall_time_reached_at_subject_boundary {
4771 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
4772 }
4773 let mut should_stop = cancelled_at_subject_boundary
4774 || wall_time_reached_at_subject_boundary
4775 || standard.limit_exceeded().is_some()
4776 || matches!(
4777 standard.terminal(),
4778 DecisionLoopCommand::Halt {
4779 reason: DecisionStopReason::CancelledByHost
4780 | DecisionStopReason::RuntimeBudgetLimit
4781 }
4782 );
4783 #[cfg(feature = "authorization-review")]
4784 {
4785 should_stop |= authorization_hard_stop;
4786 }
4787 let projection = projection_from_committed_bootstrap(
4788 standard.bootstrap(),
4789 self.authority.knowledge(),
4790 &subject,
4791 &self.discovery_policy,
4792 self.limits,
4793 &envelope,
4794 );
4795 let parts = standard.into_assessment_parts();
4796 let subject_report = WebAssessmentSubjectReport::complete(subject.clone(), parts);
4797 let mut suppress_discovery_projection = false;
4798
4799 match projection {
4800 Ok(projection) => {
4801 if self
4802 .replay_passive_bootstrap(
4803 subject_report.bootstrap(),
4804 &subject,
4805 &mut reasons,
4806 )
4807 .is_err()
4808 {
4809 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4810 receipt: self.failure_receipt(
4811 &known_subjects,
4812 subject_reports,
4813 forms,
4814 subject_report,
4815 failed_reasons(&reasons),
4816 started_at,
4817 ),
4818 });
4819 }
4820 if self
4821 .semantic_evidence
4822 .commit_bootstrap(
4823 subject_report.bootstrap(),
4824 self.authority.knowledge(),
4825 &subject,
4826 )
4827 .is_err()
4828 {
4829 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4830 receipt: self.failure_receipt(
4831 &known_subjects,
4832 subject_reports,
4833 forms,
4834 subject_report,
4835 failed_reasons(&reasons),
4836 started_at,
4837 ),
4838 });
4839 }
4840 #[cfg(feature = "wordpress-review")]
4841 if let Some(review) = self.wordpress_review.as_ref().filter(|review| {
4842 review.page_scope().is_some()
4843 || review.asset_fingerprints_enabled()
4844 || review.supplied_session_selected()
4845 }) {
4846 let page_subject = EntityId::new(format!("endpoint:{}", subject.url))
4847 .map_err(|_| {
4848 WebAssessmentRuntimeError::NativeReviewComposition
4849 })?;
4850 let root_subject = EntityId::new(format!("endpoint:{}", self.root.url))
4851 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4852 let discovery_committed = if subject.origin
4853 == WebAssessmentSubjectOrigin::AuthorizedRoot
4854 && (review.page_scope().is_some()
4855 || review.supplied_session_selected())
4856 {
4857 review.collector().commit_staged_entry(
4858 self.authority.knowledge(),
4859 &page_subject,
4860 &subject.url,
4861 )
4862 } else if review.page_scope().is_some() {
4863 review.collector().commit_staged_page(
4864 self.authority.knowledge(),
4865 &root_subject,
4866 &page_subject,
4867 &subject.url,
4868 )
4869 } else {
4870 Ok(())
4871 };
4872 let fingerprints_committed = review
4873 .asset_fingerprint_collector()
4874 .map(|fingerprints| {
4875 fingerprints
4876 .commit_staged_candidates(
4877 self.authority.knowledge(),
4878 &root_subject,
4879 &page_subject,
4880 &subject.url,
4881 )
4882 .and_then(|()| {
4883 fingerprints.commit_staged_response(
4884 self.authority.knowledge(),
4885 &root_subject,
4886 &page_subject,
4887 &subject.url,
4888 )
4889 })
4890 })
4891 .transpose();
4892 if discovery_committed.is_err() || fingerprints_committed.is_err() {
4893 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4894 receipt: self.failure_receipt(
4895 &known_subjects,
4896 subject_reports,
4897 forms,
4898 subject_report,
4899 failed_reasons(&reasons),
4900 started_at,
4901 ),
4902 });
4903 }
4904 }
4905 #[cfg(all(
4906 feature = "wordpress-review",
4907 feature = "supplied-session-review"
4908 ))]
4909 if self.wordpress_supplied_session
4910 && subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot
4911 && !should_stop
4912 {
4913 let root_subject = EntityId::new(format!("endpoint:{}", self.root.url))
4914 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
4915 let binding = self.wordpress_review.as_ref().and_then(|review| {
4916 review.supplied_session_observation_binding(root_subject)
4917 });
4918 let session = self.supplied_session_review.take();
4919 let (Some(binding), Some(session)) = (binding, session) else {
4920 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4921 receipt: self.failure_receipt(
4922 &known_subjects,
4923 subject_reports,
4924 forms,
4925 subject_report,
4926 failed_reasons(&reasons),
4927 started_at,
4928 ),
4929 });
4930 };
4931 let audit =
4932 boxed_session_review(session, &self.authority, Some(&binding))
4933 .await;
4934 let complete = binding.complete(&audit);
4935 self.supplied_session_audit = Some(audit);
4936 if complete.is_err() {
4937 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
4938 receipt: self.failure_receipt(
4939 &known_subjects,
4940 subject_reports,
4941 forms,
4942 subject_report,
4943 failed_reasons(&reasons),
4944 started_at,
4945 ),
4946 });
4947 }
4948 }
4949 if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot
4950 && !should_stop
4951 {
4952 let api_visibility_result = match self.root_api_visibility.as_mut() {
4953 Some(runtime) => {
4954 Some(boxed_optional_child(|| runtime.execute()).await)
4955 },
4956 None => None,
4957 };
4958 if let Some(api_visibility_result) = api_visibility_result {
4959 match api_visibility_result {
4960 Ok(RootApiVisibilityOutcome::NoDifference) => {},
4961 Ok(RootApiVisibilityOutcome::NeedsReview(committed)) => {
4962 self.committed_api_visibility = Some(*committed);
4963 },
4964 Ok(RootApiVisibilityOutcome::Incomplete) => {
4965 reasons.insert(
4966 WebAssessmentIncompleteReason::ApiVisibilityReviewIncomplete,
4967 );
4968 should_stop = true;
4969 suppress_discovery_projection = true;
4970 },
4971 Ok(RootApiVisibilityOutcome::Cancelled) => {
4972 reasons.insert(
4973 WebAssessmentIncompleteReason::HostCancellation,
4974 );
4975 should_stop = true;
4976 suppress_discovery_projection = true;
4977 },
4978 Ok(RootApiVisibilityOutcome::RuntimeLimit(dimension)) => {
4979 reasons.insert(reason_for_runtime_dimension(dimension));
4980 should_stop = true;
4981 suppress_discovery_projection = true;
4982 },
4983 Ok(RootApiVisibilityOutcome::ContractMismatch) => {
4984 return Err(
4985 WebAssessmentRuntimeError::ProjectionInvariant {
4986 receipt: self.failure_receipt(
4987 &known_subjects,
4988 subject_reports,
4989 forms,
4990 subject_report,
4991 failed_reasons(&reasons),
4992 started_at,
4993 ),
4994 },
4995 );
4996 },
4997 Err(source) => {
4998 return Err(
4999 WebAssessmentRuntimeError::ApiVisibilityRunFailed {
5000 receipt: self.failure_receipt(
5001 &known_subjects,
5002 subject_reports,
5003 forms,
5004 subject_report,
5005 failed_reasons(&reasons),
5006 started_at,
5007 ),
5008 source: Box::new(source),
5009 },
5010 );
5011 },
5012 }
5013 }
5014 }
5015 #[cfg(feature = "wordpress-review")]
5016 if subject.origin == WebAssessmentSubjectOrigin::AuthorizedRoot
5017 && !should_stop
5018 {
5019 let discovery_result = match self.wordpress_review.as_mut() {
5020 Some(review)
5021 if review.discovery_enabled()
5022 && review.page_scope().is_none() =>
5023 {
5024 let root_subject =
5025 EntityId::new(format!("endpoint:{}", self.root.url()))
5026 .map_err(|_| {
5027 WebAssessmentRuntimeError::NativeReviewComposition
5028 })?;
5029 Some(
5030 boxed_optional_child(|| {
5031 review.execute_discovery(
5032 root_subject,
5033 &self.authority,
5034 timing.deadline(),
5035 )
5036 })
5037 .await,
5038 )
5039 },
5040 _ => None,
5041 };
5042 match discovery_result {
5043 None => {},
5044 Some(Ok(WordPressDiscoveryStop::Complete)) => {},
5045 Some(Ok(WordPressDiscoveryStop::Cancelled)) => {
5046 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5047 reasons.insert(
5048 WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5049 );
5050 should_stop = true;
5051 suppress_discovery_projection = true;
5052 },
5053 Some(Ok(WordPressDiscoveryStop::DeadlineExceeded)) => {
5054 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5055 reasons.insert(
5056 WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5057 );
5058 should_stop = true;
5059 suppress_discovery_projection = true;
5060 },
5061 Some(Ok(WordPressDiscoveryStop::RuntimeLimit(dimension))) => {
5062 reasons.insert(reason_for_runtime_dimension(dimension));
5063 reasons.insert(
5064 WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5065 );
5066 should_stop = true;
5067 suppress_discovery_projection = true;
5068 },
5069 Some(Ok(WordPressDiscoveryStop::DiscoveryResponseLimit)) => {
5070 reasons.insert(
5071 WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5072 );
5073 should_stop = true;
5074 suppress_discovery_projection = true;
5075 },
5076 Some(Err(
5077 super::wordpress_discovery::WordPressDiscoveryExecutionError::CandidateIdentityLimitExceeded,
5078 )) => {
5079 reasons.insert(
5080 WebAssessmentIncompleteReason::WordPressReviewIncomplete,
5081 );
5082 should_stop = true;
5083 suppress_discovery_projection = true;
5084 },
5085 Some(Err(_)) => {
5086 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5087 receipt: self.failure_receipt(
5088 &known_subjects,
5089 subject_reports,
5090 forms,
5091 subject_report,
5092 failed_reasons(&reasons),
5093 started_at,
5094 ),
5095 });
5096 },
5097 }
5098 }
5099 if let Some(projection) = projection {
5100 projection.add_reasons(&mut reasons);
5101 if !suppress_discovery_projection {
5102 self.ledger.apply(&projection);
5103 forms.extend(projection.forms);
5104 for route in projection.routes {
5105 let route_is_pending = self
5106 .ledger
5107 .subject_admission(&route.url)
5108 .is_some_and(|entry| !entry.executed);
5109 if route_is_pending {
5110 if let Some(existing) =
5111 known_subjects.get_mut(route.url.as_str())
5112 {
5113 merge_subject_route(
5114 existing,
5115 &route,
5116 subject.depth + 1,
5117 self.limits.max_query_parameter_names(),
5118 );
5119 } else {
5120 known_subjects.insert(
5121 route.url.to_string(),
5122 WebAssessmentSubject {
5123 url: route.url.clone(),
5124 method: route.method,
5125 depth: subject.depth.saturating_add(1),
5126 origin: WebAssessmentSubjectOrigin::Discovered,
5127 query_parameter_names: route
5128 .query_parameter_names
5129 .clone(),
5130 evidence_ids: route.evidence_ids.clone(),
5131 },
5132 );
5133 }
5134 }
5135 merge_pending_route(
5136 &mut next_candidates,
5137 route,
5138 subject.depth + 1,
5139 self.limits.max_query_parameter_names(),
5140 );
5141 }
5142 }
5143 }
5144 },
5145 Err(()) => {
5146 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5147 receipt: self.failure_receipt(
5148 &known_subjects,
5149 subject_reports,
5150 forms,
5151 subject_report,
5152 failed_reasons(&reasons),
5153 started_at,
5154 ),
5155 });
5156 },
5157 }
5158 subject_reports.push(subject_report);
5159 progress_processed_subjects = progress_processed_subjects.saturating_add(1);
5160 self.publish_progress(
5161 WebAssessmentProgressCheckpoint::SubjectStateCommitted,
5162 progress_executed_subjects,
5163 progress_processed_subjects,
5164 );
5165 if should_stop {
5166 stop = true;
5167 break;
5168 }
5169 }
5170
5171 for (_, candidate) in next_candidates {
5172 if let Some(existing) = known_subjects.get_mut(candidate.url.as_str()) {
5173 if self
5174 .ledger
5175 .subject_admission(&candidate.url)
5176 .is_some_and(|entry| !entry.executed)
5177 {
5178 merge_subject_route(
5179 existing,
5180 &candidate,
5181 candidate.depth,
5182 self.limits.max_query_parameter_names(),
5183 );
5184 }
5185 } else {
5186 known_subjects.insert(
5187 candidate.url.to_string(),
5188 WebAssessmentSubject {
5189 url: candidate.url.clone(),
5190 method: candidate.method,
5191 depth: candidate.depth,
5192 origin: WebAssessmentSubjectOrigin::Discovered,
5193 query_parameter_names: candidate.query_parameter_names.clone(),
5194 evidence_ids: candidate.evidence_ids.clone(),
5195 },
5196 );
5197 }
5198 if !stop
5199 && self
5200 .ledger
5201 .subject_admission(&candidate.url)
5202 .is_some_and(|entry| !entry.executed)
5203 {
5204 if let Some(pending) = known_subjects.get(candidate.url.as_str()) {
5205 current_layer.push(pending.clone());
5206 } else {
5207 reasons.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
5208 stop = true;
5209 }
5210 }
5211 }
5212 if stop {
5213 break;
5214 }
5215 }
5216
5217 let reported: BTreeSet<_> = subject_reports
5219 .iter()
5220 .map(|report| report.subject.url.to_string())
5221 .collect();
5222 for subject in known_subjects.values().cloned() {
5223 if !reported.contains(subject.url.as_str()) {
5224 subject_reports.push(WebAssessmentSubjectReport::pending(subject));
5225 }
5226 }
5227 subject_reports.sort_by(|left, right| {
5228 left.subject
5229 .depth
5230 .cmp(&right.subject.depth)
5231 .then_with(|| left.subject.url.as_str().cmp(right.subject.url.as_str()))
5232 });
5233 forms.sort_by(|left, right| {
5234 left.action
5235 .as_str()
5236 .cmp(right.action.as_str())
5237 .then_with(|| left.method.cmp(&right.method))
5238 });
5239 match self.reconcile_final_state(&subject_reports, &forms, &known_subjects) {
5240 Ok(true) => {
5241 reasons.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
5242 },
5243 Ok(false) => {},
5244 Err(()) => {
5245 let current_subject = subject_reports
5246 .pop()
5247 .unwrap_or_else(|| WebAssessmentSubjectReport::pending(self.root.clone()));
5248 let completed_subjects = subject_reports
5249 .into_iter()
5250 .filter(WebAssessmentSubjectReport::was_executed)
5251 .collect();
5252 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5253 receipt: self.failure_receipt(
5254 &known_subjects,
5255 completed_subjects,
5256 forms,
5257 current_subject,
5258 failed_reasons(&reasons),
5259 started_at,
5260 ),
5261 });
5262 },
5263 }
5264 self.publish_progress(
5265 WebAssessmentProgressCheckpoint::InventoryReconciled,
5266 progress_executed_subjects,
5267 progress_processed_subjects,
5268 );
5269 #[cfg(feature = "wordpress-review")]
5270 if reasons.is_empty() {
5271 let discovery_result = match self.wordpress_review.as_mut() {
5272 Some(review) if review.discovery_enabled() && review.page_scope().is_some() => {
5273 let root_subject = EntityId::new(format!("endpoint:{}", self.root.url))
5274 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5275 Some(
5276 boxed_optional_child(|| {
5277 review.execute_discovery(
5278 root_subject,
5279 &self.authority,
5280 timing.deadline(),
5281 )
5282 })
5283 .await,
5284 )
5285 },
5286 _ => None,
5287 };
5288 match discovery_result {
5289 None | Some(Ok(WordPressDiscoveryStop::Complete)) => {},
5290 Some(Ok(WordPressDiscoveryStop::Cancelled)) => {
5291 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5292 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5293 },
5294 Some(Ok(WordPressDiscoveryStop::DeadlineExceeded)) => {
5295 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5296 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5297 },
5298 Some(Ok(WordPressDiscoveryStop::RuntimeLimit(dimension))) => {
5299 reasons.insert(reason_for_runtime_dimension(dimension));
5300 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5301 },
5302 Some(Ok(WordPressDiscoveryStop::DiscoveryResponseLimit))
5303 | Some(Err(
5304 super::wordpress_discovery::WordPressDiscoveryExecutionError::CandidateIdentityLimitExceeded,
5305 )) => {
5306 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5307 },
5308 Some(Err(_)) => {
5309 let current_subject = subject_reports
5310 .pop()
5311 .unwrap_or_else(|| WebAssessmentSubjectReport::pending(self.root.clone()));
5312 let completed_subjects = subject_reports
5313 .into_iter()
5314 .filter(WebAssessmentSubjectReport::was_executed)
5315 .collect();
5316 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5317 receipt: self.failure_receipt(
5318 &known_subjects,
5319 completed_subjects,
5320 forms,
5321 current_subject,
5322 failed_reasons(&reasons),
5323 started_at,
5324 ),
5325 });
5326 },
5327 }
5328 }
5329 #[cfg(feature = "wordpress-review")]
5330 if reasons.is_empty() {
5331 let fingerprint_result = match self.wordpress_review.as_mut() {
5332 Some(review) if review.asset_fingerprints_enabled() => {
5333 let root_subject = EntityId::new(format!("endpoint:{}", self.root.url))
5334 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5335 Some(
5336 boxed_optional_child(|| {
5337 review.execute_asset_fingerprints(
5338 root_subject,
5339 &self.authority,
5340 timing.deadline(),
5341 )
5342 })
5343 .await,
5344 )
5345 },
5346 _ => None,
5347 };
5348 match fingerprint_result {
5349 None | Some(Ok(WordPressAssetFingerprintStop::Complete)) => {},
5350 Some(Ok(WordPressAssetFingerprintStop::Cancelled)) => {
5351 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5352 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5353 },
5354 Some(Ok(WordPressAssetFingerprintStop::DeadlineExceeded)) => {
5355 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5356 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5357 },
5358 Some(Ok(
5359 WordPressAssetFingerprintStop::RequestLimit
5360 | WordPressAssetFingerprintStop::ResponseLimit
5361 | WordPressAssetFingerprintStop::RuntimeLimit
5362 | WordPressAssetFingerprintStop::RateLimited,
5363 )) => {
5364 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5365 },
5366 Some(Err(_)) => {
5367 let current_subject = subject_reports
5368 .pop()
5369 .unwrap_or_else(|| WebAssessmentSubjectReport::pending(self.root.clone()));
5370 let completed_subjects = subject_reports
5371 .into_iter()
5372 .filter(WebAssessmentSubjectReport::was_executed)
5373 .collect();
5374 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5375 receipt: self.failure_receipt(
5376 &known_subjects,
5377 completed_subjects,
5378 forms,
5379 current_subject,
5380 failed_reasons(&reasons),
5381 started_at,
5382 ),
5383 });
5384 },
5385 }
5386 }
5387 #[cfg(feature = "graphql-review")]
5388 if self.graphql_review {
5389 if !reasons.is_empty() {
5390 reasons.insert(WebAssessmentIncompleteReason::GraphqlReviewIncomplete);
5391 } else {
5392 let root_subject = EntityId::new(format!("endpoint:{}", self.root.url()))
5393 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5394 let parent_defense = self
5395 .optional_child_parent_defense
5396 .as_ref()
5397 .ok_or(WebAssessmentRuntimeError::NativeReviewComposition)?;
5398 if !parent_defense.permits_optional_interaction(
5399 &root_subject,
5400 DefenseInteractionClass::DifferentialRead,
5401 ) {
5402 } else {
5405 let hints = graphql_endpoint_hints(
5406 self.root.url(),
5407 self.authority.knowledge(),
5408 subject_reports
5409 .iter()
5410 .filter(|report| report.was_executed())
5411 .map(|report| report.subject().url().clone()),
5412 forms.iter().map(|form| form.action().clone()),
5413 );
5414 match execute_graphql_review(
5415 &self.authority,
5416 self.root.url(),
5417 hints,
5418 self.limits.max_response_body_bytes(),
5419 self.defense_enforcement,
5420 {
5421 #[cfg(feature = "secret-exposure-review")]
5422 if self.secret_exposure_collector.is_some() {
5423 GraphqlResponsePrivacy::ValueFree
5424 } else {
5425 GraphqlResponsePrivacy::HistoricalDigests
5426 }
5427 #[cfg(not(feature = "secret-exposure-review"))]
5428 {
5429 GraphqlResponsePrivacy::HistoricalDigests
5430 }
5431 },
5432 )
5433 .await
5434 {
5435 Ok(GraphqlRuntimeResult::NotEligible) => {},
5436 Ok(GraphqlRuntimeResult::Complete(completed)) => {
5437 let replayed = completed
5438 .replay_defense(
5439 self.authority.knowledge(),
5440 self.defense_enforcement,
5441 )
5442 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5443 if !completed.defense().exact_audit_eq(&replayed)
5444 || self.defense_audit.append_controller(&replayed).is_err()
5445 {
5446 return Err(WebAssessmentRuntimeError::NativeReviewComposition);
5447 }
5448 if completed.review().outcome()
5449 == crate::graphql_review::GraphqlReviewOutcome::Incomplete
5450 {
5451 reasons
5452 .insert(WebAssessmentIncompleteReason::GraphqlReviewIncomplete);
5453 } else {
5454 self.committed_graphql_review = Some(completed.into_review());
5455 }
5456 },
5457 Ok(GraphqlRuntimeResult::Stopped(stopped)) => {
5458 let replayed = stopped
5459 .replay_defense(
5460 self.authority.knowledge(),
5461 self.defense_enforcement,
5462 )
5463 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5464 if !stopped.defense().exact_audit_eq(&replayed)
5465 || self.defense_audit.append_controller(&replayed).is_err()
5466 {
5467 return Err(WebAssessmentRuntimeError::NativeReviewComposition);
5468 }
5469 match stopped.stop() {
5470 GraphqlRuntimeStop::Cancelled => {
5471 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5472 },
5473 GraphqlRuntimeStop::RuntimeLimit(dimension) => {
5474 reasons.insert(reason_for_runtime_dimension(dimension));
5475 },
5476 GraphqlRuntimeStop::TransportIncomplete => {},
5477 }
5478 reasons.insert(WebAssessmentIncompleteReason::GraphqlReviewIncomplete);
5479 },
5480 Err(_) => {
5481 let current_subject = subject_reports.pop().unwrap_or_else(|| {
5482 WebAssessmentSubjectReport::pending(self.root.clone())
5483 });
5484 let completed_subjects = subject_reports
5485 .into_iter()
5486 .filter(WebAssessmentSubjectReport::was_executed)
5487 .collect();
5488 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5489 receipt: self.failure_receipt(
5490 &known_subjects,
5491 completed_subjects,
5492 forms,
5493 current_subject,
5494 failed_reasons(&reasons),
5495 started_at,
5496 ),
5497 });
5498 },
5499 }
5500 }
5501 }
5502 }
5503 #[cfg(feature = "template-evaluation-review")]
5504 if self.template_evaluation_review {
5505 let transport = self.authority.request_accounting().dispatch_audit();
5506 let audit = template_evaluation_audit_from_committed_runtime(
5507 self.native_review.as_ref(),
5508 &transport,
5509 )
5510 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5511 if audit.outcome() == TemplateEvaluationReviewOutcome::Incomplete {
5512 reasons.insert(WebAssessmentIncompleteReason::DifferentialReviewIncomplete);
5513 }
5514 self.template_evaluation_review_audit = Some(audit);
5515 }
5516 #[cfg(not(feature = "normalization-resilience"))]
5517 let review_ledgers = self
5518 .native_review
5519 .iter()
5520 .chain(self.non_root_structural_review.iter())
5521 .chain(self.xss_structural_review.iter())
5522 .map(|review| &review.ledger)
5523 .collect::<Vec<_>>();
5524 #[cfg(feature = "normalization-resilience")]
5525 let review_ledgers = self
5526 .native_review
5527 .iter()
5528 .chain(self.non_root_structural_review.iter())
5529 .chain(self.xss_structural_review.iter())
5530 .chain(self.normalization_review.iter())
5531 .map(|review| &review.ledger)
5532 .collect::<Vec<_>>();
5533 self.publish_progress(
5534 WebAssessmentProgressCheckpoint::ProjectionStarted,
5535 progress_executed_subjects,
5536 progress_processed_subjects,
5537 );
5538 #[cfg(feature = "wordpress-review")]
5539 if let Some(review) = self.wordpress_review.take() {
5540 if !reasons.is_empty() {
5541 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5542 } else {
5543 let root_subject = EntityId::new(format!("endpoint:{}", self.root.url()))
5544 .map_err(|_| WebAssessmentRuntimeError::NativeReviewComposition)?;
5545 match review.finish(root_subject) {
5546 Ok(_) if self.authority.cancellation().is_cancelled() => {
5547 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5548 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5549 },
5550 Ok(_) if started_at.elapsed() >= self.limits.max_wall_time() => {
5551 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5552 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5553 },
5554 Ok(committed) => self.committed_wordpress_review = Some(committed),
5555 Err(_) => {
5556 reasons.insert(WebAssessmentIncompleteReason::WordPressReviewIncomplete);
5557 },
5558 }
5559 }
5560 }
5561 let assessment_projection = match project_assessment_items(
5562 &self.passive_ledger,
5563 AssessmentReviewProjectionSources {
5564 native: &review_ledgers,
5565 api_visibility: self.committed_api_visibility.as_ref(),
5566 #[cfg(feature = "supplied-session-review")]
5567 supplied_session: self.supplied_session_audit.as_ref(),
5568 #[cfg(feature = "graphql-review")]
5569 graphql: self.committed_graphql_review.as_ref(),
5570 #[cfg(feature = "authorization-review")]
5571 authorization: self.committed_resource_authorization_review.as_ref(),
5572 #[cfg(feature = "openapi-review")]
5573 openapi: self.committed_openapi_review.as_ref(),
5574 #[cfg(feature = "rest-review")]
5575 rest: self.committed_rest_review.as_ref(),
5576 #[cfg(feature = "ssrf-oast-review")]
5577 ssrf_oast: self.committed_ssrf_oast_review.as_ref(),
5578 #[cfg(feature = "xml-external-entity-review")]
5579 xml_external_entity: self.committed_xml_external_entity_review.as_ref(),
5580 #[cfg(feature = "xml-external-entity-review")]
5581 xml_external_entity_selected: self.xml_external_entity_review_audit.is_some(),
5582 #[cfg(feature = "wordpress-review")]
5583 wordpress: self.committed_wordpress_review.as_ref(),
5584 #[cfg(feature = "secret-exposure-review")]
5585 secret_exposure: self
5586 .secret_exposure_collector
5587 .as_ref()
5588 .map(|_| &self.secret_exposure_ledger),
5589 #[cfg(feature = "websocket-review")]
5590 websocket_selected: self.websocket_review.is_some()
5591 || self.websocket_review_audit.is_some(),
5592 },
5593 self.authority.knowledge(),
5594 &self.root,
5595 &subject_reports
5596 .iter()
5597 .map(|report| report.subject().clone())
5598 .collect::<Vec<_>>(),
5599 ) {
5600 Ok(projection) => projection,
5601 Err(_) => {
5602 let current_subject = subject_reports
5603 .pop()
5604 .unwrap_or_else(|| WebAssessmentSubjectReport::pending(self.root.clone()));
5605 let completed_subjects = subject_reports
5606 .into_iter()
5607 .filter(WebAssessmentSubjectReport::was_executed)
5608 .collect();
5609 return Err(WebAssessmentRuntimeError::ProjectionInvariant {
5610 receipt: self.failure_receipt(
5611 &known_subjects,
5612 completed_subjects,
5613 forms,
5614 current_subject,
5615 failed_reasons(&reasons),
5616 started_at,
5617 ),
5618 });
5619 },
5620 };
5621 let (assessment_items, assessment_projection_incompleteness) =
5622 assessment_projection.into_parts();
5623 if assessment_projection_incompleteness.is_incomplete() {
5624 reasons.insert(WebAssessmentIncompleteReason::AssessmentSubjectIdentityUnavailable);
5625 }
5626 let semantics = self.extract_semantics_and_refresh_limits(&mut reasons, started_at);
5627 #[cfg(feature = "tls-negotiation-review")]
5628 if self.tls_negotiation_review {
5629 self.tls_negotiation_review_audit = Some(if reasons.is_empty() {
5630 self.authority
5631 .mint_tls_negotiation_review()
5632 .map_err(|_| WebAssessmentRuntimeError::TlsNegotiationReviewComposition)?
5633 .execute()
5634 .await
5635 } else {
5636 WebAssessmentTlsNegotiationReviewAudit::parent_authority_unavailable()
5637 });
5638 }
5639 #[cfg(feature = "tls-negotiation-review")]
5640 self.refresh_parent_limits_after_tls_negotiation(&mut reasons, started_at);
5641 #[cfg(feature = "websocket-review")]
5642 if let Some(policy) = self.websocket_review.take() {
5643 self.websocket_review_audit = Some(if reasons.is_empty() {
5644 boxed_websocket_review(&self.authority, &policy).await
5645 } else {
5646 websocket_review_not_run(
5647 &policy,
5648 WebSocketReviewFailureKind::ParentAuthorityUnavailable,
5649 )
5650 });
5651 }
5652 #[cfg(feature = "websocket-review")]
5653 self.refresh_parent_limits_after_websocket(&mut reasons, started_at);
5654 #[cfg(feature = "recon-ct-provider")]
5655 let recon_ct_provider = if !reasons.is_empty() {
5656 None
5660 } else if let Some(policy) = self.recon_ct_provider.take() {
5661 Some(execute_recon_ct_provider(policy, &self.root.url, &self.authority).await)
5666 } else {
5667 None
5668 };
5669 let usage = self.usage(
5670 subject_reports.len(),
5671 subject_reports
5672 .iter()
5673 .filter(|report| report.was_executed())
5674 .count(),
5675 forms.len(),
5676 started_at,
5677 );
5678 let completion = if reasons.is_empty() {
5679 WebAssessmentCompletion::Complete
5680 } else {
5681 WebAssessmentCompletion::Incomplete { reasons }
5682 };
5683 self.publish_progress(
5684 WebAssessmentProgressCheckpoint::FinishedSnapshot,
5685 usage.executed_subjects(),
5686 progress_processed_subjects,
5687 );
5688 Ok(WebAssessmentRunReport {
5689 #[cfg(feature = "reporting")]
5690 run_started_at,
5691 authorized_root: self.root.clone(),
5692 limits: self.limits,
5693 runtime_active_verification_limit: self.runtime_active_verification_limit,
5694 runtime_optional_active_verification_allowance: self
5695 .runtime_optional_active_verification_allowance,
5696 subjects: subject_reports,
5697 forms,
5698 semantics,
5699 defense: self.defense_audit.clone(),
5700 #[cfg(feature = "supplied-session-review")]
5701 supplied_session: self.supplied_session_audit.clone(),
5702 #[cfg(feature = "websocket-review")]
5703 websocket_review: self.websocket_review_audit.clone(),
5704 #[cfg(feature = "jwt-target-acceptance-review")]
5705 jwt_target_acceptance: self.jwt_target_acceptance_audit.clone(),
5706 #[cfg(feature = "xml-external-entity-review")]
5707 xml_external_entity_review: self.xml_external_entity_review_audit.clone(),
5708 #[cfg(feature = "template-evaluation-review")]
5709 template_evaluation_review: self.template_evaluation_review_audit.clone(),
5710 #[cfg(feature = "authorization-review")]
5711 authorization_review: self.authorization_review_audit.clone(),
5712 #[cfg(feature = "openapi-review")]
5713 openapi_review: self.openapi_review_audit.clone(),
5714 #[cfg(feature = "rest-review")]
5715 rest_review: self.rest_review_audit.clone(),
5716 #[cfg(feature = "ssrf-oast-review")]
5717 ssrf_oast_review: self.ssrf_oast_review_audit.clone(),
5718 #[cfg(feature = "wordpress-review")]
5719 wordpress_review: self
5720 .committed_wordpress_review
5721 .as_ref()
5722 .map(|review| review.audit().clone()),
5723 #[cfg(feature = "secret-exposure-review")]
5724 secret_exposure_review: self
5725 .secret_exposure_collector
5726 .as_ref()
5727 .map(|_| self.secret_exposure_ledger.audit()),
5728 #[cfg(feature = "tls-observation")]
5729 tls_observation: self.authority.tls_observation_audit(),
5730 #[cfg(feature = "tls-negotiation-review")]
5731 tls_negotiation_review: self.tls_negotiation_review_audit.clone(),
5732 #[cfg(feature = "recon-ct-provider")]
5733 recon_ct_provider,
5734 assessment_items,
5735 assessment_projection_incompleteness,
5736 completion,
5737 usage,
5738 transport: self.authority.request_accounting().dispatch_audit(),
5739 })
5740 }
5741
5742 fn reconcile_final_state(
5743 &self,
5744 subject_reports: &[WebAssessmentSubjectReport],
5745 forms: &[WebAssessmentForm],
5746 known_subjects: &BTreeMap<String, WebAssessmentSubject>,
5747 ) -> Result<bool, ()> {
5748 let report_subjects: BTreeMap<_, _> = subject_reports
5749 .iter()
5750 .map(|report| (report.subject.url.to_string(), report))
5751 .collect();
5752 if report_subjects.len() != subject_reports.len()
5753 || report_subjects.len() != known_subjects.len()
5754 || report_subjects.len() != self.ledger.subjects.len()
5755 || report_subjects.keys().ne(self.ledger.subjects.keys())
5756 {
5757 return Err(());
5758 }
5759 for (url, report) in &report_subjects {
5760 let admission = self.ledger.subjects.get(url).ok_or(())?;
5761 if report.was_executed() != admission.executed
5762 || report.subject.method != admission.method
5763 || report
5764 .subject
5765 .query_parameter_names
5766 .iter()
5767 .cloned()
5768 .collect::<BTreeSet<_>>()
5769 != admission.query_parameter_names
5770 {
5771 return Err(());
5772 }
5773 }
5774 let bootstrap_subjects: BTreeSet<_> = subject_reports
5775 .iter()
5776 .filter_map(|report| {
5777 report
5778 .bootstrap()
5779 .map(|receipt| receipt.case().subject().clone())
5780 })
5781 .collect();
5782 let passive_subjects: BTreeSet<_> = self
5783 .passive_ledger
5784 .observations()
5785 .iter()
5786 .map(|observation| observation.subject().clone())
5787 .collect();
5788 if bootstrap_subjects.len() != self.passive_ledger.observations().len()
5789 || bootstrap_subjects != passive_subjects
5790 {
5791 return Err(());
5792 }
5793 let form_identities: BTreeSet<_> = forms
5794 .iter()
5795 .map(|form| FormIdentity {
5796 action: form.action.to_string(),
5797 method: form.method,
5798 })
5799 .collect();
5800 if form_identities.len() != forms.len() || form_identities != self.ledger.form_identities {
5801 return Err(());
5802 }
5803 let retained_urls: BTreeSet<_> = subject_reports
5804 .iter()
5805 .map(|report| report.subject.url.to_string())
5806 .chain(forms.iter().map(|form| form.action.to_string()))
5807 .collect();
5808 let retained_unique_url_bytes = retained_urls.iter().map(String::len).sum::<usize>();
5809 if retained_urls != self.ledger.retained_urls
5810 || retained_unique_url_bytes != self.ledger.retained_unique_url_bytes
5811 {
5812 return Err(());
5813 }
5814 Ok(subject_reports.iter().any(|report| !report.was_executed()))
5815 }
5816
5817 fn run_failed(
5818 &mut self,
5819 known_subjects: &mut BTreeMap<String, WebAssessmentSubject>,
5820 completed_subjects: Vec<WebAssessmentSubjectReport>,
5821 mut forms: Vec<WebAssessmentForm>,
5822 boundary: FailedSubjectBoundary,
5823 reasons: &BTreeSet<WebAssessmentIncompleteReason>,
5824 started_at: tokio::time::Instant,
5825 ) -> WebAssessmentRuntimeError {
5826 let FailedSubjectBoundary {
5827 subject: current_subject,
5828 envelope,
5829 source,
5830 started: subject_started,
5831 defense,
5832 planner,
5833 } = boundary;
5834 let (parts, source) = source.into_assessment_failure();
5835 if subject_started {
5836 let replay = planner.as_ref().and_then(|planner| {
5837 let mut replay = replay_defense_parts(
5838 parts.bootstrap.as_ref(),
5839 &parts.turns,
5840 None,
5841 planner,
5842 self.authority.knowledge(),
5843 self.defense_enforcement,
5844 )
5845 .ok()?;
5846 if let Some(committed) = source.committed_evidence() {
5847 replay
5848 .ingest_receipt(
5849 committed,
5850 self.authority.knowledge(),
5851 receipt_requires_defense_projection(committed),
5852 )
5853 .ok()?;
5854 }
5855 Some(replay)
5856 });
5857 let defense_valid = match (defense.as_ref(), replay) {
5858 (Some(expected), Some(replayed)) if replayed.exact_audit_eq(expected) => {
5859 self.defense_audit.append_controller(&replayed).is_ok()
5860 },
5861 _ => false,
5862 };
5863 if !defense_valid {
5864 let current_report =
5865 WebAssessmentSubjectReport::failed(current_subject, parts, true);
5866 return WebAssessmentRuntimeError::ProjectionInvariant {
5867 receipt: self.failure_receipt(
5868 known_subjects,
5869 completed_subjects,
5870 forms,
5871 current_report,
5872 failed_reasons(reasons),
5873 started_at,
5874 ),
5875 };
5876 }
5877 }
5878 let projection = projection_from_committed_bootstrap(
5879 parts.bootstrap.as_ref(),
5880 self.authority.knowledge(),
5881 ¤t_subject,
5882 &self.discovery_policy,
5883 self.limits,
5884 &envelope,
5885 );
5886 let current_report =
5887 WebAssessmentSubjectReport::failed(current_subject.clone(), parts, subject_started);
5888 let mut incomplete_reasons = failed_reasons(reasons);
5889 if self.authority.cancellation().is_cancelled() {
5890 incomplete_reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
5891 }
5892 if started_at.elapsed() >= self.limits.max_wall_time() {
5893 incomplete_reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
5894 }
5895 match projection {
5896 Ok(projection) => {
5897 if self
5898 .replay_passive_bootstrap(
5899 current_report.bootstrap(),
5900 ¤t_subject,
5901 &mut incomplete_reasons,
5902 )
5903 .is_err()
5904 {
5905 let receipt = self.failure_receipt(
5906 known_subjects,
5907 completed_subjects,
5908 forms,
5909 current_report,
5910 incomplete_reasons,
5911 started_at,
5912 );
5913 return WebAssessmentRuntimeError::ProjectionInvariant { receipt };
5914 }
5915 if self
5916 .semantic_evidence
5917 .commit_bootstrap(
5918 current_report.bootstrap(),
5919 self.authority.knowledge(),
5920 ¤t_subject,
5921 )
5922 .is_err()
5923 {
5924 let receipt = self.failure_receipt(
5925 known_subjects,
5926 completed_subjects,
5927 forms,
5928 current_report,
5929 incomplete_reasons,
5930 started_at,
5931 );
5932 return WebAssessmentRuntimeError::ProjectionInvariant { receipt };
5933 }
5934 if let Some(projection) = projection {
5935 projection.add_reasons(&mut incomplete_reasons);
5936 self.ledger.apply(&projection);
5937 forms.extend(projection.forms);
5938 for route in projection.routes {
5939 if self
5940 .ledger
5941 .subject_admission(&route.url)
5942 .is_some_and(|entry| !entry.executed)
5943 {
5944 if let Some(existing) = known_subjects.get_mut(route.url.as_str()) {
5945 merge_subject_route(
5946 existing,
5947 &route,
5948 current_subject.depth.saturating_add(1),
5949 self.limits.max_query_parameter_names(),
5950 );
5951 } else {
5952 known_subjects.insert(
5953 route.url.to_string(),
5954 WebAssessmentSubject {
5955 url: route.url,
5956 method: route.method,
5957 depth: current_subject.depth.saturating_add(1),
5958 origin: WebAssessmentSubjectOrigin::Discovered,
5959 query_parameter_names: route.query_parameter_names,
5960 evidence_ids: route.evidence_ids,
5961 },
5962 );
5963 }
5964 }
5965 }
5966 }
5967 },
5968 Err(()) => {
5969 let receipt = self.failure_receipt(
5970 known_subjects,
5971 completed_subjects,
5972 forms,
5973 current_report,
5974 incomplete_reasons,
5975 started_at,
5976 );
5977 return WebAssessmentRuntimeError::ProjectionInvariant { receipt };
5978 },
5979 }
5980 let receipt = self.failure_receipt(
5981 known_subjects,
5982 completed_subjects,
5983 forms,
5984 current_report,
5985 incomplete_reasons,
5986 started_at,
5987 );
5988 if receipt.inventory_consistent() {
5989 WebAssessmentRuntimeError::RunFailed {
5990 receipt,
5991 source: Box::new(source),
5992 }
5993 } else {
5994 WebAssessmentRuntimeError::ProjectionInvariant { receipt }
5995 }
5996 }
5997
5998 fn failure_receipt(
5999 &self,
6000 known_subjects: &BTreeMap<String, WebAssessmentSubject>,
6001 completed_subjects: Vec<WebAssessmentSubjectReport>,
6002 forms: Vec<WebAssessmentForm>,
6003 current_subject: WebAssessmentSubjectReport,
6004 mut incomplete_reasons: BTreeSet<WebAssessmentIncompleteReason>,
6005 started_at: tokio::time::Instant,
6006 ) -> Box<WebAssessmentFailureReceipt> {
6007 let semantics =
6008 self.extract_semantics_and_refresh_limits(&mut incomplete_reasons, started_at);
6009 let completed_urls: BTreeSet<_> = completed_subjects
6010 .iter()
6011 .map(|report| report.subject.url.to_string())
6012 .collect();
6013 let mut pending_subjects: Vec<_> = known_subjects
6014 .values()
6015 .filter(|subject| {
6016 subject.url != current_subject.subject.url
6017 && !completed_urls.contains(subject.url.as_str())
6018 })
6019 .cloned()
6020 .collect();
6021 pending_subjects.sort_by(|left, right| {
6022 left.depth
6023 .cmp(&right.depth)
6024 .then_with(|| left.url.as_str().cmp(right.url.as_str()))
6025 });
6026 let expected_executed: BTreeSet<_> = completed_subjects
6027 .iter()
6028 .filter(|report| report.was_executed())
6029 .map(|report| report.subject.url.to_string())
6030 .chain(
6031 current_subject
6032 .was_executed()
6033 .then(|| current_subject.subject.url.to_string()),
6034 )
6035 .collect();
6036 let unrepresented_ledger_subjects = self
6037 .ledger
6038 .subjects
6039 .keys()
6040 .filter(|url| !known_subjects.contains_key(url.as_str()))
6041 .count();
6042 let known_matches_ledger = known_subjects.len() == self.ledger.subjects.len()
6043 && known_subjects.iter().all(|(url, subject)| {
6044 self.ledger.subjects.get(url).is_some_and(|entry| {
6045 entry.method == subject.method
6046 && entry.query_parameter_names
6047 == subject
6048 .query_parameter_names
6049 .iter()
6050 .cloned()
6051 .collect::<BTreeSet<_>>()
6052 && entry.executed == expected_executed.contains(url)
6053 })
6054 });
6055 let retained_form_identities: BTreeSet<_> = forms
6056 .iter()
6057 .map(|form| FormIdentity {
6058 action: form.action.to_string(),
6059 method: form.method,
6060 })
6061 .collect();
6062 let inventory_consistent = known_matches_ledger
6063 && retained_form_identities.len() == forms.len()
6064 && retained_form_identities == self.ledger.form_identities;
6065 let executed_subjects = completed_subjects
6066 .iter()
6067 .filter(|report| report.was_executed())
6068 .count()
6069 .saturating_add(usize::from(current_subject.was_executed()));
6070 let mut usage = self.usage(
6071 completed_subjects
6072 .len()
6073 .saturating_add(1)
6074 .saturating_add(pending_subjects.len()),
6075 executed_subjects,
6076 forms.len(),
6077 started_at,
6078 );
6079 usage.retained_unique_url_bytes = completed_subjects
6080 .iter()
6081 .map(|report| report.subject.url.to_string())
6082 .chain(std::iter::once(current_subject.subject.url.to_string()))
6083 .chain(
6084 pending_subjects
6085 .iter()
6086 .map(|subject| subject.url.to_string()),
6087 )
6088 .chain(forms.iter().map(|form| form.action.to_string()))
6089 .collect::<BTreeSet<_>>()
6090 .iter()
6091 .map(String::len)
6092 .sum();
6093 Box::new(WebAssessmentFailureReceipt {
6094 completed_subjects,
6095 pending_subjects,
6096 forms,
6097 semantics,
6098 defense: self.defense_audit.clone(),
6099 #[cfg(feature = "supplied-session-review")]
6100 supplied_session: self.supplied_session_audit.clone(),
6101 #[cfg(feature = "websocket-review")]
6102 websocket_review: self.websocket_review_audit.clone(),
6103 #[cfg(feature = "jwt-target-acceptance-review")]
6104 jwt_target_acceptance: self.jwt_target_acceptance_audit.clone(),
6105 #[cfg(feature = "xml-external-entity-review")]
6106 xml_external_entity_review: self.xml_external_entity_review_audit.clone(),
6107 current_subject,
6108 incomplete_reasons,
6109 inventory_consistent,
6110 unrepresented_ledger_subjects,
6111 committed_passive_observations: self.passive_ledger.observations().len(),
6112 usage,
6113 transport: self.authority.request_accounting().dispatch_audit(),
6114 })
6115 }
6116
6117 fn usage(
6118 &self,
6119 retained_subjects: usize,
6120 executed_subjects: usize,
6121 retained_forms: usize,
6122 started_at: tokio::time::Instant,
6123 ) -> WebAssessmentUsage {
6124 let accounting = self.authority.request_accounting().snapshot();
6125 WebAssessmentUsage {
6126 retained_subjects,
6127 executed_subjects,
6128 retained_forms,
6129 retained_unique_url_bytes: self.ledger.retained_unique_url_bytes,
6130 total_requests: accounting.total_requests(),
6131 active_verifications: accounting.active_verifications(),
6132 request_body_bytes: accounting.request_body_bytes(),
6133 response_bytes: accounting.response_bytes(),
6134 elapsed_ms: duration_ms(started_at.elapsed()),
6135 }
6136 }
6137
6138 fn extract_semantics_and_refresh_limits(
6139 &self,
6140 reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6141 started_at: tokio::time::Instant,
6142 ) -> SemanticExtractionResult {
6143 let semantics = self.semantic_evidence.extract(&self.semantic_limits);
6144 if semantics.truncated {
6145 reasons.insert(WebAssessmentIncompleteReason::SemanticExtractionLimit);
6146 }
6147 if self.authority.cancellation().is_cancelled() {
6148 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
6149 }
6150 if started_at.elapsed() >= self.limits.max_wall_time() {
6151 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
6152 }
6153 semantics
6154 }
6155
6156 #[cfg(feature = "tls-negotiation-review")]
6161 fn refresh_parent_limits_after_tls_negotiation(
6162 &self,
6163 reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6164 started_at: tokio::time::Instant,
6165 ) {
6166 let Some(audit) = self.tls_negotiation_review_audit.as_ref() else {
6167 return;
6168 };
6169 if !audit.network_matrix_complete() {
6170 reasons.insert(WebAssessmentIncompleteReason::TlsNegotiationReviewIncomplete);
6171 }
6172 if self.authority.cancellation().is_cancelled() {
6173 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
6174 }
6175 if started_at.elapsed() >= self.limits.max_wall_time() {
6176 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
6177 }
6178 if audit.terminal() != "budget_exhausted" {
6179 return;
6180 }
6181 let accounting = self.authority.request_accounting().snapshot();
6182 if accounting.total_requests() >= self.limits.max_total_requests() {
6183 reasons.insert(WebAssessmentIncompleteReason::TotalRequestLimit);
6184 }
6185 if accounting.response_bytes() >= self.limits.max_total_response_bytes() {
6186 reasons.insert(WebAssessmentIncompleteReason::ResponseBytesLimit);
6187 }
6188 if accounting.active_verifications() >= self.runtime_active_verification_limit {
6189 reasons.insert(WebAssessmentIncompleteReason::ActiveVerificationLimit);
6190 }
6191 }
6192
6193 #[cfg(feature = "websocket-review")]
6203 fn refresh_parent_limits_after_websocket(
6204 &self,
6205 reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6206 started_at: tokio::time::Instant,
6207 ) {
6208 let Some(audit) = self.websocket_review_audit.as_ref() else {
6209 return;
6210 };
6211 if self.authority.cancellation().is_cancelled() {
6212 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
6213 }
6214 if started_at.elapsed() >= self.limits.max_wall_time() {
6215 reasons.insert(WebAssessmentIncompleteReason::WallTimeLimit);
6216 }
6217 if audit.first_failure() == Some(WebSocketReviewFailureKind::ParentResponseBudgetReached) {
6218 reasons.insert(WebAssessmentIncompleteReason::ResponseBytesLimit);
6219 }
6220 if audit.first_failure() != Some(WebSocketReviewFailureKind::ParentAuthorityUnavailable)
6221 || audit.request_attempt_count() == 0
6222 {
6223 return;
6224 }
6225
6226 let accounting = self.authority.request_accounting().snapshot();
6231 if accounting.total_requests() >= self.limits.max_total_requests() {
6232 reasons.insert(WebAssessmentIncompleteReason::TotalRequestLimit);
6233 }
6234 if accounting.response_bytes() >= self.limits.max_total_response_bytes() {
6235 reasons.insert(WebAssessmentIncompleteReason::ResponseBytesLimit);
6236 }
6237 }
6238
6239 fn replay_passive_bootstrap(
6240 &mut self,
6241 receipt: Option<&DecisionEvidenceReceipt>,
6242 expected_subject: &WebAssessmentSubject,
6243 reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6244 ) -> Result<(), ()> {
6245 let Some(receipt) = receipt else {
6246 return Ok(());
6247 };
6248 let observation = self.passive_ledger.ingest_receipt(
6249 receipt,
6250 self.authority.knowledge(),
6251 expected_subject,
6252 )?;
6253 #[cfg(feature = "secret-exposure-review")]
6254 if self.secret_exposure_collector.is_some() {
6255 self.secret_exposure_ledger.ingest_receipt(
6256 receipt,
6257 self.authority.knowledge(),
6258 expected_subject,
6259 )?;
6260 }
6261 if observation.is_some_and(|observation| observation.projection_incomplete()) {
6262 reasons.insert(WebAssessmentIncompleteReason::PassiveResponseProjectionLimit);
6263 }
6264 Ok(())
6265 }
6266}
6267
6268fn classify_standard_completion(
6269 report: &crate::StandardWebDecisionRunReport,
6270 reasons: &mut BTreeSet<WebAssessmentIncompleteReason>,
6271) {
6272 if let Some(limit) = report.limit_exceeded() {
6273 reasons.insert(reason_for_runtime_dimension(limit.dimension()));
6274 }
6275 match report.terminal() {
6276 DecisionLoopCommand::Complete { .. } => {},
6277 DecisionLoopCommand::AwaitHumanReview { .. } => {
6278 reasons.insert(WebAssessmentIncompleteReason::HumanReviewRequired);
6279 },
6280 DecisionLoopCommand::Halt { reason } => match reason {
6281 DecisionStopReason::ObjectiveComplete | DecisionStopReason::NoEligibleAction => {},
6282 DecisionStopReason::HumanReview => {
6283 reasons.insert(WebAssessmentIncompleteReason::HumanReviewRequired);
6284 },
6285 DecisionStopReason::ActionCycleLimit => {
6286 reasons.insert(WebAssessmentIncompleteReason::ActionCycleLimit);
6287 },
6288 DecisionStopReason::AdaptationLimit => {
6289 reasons.insert(WebAssessmentIncompleteReason::AdaptationLimit);
6290 },
6291 DecisionStopReason::CancelledByHost => {
6292 reasons.insert(WebAssessmentIncompleteReason::HostCancellation);
6293 },
6294 DecisionStopReason::RuntimeBudgetLimit => {
6295 if report.limit_exceeded().is_none() {
6296 reasons.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
6297 }
6298 },
6299 },
6300 DecisionLoopCommand::ExecuteAction { .. }
6301 | DecisionLoopCommand::CollectActiveEvidence { .. }
6302 | DecisionLoopCommand::Replan => {
6303 reasons.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
6304 },
6305 }
6306}
6307
6308fn failed_reasons(
6309 reasons: &BTreeSet<WebAssessmentIncompleteReason>,
6310) -> BTreeSet<WebAssessmentIncompleteReason> {
6311 let mut failed = reasons.clone();
6312 failed.insert(WebAssessmentIncompleteReason::SubjectExecutionIncomplete);
6313 failed
6314}
6315
6316const fn reason_for_runtime_dimension(
6317 dimension: RuntimeBudgetDimension,
6318) -> WebAssessmentIncompleteReason {
6319 match dimension {
6320 RuntimeBudgetDimension::TotalRequests => WebAssessmentIncompleteReason::TotalRequestLimit,
6321 RuntimeBudgetDimension::WallTime => WebAssessmentIncompleteReason::WallTimeLimit,
6322 RuntimeBudgetDimension::ResponseBytes => WebAssessmentIncompleteReason::ResponseBytesLimit,
6323 RuntimeBudgetDimension::RequestBodyBytes => {
6324 WebAssessmentIncompleteReason::RequestBodyBytesLimit
6325 },
6326 RuntimeBudgetDimension::ActiveVerifications => {
6327 WebAssessmentIncompleteReason::ActiveVerificationLimit
6328 },
6329 RuntimeBudgetDimension::SameActionAttempts => {
6330 WebAssessmentIncompleteReason::SameActionAttemptLimit
6331 },
6332 RuntimeBudgetDimension::ConsecutiveNoProgressTurns => {
6333 WebAssessmentIncompleteReason::ConsecutiveNoProgressLimit
6334 },
6335 }
6336}
6337
6338#[derive(Clone)]
6339struct PendingRoute {
6340 url: Url,
6341 method: WebAssessmentMethod,
6342 depth: u16,
6343 query_parameter_names: Vec<String>,
6344 evidence_ids: Vec<EvidenceId>,
6345}
6346
6347fn merge_pending_route(
6348 routes: &mut BTreeMap<String, PendingRoute>,
6349 candidate: PendingRoute,
6350 depth: u16,
6351 query_name_limit: usize,
6352) {
6353 let key = candidate.url.to_string();
6354 match routes.get_mut(&key) {
6355 Some(existing) => {
6356 if candidate.method == WebAssessmentMethod::Get {
6357 existing.method = WebAssessmentMethod::Get;
6358 }
6359 existing.depth = existing.depth.min(depth);
6360 existing.query_parameter_names = existing
6361 .query_parameter_names
6362 .iter()
6363 .cloned()
6364 .chain(candidate.query_parameter_names)
6365 .collect::<BTreeSet<_>>()
6366 .into_iter()
6367 .take(query_name_limit)
6368 .collect();
6369 existing.evidence_ids = existing
6370 .evidence_ids
6371 .iter()
6372 .cloned()
6373 .chain(candidate.evidence_ids)
6374 .collect::<BTreeSet<_>>()
6375 .into_iter()
6376 .collect();
6377 },
6378 None => {
6379 routes.insert(key, PendingRoute { depth, ..candidate });
6380 },
6381 }
6382}
6383
6384fn merge_subject_route(
6385 subject: &mut WebAssessmentSubject,
6386 candidate: &PendingRoute,
6387 depth: u16,
6388 query_name_limit: usize,
6389) {
6390 if candidate.method == WebAssessmentMethod::Get {
6391 subject.method = WebAssessmentMethod::Get;
6392 }
6393 subject.depth = subject.depth.min(depth);
6394 subject.query_parameter_names = subject
6395 .query_parameter_names
6396 .iter()
6397 .cloned()
6398 .chain(candidate.query_parameter_names.iter().cloned())
6399 .collect::<BTreeSet<_>>()
6400 .into_iter()
6401 .take(query_name_limit)
6402 .collect();
6403 subject.evidence_ids = subject
6404 .evidence_ids
6405 .iter()
6406 .cloned()
6407 .chain(candidate.evidence_ids.iter().cloned())
6408 .collect::<BTreeSet<_>>()
6409 .into_iter()
6410 .collect();
6411}
6412
6413#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
6416struct FormIdentity {
6417 action: String,
6418 method: WebAssessmentFormMethod,
6419}
6420
6421#[derive(Clone)]
6422struct SubjectAdmission {
6423 method: WebAssessmentMethod,
6424 query_parameter_names: BTreeSet<String>,
6425 executed: bool,
6426}
6427
6428#[derive(Clone)]
6429struct AssessmentEnvelope {
6430 subjects: BTreeMap<String, SubjectAdmission>,
6431 form_identities: BTreeSet<FormIdentity>,
6432 retained_urls: BTreeSet<String>,
6433 remaining_subjects: usize,
6434 remaining_forms: usize,
6435 remaining_url_bytes: usize,
6436 allow_children: bool,
6437}
6438
6439struct AssessmentLedger {
6440 subjects: BTreeMap<String, SubjectAdmission>,
6441 form_identities: BTreeSet<FormIdentity>,
6442 retained_urls: BTreeSet<String>,
6443 retained_unique_url_bytes: usize,
6444}
6445
6446impl AssessmentLedger {
6447 fn new(root: &WebAssessmentSubject) -> Self {
6448 let root_url = root.url.to_string();
6449 Self {
6450 subjects: BTreeMap::from([(
6451 root_url.clone(),
6452 SubjectAdmission {
6453 method: root.method,
6454 query_parameter_names: root.query_parameter_names.iter().cloned().collect(),
6455 executed: false,
6456 },
6457 )]),
6458 form_identities: BTreeSet::new(),
6459 retained_urls: BTreeSet::from([root_url.clone()]),
6460 retained_unique_url_bytes: root_url.len(),
6461 }
6462 }
6463
6464 fn snapshot(&self, limits: WebAssessmentLimits, subject_depth: u16) -> AssessmentEnvelope {
6465 AssessmentEnvelope {
6466 subjects: self.subjects.clone(),
6467 form_identities: self.form_identities.clone(),
6468 retained_urls: self.retained_urls.clone(),
6469 remaining_subjects: limits.max_subjects().saturating_sub(self.subjects.len()),
6470 remaining_forms: limits
6471 .max_forms()
6472 .saturating_sub(self.form_identities.len()),
6473 remaining_url_bytes: limits
6474 .max_retained_url_bytes()
6475 .saturating_sub(self.retained_unique_url_bytes),
6476 allow_children: subject_depth < limits.max_discovery_depth(),
6477 }
6478 }
6479
6480 fn mark_executed(&mut self, subject: &WebAssessmentSubject) -> Result<(), ()> {
6481 let entry = self.subjects.get_mut(subject.url.as_str()).ok_or(())?;
6482 entry.executed = true;
6483 entry.method = subject.method;
6484 entry.query_parameter_names = subject.query_parameter_names.iter().cloned().collect();
6485 Ok(())
6486 }
6487
6488 fn subject_admission(&self, url: &Url) -> Option<&SubjectAdmission> {
6489 self.subjects.get(url.as_str())
6490 }
6491
6492 fn apply(&mut self, projection: &DocumentProjection) {
6493 for route in &projection.routes {
6494 let key = route.url.to_string();
6495 match self.subjects.get_mut(&key) {
6496 Some(existing) if !existing.executed => {
6497 if route.method == WebAssessmentMethod::Get {
6498 existing.method = WebAssessmentMethod::Get;
6499 }
6500 existing
6501 .query_parameter_names
6502 .extend(route.query_parameter_names.iter().cloned());
6503 },
6504 Some(_) => {},
6505 None => {
6506 self.subjects.insert(
6507 key,
6508 SubjectAdmission {
6509 method: route.method,
6510 query_parameter_names: route
6511 .query_parameter_names
6512 .iter()
6513 .cloned()
6514 .collect(),
6515 executed: false,
6516 },
6517 );
6518 },
6519 }
6520 self.retain_url(&route.url);
6521 }
6522 for form in &projection.forms {
6523 self.form_identities.insert(FormIdentity {
6524 action: form.action.to_string(),
6525 method: form.method,
6526 });
6527 self.retain_url(&form.action);
6528 }
6529 }
6530
6531 fn retain_url(&mut self, url: &Url) {
6532 let value = url.to_string();
6533 if self.retained_urls.insert(value.clone()) {
6534 self.retained_unique_url_bytes =
6535 self.retained_unique_url_bytes.saturating_add(value.len());
6536 }
6537 }
6538}
6539
6540pub(crate) struct AssessmentDiscoveryObserver {
6545 policy: HttpEvidencePolicy,
6546 limits: WebAssessmentLimits,
6547 envelope: AssessmentEnvelope,
6548 expected_subject: String,
6549 expected_url: Url,
6550 expected_method: HttpProbeMethod,
6551 cancellation: CancellationToken,
6552 deadline: Option<tokio::time::Instant>,
6553 #[cfg(feature = "wordpress-review")]
6554 wordpress_signals: Option<WordPressSignalCollector>,
6555 #[cfg(feature = "wordpress-review")]
6556 wordpress_discovery_enabled: bool,
6557 #[cfg(feature = "wordpress-review")]
6558 wordpress_discovery_context: Option<WordPressDiscoveryContext>,
6559 #[cfg(feature = "wordpress-review")]
6560 wordpress_page_scope_selected: bool,
6561 #[cfg(feature = "wordpress-review")]
6562 wordpress_supplied_session_selected: bool,
6563 #[cfg(feature = "wordpress-review")]
6564 wordpress_page_candidate: Option<(WordPressPageCandidate, FrozenWordPressPageLayout)>,
6565 #[cfg(feature = "wordpress-review")]
6566 wordpress_asset_fingerprints: Option<WordPressAssetFingerprintCollector>,
6567 #[cfg(feature = "secret-exposure-review")]
6568 secret_exposure: Option<SecretExposureCollector>,
6569}
6570
6571impl AssessmentDiscoveryObserver {
6572 fn new(
6573 policy: HttpEvidencePolicy,
6574 limits: WebAssessmentLimits,
6575 envelope: AssessmentEnvelope,
6576 subject: &WebAssessmentSubject,
6577 cancellation: CancellationToken,
6578 deadline: Option<tokio::time::Instant>,
6579 ) -> Self {
6580 Self {
6581 policy,
6582 limits,
6583 envelope,
6584 expected_subject: format!("endpoint:{}", subject.url),
6585 expected_url: subject.url.clone(),
6586 expected_method: subject.method.probe_method(),
6587 cancellation,
6588 deadline,
6589 #[cfg(feature = "wordpress-review")]
6590 wordpress_signals: None,
6591 #[cfg(feature = "wordpress-review")]
6592 wordpress_discovery_enabled: false,
6593 #[cfg(feature = "wordpress-review")]
6594 wordpress_discovery_context: None,
6595 #[cfg(feature = "wordpress-review")]
6596 wordpress_page_scope_selected: false,
6597 #[cfg(feature = "wordpress-review")]
6598 wordpress_supplied_session_selected: false,
6599 #[cfg(feature = "wordpress-review")]
6600 wordpress_page_candidate: None,
6601 #[cfg(feature = "wordpress-review")]
6602 wordpress_asset_fingerprints: None,
6603 #[cfg(feature = "secret-exposure-review")]
6604 secret_exposure: None,
6605 }
6606 }
6607
6608 #[cfg(feature = "secret-exposure-review")]
6609 fn with_secret_exposure(mut self, collector: SecretExposureCollector) -> Self {
6610 self.secret_exposure = Some(collector);
6611 self
6612 }
6613
6614 #[cfg(feature = "wordpress-review")]
6615 fn with_wordpress_signals(
6616 mut self,
6617 collector: WordPressSignalCollector,
6618 discovery_enabled: bool,
6619 discovery_context: WordPressDiscoveryContext,
6620 page_scope_selected: bool,
6621 supplied_session_selected: bool,
6622 asset_fingerprints: Option<WordPressAssetFingerprintCollector>,
6623 ) -> Self {
6624 self.wordpress_signals = Some(collector);
6625 self.wordpress_discovery_enabled = discovery_enabled;
6626 self.wordpress_discovery_context = Some(discovery_context);
6627 self.wordpress_page_scope_selected = page_scope_selected;
6628 self.wordpress_supplied_session_selected = supplied_session_selected;
6629 self.wordpress_asset_fingerprints = asset_fingerprints;
6630 self
6631 }
6632
6633 #[cfg(feature = "wordpress-review")]
6634 fn with_wordpress_page_observation(
6635 mut self,
6636 collector: WordPressSignalCollector,
6637 candidate: WordPressPageCandidate,
6638 frozen_layout: FrozenWordPressPageLayout,
6639 asset_fingerprints: Option<WordPressAssetFingerprintCollector>,
6640 ) -> Self {
6641 self.wordpress_signals = Some(collector);
6642 self.wordpress_discovery_enabled = true;
6643 self.wordpress_page_scope_selected = true;
6644 self.wordpress_page_candidate = Some((candidate, frozen_layout));
6645 self.wordpress_asset_fingerprints = asset_fingerprints;
6646 self
6647 }
6648
6649 #[cfg(feature = "wordpress-review")]
6650 fn with_wordpress_asset_observation(
6651 mut self,
6652 collector: WordPressAssetFingerprintCollector,
6653 ) -> Self {
6654 self.wordpress_asset_fingerprints = Some(collector);
6655 self
6656 }
6657
6658 fn stopped(&self) -> bool {
6659 self.cancellation.is_cancelled()
6660 || self
6661 .deadline
6662 .is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
6663 }
6664
6665 fn admit_document(&self, parsed: ParsedDocument) -> AdmittedDocument {
6666 let mut admitted = AdmittedDocument {
6667 route_limit_reached: parsed.route_limit_reached,
6668 form_limit_reached: parsed.form_limit_reached,
6669 control_limit_reached: parsed.control_limit_reached,
6670 query_name_limit_reached: parsed.query_name_limit_reached,
6671 url_byte_limit_reached: parsed.url_byte_limit_reached,
6672 outside_origin_reference_count: parsed.outside_origin_reference_count,
6673 ..AdmittedDocument::default()
6674 };
6675 if !self.envelope.allow_children {
6676 admitted.depth_limit_reached = !parsed.routes.is_empty() || !parsed.forms.is_empty();
6677 return admitted;
6678 }
6679
6680 let mut envelope = self.envelope.clone();
6681 for route in parsed.routes {
6684 if self.policy.require_permitted_target(&route.url).is_err() {
6685 continue;
6686 }
6687 let key = route.url.to_string();
6688 if let Some(existing) = envelope.subjects.get_mut(&key) {
6689 if existing.executed {
6690 continue;
6691 }
6692 let merged_method = if existing.method == WebAssessmentMethod::Get
6693 || route.method == WebAssessmentMethod::Get
6694 {
6695 WebAssessmentMethod::Get
6696 } else {
6697 WebAssessmentMethod::Head
6698 };
6699 let method_changed = merged_method != existing.method;
6700 let mut admitted_names = Vec::new();
6701 for name in route.query_parameter_names {
6702 if existing.query_parameter_names.contains(&name) {
6703 continue;
6704 }
6705 if existing.query_parameter_names.len()
6706 >= self.limits.max_query_parameter_names()
6707 {
6708 admitted.query_name_limit_reached = true;
6709 continue;
6710 }
6711 existing.query_parameter_names.insert(name.clone());
6712 admitted_names.push(name);
6713 }
6714 if !method_changed && admitted_names.is_empty() {
6715 continue;
6716 }
6717 existing.method = merged_method;
6718 admitted.routes.push(ParsedRoute {
6719 url: route.url,
6720 method: route.method,
6721 query_parameter_names: admitted_names,
6722 });
6723 continue;
6724 }
6725 if envelope.remaining_subjects == 0 {
6726 admitted.subject_limit_reached = true;
6727 break;
6728 }
6729 if !admit_unique_url(&mut envelope, &route.url) {
6730 admitted.retained_url_limit_reached = true;
6731 break;
6732 }
6733 envelope.remaining_subjects = envelope.remaining_subjects.saturating_sub(1);
6734 envelope.subjects.insert(
6735 key,
6736 SubjectAdmission {
6737 method: route.method,
6738 query_parameter_names: route.query_parameter_names.iter().cloned().collect(),
6739 executed: false,
6740 },
6741 );
6742 admitted.routes.push(route);
6743 }
6744
6745 for form in parsed.forms {
6746 if self.policy.require_permitted_target(&form.action).is_err() {
6747 continue;
6748 }
6749 let identity = FormIdentity {
6750 action: form.action.to_string(),
6751 method: form.method,
6752 };
6753 if envelope.form_identities.contains(&identity) {
6754 continue;
6755 }
6756 if envelope.remaining_forms == 0 {
6757 admitted.form_limit_reached = true;
6758 break;
6759 }
6760 if !admit_unique_url(&mut envelope, &form.action) {
6761 admitted.retained_url_limit_reached = true;
6762 break;
6763 }
6764 envelope.remaining_forms = envelope.remaining_forms.saturating_sub(1);
6765 envelope.form_identities.insert(identity);
6766 admitted.forms.push(form);
6767 }
6768 admitted
6769 }
6770
6771 fn evidence_for_document(
6772 &self,
6773 observation: &CompleteHttpResponseObservation<'_>,
6774 admitted: AdmittedDocument,
6775 parents: Vec<EvidenceId>,
6776 ) -> Result<Vec<Evidence>, HttpEvidenceError> {
6777 let marker = derived_observation(
6778 observation,
6779 WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED,
6780 EvidenceValue::Boolean(true),
6781 "document-projected",
6782 parents,
6783 )?;
6784 let mut evidence = vec![marker.clone()];
6785
6786 for (condition, predicate, method) in [
6787 (
6788 admitted.route_limit_reached,
6789 WebDiscoveryEvidencePredicate::DOCUMENT_ROUTE_LIMIT_REACHED,
6790 "document-route-limit-reached",
6791 ),
6792 (
6793 admitted.form_limit_reached,
6794 WebDiscoveryEvidencePredicate::DOCUMENT_FORM_LIMIT_REACHED,
6795 "document-form-limit-reached",
6796 ),
6797 (
6798 admitted.control_limit_reached,
6799 WebDiscoveryEvidencePredicate::DOCUMENT_CONTROL_LIMIT_REACHED,
6800 "document-control-limit-reached",
6801 ),
6802 (
6803 admitted.query_name_limit_reached,
6804 WebDiscoveryEvidencePredicate::DOCUMENT_QUERY_NAME_LIMIT_REACHED,
6805 "document-query-name-limit-reached",
6806 ),
6807 (
6808 admitted.url_byte_limit_reached,
6809 WebDiscoveryEvidencePredicate::DOCUMENT_URL_BYTE_LIMIT_REACHED,
6810 "document-url-byte-limit-reached",
6811 ),
6812 (
6813 admitted.subject_limit_reached,
6814 WebDiscoveryEvidencePredicate::ASSESSMENT_SUBJECT_LIMIT_REACHED,
6815 "assessment-subject-limit-reached",
6816 ),
6817 (
6818 admitted.depth_limit_reached,
6819 WebDiscoveryEvidencePredicate::ASSESSMENT_DEPTH_LIMIT_REACHED,
6820 "assessment-depth-limit-reached",
6821 ),
6822 (
6823 admitted.retained_url_limit_reached,
6824 WebDiscoveryEvidencePredicate::ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED,
6825 "assessment-retained-url-byte-limit-reached",
6826 ),
6827 ] {
6828 if condition {
6829 evidence.push(derived_observation(
6830 observation,
6831 predicate,
6832 EvidenceValue::Boolean(true),
6833 method,
6834 [marker.id().clone()],
6835 )?);
6836 }
6837 }
6838 if admitted.outside_origin_reference_count > 0 {
6839 evidence.push(derived_observation(
6840 observation,
6841 WebDiscoveryEvidencePredicate::DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT,
6842 EvidenceValue::Unsigned(admitted.outside_origin_reference_count),
6843 "document-outside-origin-reference-count",
6844 [marker.id().clone()],
6845 )?);
6846 }
6847
6848 for route in admitted.routes {
6849 let predicate = match route.method {
6850 WebAssessmentMethod::Get => WebDiscoveryEvidencePredicate::GET_ROUTE,
6851 WebAssessmentMethod::Head => WebDiscoveryEvidencePredicate::HEAD_ROUTE,
6852 };
6853 let method = match route.method {
6854 WebAssessmentMethod::Get => "get-route",
6855 WebAssessmentMethod::Head => "head-route",
6856 };
6857 let parent = derived_observation(
6858 observation,
6859 predicate,
6860 EvidenceValue::Text(route.url.to_string()),
6861 method,
6862 [marker.id().clone()],
6863 )?;
6864 let parent_id = parent.id().clone();
6865 evidence.push(parent);
6866 if !route.query_parameter_names.is_empty() {
6867 evidence.push(derived_observation(
6868 observation,
6869 WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES,
6870 EvidenceValue::TextList(route.query_parameter_names),
6871 "route-query-parameter-names",
6872 [parent_id],
6873 )?);
6874 }
6875 }
6876
6877 for form in admitted.forms {
6878 let (predicate, method) = match form.method {
6879 WebAssessmentFormMethod::Get => (
6880 WebDiscoveryEvidencePredicate::GET_FORM_ACTION,
6881 "get-form-action",
6882 ),
6883 WebAssessmentFormMethod::Post => (
6884 WebDiscoveryEvidencePredicate::POST_FORM_ACTION,
6885 "post-form-action",
6886 ),
6887 WebAssessmentFormMethod::Dialog => (
6888 WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION,
6889 "dialog-form-action",
6890 ),
6891 };
6892 let parent = derived_observation(
6893 observation,
6894 predicate,
6895 EvidenceValue::Text(form.action.to_string()),
6896 method,
6897 [marker.id().clone()],
6898 )?;
6899 let parent_id = parent.id().clone();
6900 evidence.push(parent);
6901 if !form.query_parameter_names.is_empty() {
6902 evidence.push(derived_observation(
6903 observation,
6904 WebDiscoveryEvidencePredicate::FORM_QUERY_PARAMETER_NAMES,
6905 EvidenceValue::TextList(form.query_parameter_names),
6906 "form-query-parameter-names",
6907 [parent_id.clone()],
6908 )?);
6909 }
6910 if !form.control_names.is_empty() {
6911 evidence.push(derived_observation(
6912 observation,
6913 WebDiscoveryEvidencePredicate::FORM_CONTROL_NAMES,
6914 EvidenceValue::TextList(form.control_names),
6915 "form-control-names",
6916 [parent_id],
6917 )?);
6918 }
6919 }
6920 Ok(evidence)
6921 }
6922}
6923
6924impl CompleteHttpResponseObserver for AssessmentDiscoveryObserver {
6925 fn observe(
6926 &self,
6927 observation: CompleteHttpResponseObservation<'_>,
6928 ) -> Result<(Vec<Evidence>, ResponseBodyDigestDisposition), HttpEvidenceError> {
6929 if observation.case_id() != BOOTSTRAP_CASE_ID
6930 || observation.action_id() != BOOTSTRAP_ACTION_ID
6931 || observation.hypothesis_id() != BOOTSTRAP_HYPOTHESIS_ID
6932 || observation.has_payload_strategy()
6933 || !observation.applies_hypothesis_transition()
6934 || observation.stage() != DecisionExecutionStage::Passive
6935 || observation.subject().as_str() != self.expected_subject
6936 || observation.method() != self.expected_method
6937 || observation.requested_url() != &self.expected_url
6938 || observation.requested_url().query().is_some()
6939 || observation.requested_url().fragment().is_some()
6940 || self
6941 .policy
6942 .require_permitted_target(observation.requested_url())
6943 .is_err()
6944 {
6945 #[cfg(feature = "secret-exposure-review")]
6946 if self.secret_exposure.is_some() {
6947 return Err(HttpEvidenceError::AssessmentObserverInvariant {
6948 invariant: "secret-exposure observer routing",
6949 });
6950 }
6951 return Ok((Vec::new(), ResponseBodyDigestDisposition::Retain));
6952 }
6953 let mut evidence = project_assessment_passive_response(
6957 observation.passive_response_projection(),
6958 AssessmentPassiveProjectionContext {
6959 subject: observation.subject(),
6960 case_id: observation.case_id(),
6961 executor_id: HTTP_EVIDENCE_EXECUTOR_ID,
6962 reliability: observation.reliability(),
6963 parents: passive_projection_parents(&observation)?,
6964 },
6965 )?;
6966 #[cfg(feature = "secret-exposure-review")]
6967 let mut secret_exposure_lineage = None;
6968 #[cfg(feature = "secret-exposure-review")]
6969 if let Some(secret_exposure) = self.secret_exposure.as_ref() {
6970 let secret_exposure_parents = secret_exposure_projection_parents(&observation)?;
6971 let projected =
6972 secret_exposure.observe(&observation, secret_exposure_parents.clone())?;
6973 secret_exposure_lineage = secret_exposure_response_lineage_id(
6974 &projected,
6975 observation.subject(),
6976 observation.case_id(),
6977 &secret_exposure_parents,
6978 );
6979 let lineage_record_present = projected.iter().any(|item| {
6980 item.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
6981 && item.predicate().name() == RESPONSE_BODY_LINEAGE
6982 });
6983 if lineage_record_present != secret_exposure_lineage.is_some() {
6984 return Err(HttpEvidenceError::AssessmentObserverInvariant {
6985 invariant: "secret-exposure response lineage",
6986 });
6987 }
6988 evidence.extend(projected);
6989 }
6990 #[cfg(not(feature = "secret-exposure-review"))]
6991 let secret_exposure_lineage: Option<EvidenceId> = None;
6992 #[cfg(feature = "wordpress-review")]
6993 if let Some(fingerprints) = self.wordpress_asset_fingerprints.as_ref() {
6994 if observation.method() == HttpProbeMethod::Get {
6995 fingerprints
6996 .mark_ordinary_get_attempted(observation.requested_url())
6997 .map_err(|_| HttpEvidenceError::AssessmentObserverInvariant {
6998 invariant: "ordinary WordPress asset GET history must remain bounded",
6999 })?;
7000 }
7001 if secret_exposure_lineage.is_none() {
7006 evidence.extend(fingerprints.observe_selected_response(&observation)?);
7007 }
7008 }
7009 if self.expected_method == HttpProbeMethod::Head {
7010 return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7011 }
7012 let Some(body) = observation.complete_body() else {
7013 evidence.push(derived_observation(
7014 &observation,
7015 WebDiscoveryEvidencePredicate::DOCUMENT_BODY_INCOMPLETE,
7016 EvidenceValue::Boolean(true),
7017 "document-body-incomplete",
7018 incomplete_projection_parents(&observation, secret_exposure_lineage.as_ref())?,
7019 )?);
7020 return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7021 };
7022 if self.stopped() {
7023 return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7024 }
7025 if observation.media_type() != Some("text/html")
7026 || !matches!(observation.status(), 200 | 206)
7027 {
7028 return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7029 }
7030 let parents = complete_projection_parents(&observation, secret_exposure_lineage.as_ref())?;
7031 if observation.status() == 206 {
7032 evidence.push(derived_observation(
7033 &observation,
7034 WebDiscoveryEvidencePredicate::DOCUMENT_PARTIAL_REPRESENTATION,
7035 EvidenceValue::Boolean(true),
7036 "document-partial-representation",
7037 parents,
7038 )?);
7039 return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7040 }
7041 let Ok(html) = std::str::from_utf8(body) else {
7042 evidence.push(derived_observation(
7043 &observation,
7044 WebDiscoveryEvidencePredicate::DOCUMENT_INVALID_UTF8,
7045 EvidenceValue::Boolean(true),
7046 "document-invalid-utf8",
7047 parents,
7048 )?);
7049 return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7050 };
7051 if secret_exposure_lineage.is_some() {
7057 return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7058 }
7059 let parsed = parse_document(observation.requested_url(), html, self.limits);
7060 #[cfg(feature = "wordpress-review")]
7061 let wordpress_observation = self.wordpress_discovery_context.as_ref().map(|context| {
7062 extract_wordpress_entry_observation_with_asset_fingerprints(
7063 context,
7064 observation.requested_url(),
7065 html,
7066 observation.wordpress_rest_index_advertisement(),
7067 self.wordpress_discovery_enabled,
7068 self.wordpress_page_scope_selected,
7069 self.wordpress_asset_fingerprints.is_some(),
7070 )
7071 });
7072 #[cfg(feature = "wordpress-review")]
7073 let wordpress_page_observation =
7074 self.wordpress_page_candidate
7075 .as_ref()
7076 .map(|(candidate, frozen_layout)| {
7077 let mut page_parents = parents.clone();
7078 if let Some(final_url) = observation.response_final_url_evidence_id() {
7079 if !page_parents.contains(final_url) {
7080 page_parents.push(final_url.clone());
7081 }
7082 }
7083 extract_wordpress_page_observation_with_asset_fingerprints(
7084 candidate.clone(),
7085 frozen_layout,
7086 html,
7087 page_parents,
7088 self.wordpress_asset_fingerprints.is_some(),
7089 )
7090 });
7091 if self.stopped() {
7092 return Ok(complete_observer_output(evidence, secret_exposure_lineage));
7093 }
7094 let admitted = self.admit_document(parsed);
7095 #[cfg(feature = "wordpress-review")]
7096 let wordpress_evidence = {
7097 let mut evidence_ids = parents.clone();
7098 if self.wordpress_page_scope_selected || self.wordpress_supplied_session_selected {
7099 if let Some(final_url) = observation.response_final_url_evidence_id() {
7100 if !evidence_ids.contains(final_url) {
7101 evidence_ids.push(final_url.clone());
7102 }
7103 }
7104 }
7105 evidence_ids
7106 };
7107 #[cfg(feature = "wordpress-review")]
7108 let wordpress_fingerprint_evidence = self
7109 .wordpress_asset_fingerprints
7110 .as_ref()
7111 .map(|_| {
7112 complete_fingerprint_projection_parents(
7113 &observation,
7114 secret_exposure_lineage.as_ref(),
7115 )
7116 })
7117 .transpose()?;
7118 evidence.extend(self.evidence_for_document(&observation, admitted, parents)?);
7119 if self.stopped() {
7120 evidence.retain(|item| {
7121 item.predicate().namespace() == ASSESSMENT_PASSIVE_NAMESPACE || {
7122 #[cfg(feature = "secret-exposure-review")]
7123 {
7124 item.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
7125 }
7126 #[cfg(not(feature = "secret-exposure-review"))]
7127 {
7128 false
7129 }
7130 }
7131 });
7132 } else {
7133 #[cfg(feature = "wordpress-review")]
7134 if let Some(collector) = self.wordpress_signals.as_ref() {
7135 if let Some(wordpress_observation) = wordpress_observation {
7136 if let Some(fingerprints) = self.wordpress_asset_fingerprints.as_ref() {
7137 fingerprints
7138 .stage_candidates(
7139 observation.requested_url(),
7140 wordpress_observation.asset_fingerprint_candidates.clone(),
7141 wordpress_fingerprint_evidence.clone().unwrap_or_default(),
7142 wordpress_observation.asset_fingerprint_candidate_limit_exceeded,
7143 )
7144 .map_err(|_| HttpEvidenceError::AssessmentObserverInvariant {
7145 invariant: "WordPress asset nominations must remain bounded",
7146 })?;
7147 }
7148 if self.wordpress_page_scope_selected
7149 || self.wordpress_supplied_session_selected
7150 {
7151 collector.stage_entry(wordpress_observation, wordpress_evidence);
7152 } else {
7153 collector.record(
7154 wordpress_observation.signals,
7155 wordpress_observation.discovery,
7156 wordpress_evidence,
7157 );
7158 }
7159 }
7160 if let Some(wordpress_page_observation) = wordpress_page_observation {
7161 if let Some(fingerprints) = self.wordpress_asset_fingerprints.as_ref() {
7162 fingerprints
7163 .stage_candidates(
7164 observation.requested_url(),
7165 wordpress_page_observation
7166 .asset_fingerprint_candidates
7167 .clone(),
7168 wordpress_fingerprint_evidence.clone().unwrap_or_default(),
7169 wordpress_page_observation
7170 .asset_fingerprint_candidate_limit_exceeded,
7171 )
7172 .map_err(|_| HttpEvidenceError::AssessmentObserverInvariant {
7173 invariant: "WordPress page asset nominations must remain bounded",
7174 })?;
7175 }
7176 collector.stage_page(wordpress_page_observation);
7177 }
7178 }
7179 }
7180 Ok(complete_observer_output(evidence, secret_exposure_lineage))
7181 }
7182}
7183
7184fn complete_observer_output(
7185 evidence: Vec<Evidence>,
7186 secret_exposure_lineage: Option<EvidenceId>,
7187) -> (Vec<Evidence>, ResponseBodyDigestDisposition) {
7188 match secret_exposure_lineage {
7189 Some(lineage_evidence_id) => (
7190 evidence,
7191 ResponseBodyDigestDisposition::Suppress {
7192 lineage_evidence_id,
7193 },
7194 ),
7195 None => (evidence, ResponseBodyDigestDisposition::Retain),
7196 }
7197}
7198
7199#[derive(Default)]
7200struct AdmittedDocument {
7201 routes: Vec<ParsedRoute>,
7202 forms: Vec<ParsedForm>,
7203 route_limit_reached: bool,
7204 form_limit_reached: bool,
7205 control_limit_reached: bool,
7206 query_name_limit_reached: bool,
7207 url_byte_limit_reached: bool,
7208 subject_limit_reached: bool,
7209 depth_limit_reached: bool,
7210 retained_url_limit_reached: bool,
7211 outside_origin_reference_count: u64,
7212}
7213
7214fn admit_unique_url(envelope: &mut AssessmentEnvelope, url: &Url) -> bool {
7215 let value = url.to_string();
7216 if envelope.retained_urls.contains(&value) {
7217 return true;
7218 }
7219 if value.len() > envelope.remaining_url_bytes {
7220 return false;
7221 }
7222 envelope.remaining_url_bytes -= value.len();
7223 envelope.retained_urls.insert(value);
7224 true
7225}
7226
7227fn complete_projection_parents(
7228 observation: &CompleteHttpResponseObservation<'_>,
7229 secret_exposure_lineage: Option<&EvidenceId>,
7230) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7231 let mut parents = [
7232 (
7233 observation.request_method_evidence_id(),
7234 "request-method-evidence",
7235 ),
7236 (
7237 observation.request_url_evidence_id(),
7238 "request-url-evidence",
7239 ),
7240 (
7241 observation.response_status_evidence_id(),
7242 "response-status-evidence",
7243 ),
7244 (
7245 observation.response_media_type_evidence_id(),
7246 "response-media-type-evidence",
7247 ),
7248 (
7249 observation.response_body_truncated_evidence_id(),
7250 "response-body-truncated-evidence",
7251 ),
7252 ]
7253 .into_iter()
7254 .map(|(id, invariant)| {
7255 id.cloned()
7256 .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7257 })
7258 .collect::<Result<Vec<_>, _>>()?;
7259 parents.push(body_content_lineage(observation, secret_exposure_lineage)?);
7260 Ok(parents)
7261}
7262
7263#[cfg(feature = "secret-exposure-review")]
7264fn secret_exposure_projection_parents(
7265 observation: &CompleteHttpResponseObservation<'_>,
7266) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7267 let mut parents = [
7268 (
7269 observation.request_method_evidence_id(),
7270 "request-method-evidence",
7271 ),
7272 (
7273 observation.request_url_evidence_id(),
7274 "request-url-evidence",
7275 ),
7276 (
7277 observation.response_status_evidence_id(),
7278 "response-status-evidence",
7279 ),
7280 (
7281 observation.response_final_url_evidence_id(),
7282 "response-final-url-evidence",
7283 ),
7284 (
7285 observation.response_body_bytes_evidence_id(),
7286 "response-body-byte-count-evidence",
7287 ),
7288 (
7289 observation.response_body_truncated_evidence_id(),
7290 "response-body-truncated-evidence",
7291 ),
7292 ]
7293 .into_iter()
7294 .map(|(id, invariant)| {
7295 id.cloned()
7296 .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7297 })
7298 .collect::<Result<Vec<_>, _>>()?;
7299 if let Some(media) = observation.response_media_type_evidence_id() {
7300 parents.push(media.clone());
7301 }
7302 Ok(parents)
7303}
7304
7305#[cfg(feature = "wordpress-review")]
7306fn complete_fingerprint_projection_parents(
7307 observation: &CompleteHttpResponseObservation<'_>,
7308 secret_exposure_lineage: Option<&EvidenceId>,
7309) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7310 let mut parents = [
7311 (
7312 observation.request_method_evidence_id(),
7313 "request-method-evidence",
7314 ),
7315 (
7316 observation.request_url_evidence_id(),
7317 "request-url-evidence",
7318 ),
7319 (
7320 observation.response_status_evidence_id(),
7321 "response-status-evidence",
7322 ),
7323 (
7324 observation.response_final_url_evidence_id(),
7325 "response-final-url-evidence",
7326 ),
7327 (
7328 observation.response_media_type_evidence_id(),
7329 "response-media-type-evidence",
7330 ),
7331 (
7332 observation.response_body_bytes_evidence_id(),
7333 "response-body-byte-count-evidence",
7334 ),
7335 (
7336 observation.response_body_truncated_evidence_id(),
7337 "response-body-truncated-evidence",
7338 ),
7339 ]
7340 .into_iter()
7341 .map(|(id, invariant)| {
7342 id.cloned()
7343 .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7344 })
7345 .collect::<Result<Vec<_>, _>>()?;
7346 parents.push(body_content_lineage(observation, secret_exposure_lineage)?);
7347 Ok(parents)
7348}
7349
7350fn passive_projection_parents(
7351 observation: &CompleteHttpResponseObservation<'_>,
7352) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7353 [
7354 (
7355 observation.request_method_evidence_id(),
7356 "request-method-evidence",
7357 ),
7358 (
7359 observation.request_url_evidence_id(),
7360 "request-url-evidence",
7361 ),
7362 (
7363 observation.response_status_evidence_id(),
7364 "response-status-evidence",
7365 ),
7366 (
7367 observation.response_final_url_evidence_id(),
7368 "response-final-url-evidence",
7369 ),
7370 ]
7371 .into_iter()
7372 .map(|(id, invariant)| {
7373 id.cloned()
7374 .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7375 })
7376 .collect()
7377}
7378
7379fn incomplete_projection_parents(
7380 observation: &CompleteHttpResponseObservation<'_>,
7381 secret_exposure_lineage: Option<&EvidenceId>,
7382) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
7383 let mut parents = [
7384 (
7385 observation.request_method_evidence_id(),
7386 "request-method-evidence",
7387 ),
7388 (
7389 observation.request_url_evidence_id(),
7390 "request-url-evidence",
7391 ),
7392 (
7393 observation.response_status_evidence_id(),
7394 "response-status-evidence",
7395 ),
7396 (
7397 observation.response_body_truncated_evidence_id(),
7398 "response-body-truncated-evidence",
7399 ),
7400 ]
7401 .into_iter()
7402 .map(|(id, invariant)| {
7403 id.cloned()
7404 .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
7405 })
7406 .collect::<Result<Vec<_>, _>>()?;
7407 parents.push(body_content_lineage(observation, secret_exposure_lineage)?);
7408 if let Some(media_id) = observation.response_media_type_evidence_id() {
7409 parents.push(media_id.clone());
7410 }
7411 Ok(parents)
7412}
7413
7414fn body_content_lineage(
7415 observation: &CompleteHttpResponseObservation<'_>,
7416 secret_exposure_lineage: Option<&EvidenceId>,
7417) -> Result<EvidenceId, HttpEvidenceError> {
7418 secret_exposure_lineage
7419 .cloned()
7420 .or_else(|| observation.response_body_digest_evidence_id().cloned())
7421 .ok_or(HttpEvidenceError::AssessmentObserverInvariant {
7422 invariant: "response-body-lineage-evidence",
7423 })
7424}
7425
7426fn derived_observation(
7427 observation: &CompleteHttpResponseObservation<'_>,
7428 predicate: PredicateDescriptor,
7429 value: EvidenceValue,
7430 method: &'static str,
7431 parents: impl IntoIterator<Item = EvidenceId>,
7432) -> Result<Evidence, HttpEvidenceError> {
7433 let source = EvidenceSource::new(HTTP_EVIDENCE_EXECUTOR_ID, method)?
7434 .with_correlation_id(observation.case_id())?;
7435 let derivation = EvidenceDerivation::new(
7436 parents,
7437 DerivationAlgorithm::new("web.discovery.html5ever-names-only", 1)?,
7438 )?;
7439 Ok(Evidence::new(
7440 observation.subject().clone(),
7441 EvidenceKind::Content,
7442 predicate.into(),
7443 value,
7444 source,
7445 observation.reliability(),
7446 )
7447 .derived_from(derivation))
7448}
7449
7450#[derive(Default)]
7451struct DocumentProjection {
7452 routes: Vec<PendingRoute>,
7453 forms: Vec<WebAssessmentForm>,
7454 explicit_reason: Option<WebAssessmentIncompleteReason>,
7455 subject_limit_reached: bool,
7456 depth_limit_reached: bool,
7457 route_limit_reached: bool,
7458 form_limit_reached: bool,
7459 control_limit_reached: bool,
7460 query_name_limit_reached: bool,
7461 canonical_url_byte_limit_reached: bool,
7462 retained_url_byte_limit_reached: bool,
7463}
7464
7465impl DocumentProjection {
7466 fn add_reasons(&self, reasons: &mut BTreeSet<WebAssessmentIncompleteReason>) {
7467 if let Some(reason) = self.explicit_reason {
7468 reasons.insert(reason);
7469 }
7470 for (condition, reason) in [
7471 (
7472 self.subject_limit_reached,
7473 WebAssessmentIncompleteReason::SubjectLimit,
7474 ),
7475 (
7476 self.depth_limit_reached,
7477 WebAssessmentIncompleteReason::DiscoveryDepthLimit,
7478 ),
7479 (
7480 self.route_limit_reached,
7481 WebAssessmentIncompleteReason::DocumentReferenceLimit,
7482 ),
7483 (
7484 self.form_limit_reached,
7485 WebAssessmentIncompleteReason::FormLimit,
7486 ),
7487 (
7488 self.control_limit_reached,
7489 WebAssessmentIncompleteReason::FormControlLimit,
7490 ),
7491 (
7492 self.query_name_limit_reached,
7493 WebAssessmentIncompleteReason::QueryParameterNameLimit,
7494 ),
7495 (
7496 self.canonical_url_byte_limit_reached,
7497 WebAssessmentIncompleteReason::CanonicalUrlBytesLimit,
7498 ),
7499 (
7500 self.retained_url_byte_limit_reached,
7501 WebAssessmentIncompleteReason::RetainedUrlBytesLimit,
7502 ),
7503 ] {
7504 if condition {
7505 reasons.insert(reason);
7506 }
7507 }
7508 }
7509}
7510
7511struct ProjectionBase {
7512 parent_ids: Vec<EvidenceId>,
7513 status: u16,
7514 body_truncated: bool,
7515}
7516
7517fn projection_from_committed_bootstrap(
7518 receipt: Option<&DecisionEvidenceReceipt>,
7519 knowledge: &KnowledgeBase,
7520 subject: &WebAssessmentSubject,
7521 policy: &HttpEvidencePolicy,
7522 limits: WebAssessmentLimits,
7523 envelope: &AssessmentEnvelope,
7524) -> Result<Option<DocumentProjection>, ()> {
7525 let Some(receipt) = receipt else {
7526 return Ok(None);
7527 };
7528 if receipt.case().id() != BOOTSTRAP_CASE_ID
7529 || receipt.case().action_id() != BOOTSTRAP_ACTION_ID
7530 || receipt.case().subject().as_str() != format!("endpoint:{}", subject.url)
7531 || receipt.case().hypothesis_id() != BOOTSTRAP_HYPOTHESIS_ID
7532 || receipt.case().payload_strategy().is_some()
7533 || !receipt.case().applies_hypothesis_transition()
7534 || receipt.executor_id() != HTTP_EVIDENCE_EXECUTOR_ID
7535 || receipt.stage() != DecisionExecutionStage::Passive
7536 || receipt.evidence().len() != receipt.writes().len()
7537 {
7538 return Err(());
7539 }
7540 for evidence in receipt.evidence() {
7541 if knowledge.evidence(evidence.id()).as_ref() != Some(evidence) {
7542 return Err(());
7543 }
7544 }
7545 let bootstrap_envelope = validate_bootstrap_request_envelope(receipt, subject)?;
7546
7547 let Some(first_discovery) = receipt
7548 .evidence()
7549 .iter()
7550 .position(|evidence| evidence.predicate().namespace() == "web.discovery")
7551 else {
7552 if subject.method == WebAssessmentMethod::Get && bootstrap_envelope.body_truncated {
7553 return Err(());
7557 }
7558 return Ok(None);
7559 };
7560 if receipt.evidence()[..first_discovery]
7561 .iter()
7562 .any(|evidence| evidence.predicate().namespace() == ASSESSMENT_DEFENSE_NAMESPACE)
7563 {
7564 return Err(());
7565 }
7566 let discovery_tail = &receipt.evidence()[first_discovery..];
7567 let discovery_len = discovery_tail
7568 .iter()
7569 .position(|evidence| evidence.predicate().namespace() != "web.discovery")
7570 .unwrap_or(discovery_tail.len());
7571 let (discovery, trailing) = discovery_tail.split_at(discovery_len);
7572 if trailing
7577 .iter()
7578 .any(|evidence| evidence.predicate().namespace() != ASSESSMENT_DEFENSE_NAMESPACE)
7579 {
7580 return Err(());
7581 }
7582 let first = discovery.first().ok_or(())?;
7583
7584 if first.predicate()
7585 == &WebDiscoveryEvidencePredicate::DOCUMENT_BODY_INCOMPLETE.into_knowledge()
7586 {
7587 let base = projection_base(receipt, subject, false)?;
7588 if discovery.len() != 1 {
7589 return Err(());
7590 }
7591 validate_discovery_evidence(first, receipt, "document-body-incomplete", &base.parent_ids)?;
7592 require_true(first)?;
7593 return Ok(Some(DocumentProjection {
7594 explicit_reason: Some(WebAssessmentIncompleteReason::ResponseBodyIncomplete),
7595 ..DocumentProjection::default()
7596 }));
7597 }
7598 let base = projection_base(receipt, subject, true)?;
7599 if first.predicate()
7600 == &WebDiscoveryEvidencePredicate::DOCUMENT_PARTIAL_REPRESENTATION.into_knowledge()
7601 {
7602 if discovery.len() != 1 || base.status != 206 || base.body_truncated {
7603 return Err(());
7604 }
7605 validate_discovery_evidence(
7606 first,
7607 receipt,
7608 "document-partial-representation",
7609 &base.parent_ids,
7610 )?;
7611 require_true(first)?;
7612 return Ok(Some(DocumentProjection {
7613 explicit_reason: Some(WebAssessmentIncompleteReason::PartialRepresentation),
7614 ..DocumentProjection::default()
7615 }));
7616 }
7617 if first.predicate() == &WebDiscoveryEvidencePredicate::DOCUMENT_INVALID_UTF8.into_knowledge() {
7618 if discovery.len() != 1 || base.status != 200 || base.body_truncated {
7619 return Err(());
7620 }
7621 validate_discovery_evidence(first, receipt, "document-invalid-utf8", &base.parent_ids)?;
7622 require_true(first)?;
7623 return Ok(Some(DocumentProjection {
7624 explicit_reason: Some(WebAssessmentIncompleteReason::InvalidUtf8),
7625 ..DocumentProjection::default()
7626 }));
7627 }
7628 if base.status != 200
7629 || base.body_truncated
7630 || first.predicate() != &WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED.into_knowledge()
7631 {
7632 return Err(());
7633 }
7634 validate_discovery_evidence(first, receipt, "document-projected", &base.parent_ids)?;
7635 require_true(first)?;
7636 let marker_id = first.id().clone();
7637 let mut projection = DocumentProjection::default();
7638 let mut index = 1;
7639 let mut last_flag_rank = None;
7640
7641 while let Some(evidence) = discovery.get(index) {
7642 let Some((rank, flag)) = projection_flag(evidence.predicate()) else {
7643 break;
7644 };
7645 if last_flag_rank.is_some_and(|previous| rank <= previous) {
7646 return Err(());
7647 }
7648 validate_discovery_evidence(
7649 evidence,
7650 receipt,
7651 discovery_method(evidence.predicate()).ok_or(())?,
7652 std::slice::from_ref(&marker_id),
7653 )?;
7654 match flag {
7655 ProjectionFlag::OutsideOriginCount => {
7656 if !matches!(evidence.value(), EvidenceValue::Unsigned(value) if *value > 0) {
7657 return Err(());
7658 }
7659 },
7660 flag => {
7661 require_true(evidence)?;
7662 set_projection_flag(&mut projection, flag)?;
7663 },
7664 }
7665 last_flag_rank = Some(rank);
7666 index += 1;
7667 }
7668
7669 let mut last_route_url: Option<String> = None;
7670 while let Some(evidence) = discovery.get(index) {
7671 let predicate = evidence.predicate();
7672 if predicate != &WebDiscoveryEvidencePredicate::GET_ROUTE.into_knowledge()
7673 && predicate != &WebDiscoveryEvidencePredicate::HEAD_ROUTE.into_knowledge()
7674 {
7675 break;
7676 }
7677 validate_discovery_evidence(
7678 evidence,
7679 receipt,
7680 discovery_method(predicate).ok_or(())?,
7681 std::slice::from_ref(&marker_id),
7682 )?;
7683 let url = canonical_evidence_url(evidence, policy, limits)?;
7684 let key = url.to_string();
7685 if last_route_url
7686 .as_ref()
7687 .is_some_and(|previous| previous >= &key)
7688 {
7689 return Err(());
7690 }
7691 last_route_url = Some(key);
7692 let method = if predicate == &WebDiscoveryEvidencePredicate::GET_ROUTE.into_knowledge() {
7693 WebAssessmentMethod::Get
7694 } else {
7695 WebAssessmentMethod::Head
7696 };
7697 let mut route = PendingRoute {
7698 url,
7699 method,
7700 depth: 0,
7701 query_parameter_names: Vec::new(),
7702 evidence_ids: vec![evidence.id().clone()],
7703 };
7704 let parent_id = evidence.id().clone();
7705 index += 1;
7706 if let Some(child) = discovery.get(index).filter(|child| {
7707 child.predicate()
7708 == &WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES.into_knowledge()
7709 }) {
7710 validate_discovery_evidence(
7711 child,
7712 receipt,
7713 "route-query-parameter-names",
7714 std::slice::from_ref(&parent_id),
7715 )?;
7716 route.query_parameter_names =
7717 valid_name_list(child.value(), limits.max_query_parameter_names())?;
7718 route.evidence_ids.push(child.id().clone());
7719 index += 1;
7720 }
7721 projection.routes.push(route);
7722 }
7723
7724 let mut last_form: Option<FormIdentity> = None;
7725 while let Some(evidence) = discovery.get(index) {
7726 if !is_form_action_predicate(evidence.predicate()) {
7727 return Err(());
7728 }
7729 validate_discovery_evidence(
7730 evidence,
7731 receipt,
7732 discovery_method(evidence.predicate()).ok_or(())?,
7733 std::slice::from_ref(&marker_id),
7734 )?;
7735 let action = canonical_evidence_url(evidence, policy, limits)?;
7736 let method = form_method_for_predicate(evidence.predicate()).ok_or(())?;
7737 let identity = FormIdentity {
7738 action: action.to_string(),
7739 method,
7740 };
7741 if last_form
7742 .as_ref()
7743 .is_some_and(|previous| previous >= &identity)
7744 {
7745 return Err(());
7746 }
7747 last_form = Some(identity);
7748 let parent_id = evidence.id().clone();
7749 let mut form = WebAssessmentForm {
7750 document_url: subject.url.clone(),
7751 action,
7752 method,
7753 query_parameter_names: Vec::new(),
7754 control_names: Vec::new(),
7755 evidence_ids: vec![parent_id.clone()],
7756 };
7757 index += 1;
7758 if let Some(child) = discovery.get(index).filter(|child| {
7759 child.predicate()
7760 == &WebDiscoveryEvidencePredicate::FORM_QUERY_PARAMETER_NAMES.into_knowledge()
7761 }) {
7762 validate_discovery_evidence(
7763 child,
7764 receipt,
7765 "form-query-parameter-names",
7766 std::slice::from_ref(&parent_id),
7767 )?;
7768 form.query_parameter_names =
7769 valid_name_list(child.value(), limits.max_query_parameter_names())?;
7770 form.evidence_ids.push(child.id().clone());
7771 index += 1;
7772 }
7773 if let Some(child) = discovery.get(index).filter(|child| {
7774 child.predicate() == &WebDiscoveryEvidencePredicate::FORM_CONTROL_NAMES.into_knowledge()
7775 }) {
7776 validate_discovery_evidence(
7777 child,
7778 receipt,
7779 "form-control-names",
7780 std::slice::from_ref(&parent_id),
7781 )?;
7782 form.control_names = valid_name_list(child.value(), limits.max_controls_per_form())?;
7783 form.evidence_ids.push(child.id().clone());
7784 index += 1;
7785 }
7786 projection.forms.push(form);
7787 }
7788
7789 validate_projection_envelope(&projection, envelope, limits)?;
7790 Ok(Some(projection))
7791}
7792
7793struct BootstrapRequestEnvelope {
7794 body_truncated: bool,
7795}
7796
7797fn validate_bootstrap_request_envelope(
7798 receipt: &DecisionEvidenceReceipt,
7799 subject: &WebAssessmentSubject,
7800) -> Result<BootstrapRequestEnvelope, ()> {
7801 let expected_method = match subject.method {
7802 WebAssessmentMethod::Get => "GET",
7803 WebAssessmentMethod::Head => "HEAD",
7804 };
7805 let mut body_truncated = None;
7806 for (descriptor, kind, source_method) in [
7807 (
7808 HttpEvidencePredicate::REQUEST_METHOD,
7809 EvidenceKind::Http,
7810 "request-method",
7811 ),
7812 (
7813 HttpEvidencePredicate::REQUEST_URL,
7814 EvidenceKind::Http,
7815 "request-url",
7816 ),
7817 (
7818 HttpEvidencePredicate::RESPONSE_STATUS,
7819 EvidenceKind::Http,
7820 "response-status",
7821 ),
7822 (
7823 HttpEvidencePredicate::RESPONSE_FINAL_URL,
7824 EvidenceKind::Http,
7825 "response-final-url",
7826 ),
7827 (
7828 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
7829 EvidenceKind::Content,
7830 "response-body-truncation",
7831 ),
7832 ] {
7833 let predicate = descriptor.into_knowledge();
7834 let mut matches = receipt
7835 .evidence()
7836 .iter()
7837 .filter(|evidence| evidence.predicate() == &predicate);
7838 let evidence = matches.next().ok_or(())?;
7839 if matches.next().is_some()
7840 || evidence.subject() != receipt.case().subject()
7841 || evidence.kind() != &kind
7842 || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
7843 || evidence.source().method() != source_method
7844 || evidence.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
7845 || !matches!(evidence.origin(), EvidenceOrigin::Direct)
7846 {
7847 return Err(());
7848 }
7849 match descriptor {
7850 HttpEvidencePredicate::REQUEST_METHOD => {
7851 if evidence.value() != &EvidenceValue::Text(expected_method.to_owned()) {
7852 return Err(());
7853 }
7854 },
7855 HttpEvidencePredicate::REQUEST_URL => {
7856 if evidence.value() != &EvidenceValue::Text(subject.url.to_string()) {
7857 return Err(());
7858 }
7859 },
7860 HttpEvidencePredicate::RESPONSE_STATUS => {
7861 if !matches!(evidence.value(), EvidenceValue::Unsigned(value) if u16::try_from(*value).is_ok())
7862 {
7863 return Err(());
7864 }
7865 },
7866 HttpEvidencePredicate::RESPONSE_FINAL_URL => {
7867 if evidence.value() != &EvidenceValue::Text(subject.url.to_string()) {
7868 return Err(());
7869 }
7870 },
7871 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED => {
7872 let EvidenceValue::Boolean(value) = evidence.value() else {
7873 return Err(());
7874 };
7875 body_truncated = Some(*value);
7876 },
7877 _ => return Err(()),
7878 }
7879 }
7880 response_body_lineage_from_receipt(receipt)?;
7881 let media_predicate = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
7882 let mut media_matches = receipt
7883 .evidence()
7884 .iter()
7885 .filter(|evidence| evidence.predicate() == &media_predicate);
7886 if let Some(media) = media_matches.next() {
7887 let EvidenceValue::Text(value) = media.value() else {
7888 return Err(());
7889 };
7890 if media_matches.next().is_some()
7891 || media.subject() != receipt.case().subject()
7892 || media.kind() != &EvidenceKind::Http
7893 || media.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
7894 || media.source().method() != "response-media-type"
7895 || media.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
7896 || !matches!(media.origin(), EvidenceOrigin::Direct)
7897 || value.is_empty()
7898 || value != &value.to_ascii_lowercase()
7899 || value.contains(';')
7900 || value.chars().any(char::is_control)
7901 {
7902 return Err(());
7903 }
7904 }
7905 Ok(BootstrapRequestEnvelope {
7906 body_truncated: body_truncated.ok_or(())?,
7907 })
7908}
7909
7910fn valid_sha256(value: &str) -> bool {
7911 value.len() == 64
7912 && value
7913 .bytes()
7914 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
7915}
7916
7917fn projection_base(
7918 receipt: &DecisionEvidenceReceipt,
7919 subject: &WebAssessmentSubject,
7920 require_html_media: bool,
7921) -> Result<ProjectionBase, ()> {
7922 let specs = [
7923 (
7924 HttpEvidencePredicate::REQUEST_METHOD,
7925 EvidenceKind::Http,
7926 "request-method",
7927 ),
7928 (
7929 HttpEvidencePredicate::REQUEST_URL,
7930 EvidenceKind::Http,
7931 "request-url",
7932 ),
7933 (
7934 HttpEvidencePredicate::RESPONSE_STATUS,
7935 EvidenceKind::Http,
7936 "response-status",
7937 ),
7938 (
7939 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
7940 EvidenceKind::Content,
7941 "response-body-truncation",
7942 ),
7943 ];
7944 let mut ids = Vec::with_capacity(specs.len());
7945 let mut status = None;
7946 let mut body_truncated = None;
7947 for (descriptor, kind, method) in specs {
7948 let predicate = descriptor.into_knowledge();
7949 let mut matches = receipt
7950 .evidence()
7951 .iter()
7952 .filter(|evidence| evidence.predicate() == &predicate);
7953 let evidence = matches.next().ok_or(())?;
7954 if matches.next().is_some()
7955 || evidence.subject() != receipt.case().subject()
7956 || evidence.kind() != &kind
7957 || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
7958 || evidence.source().method() != method
7959 || evidence.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
7960 || !matches!(evidence.origin(), EvidenceOrigin::Direct)
7961 {
7962 return Err(());
7963 }
7964 match descriptor {
7965 HttpEvidencePredicate::REQUEST_METHOD => {
7966 if evidence.value() != &EvidenceValue::Text("GET".to_owned())
7967 || subject.method != WebAssessmentMethod::Get
7968 {
7969 return Err(());
7970 }
7971 },
7972 HttpEvidencePredicate::REQUEST_URL => {
7973 if evidence.value() != &EvidenceValue::Text(subject.url.to_string()) {
7974 return Err(());
7975 }
7976 },
7977 HttpEvidencePredicate::RESPONSE_STATUS => {
7978 let EvidenceValue::Unsigned(value) = evidence.value() else {
7979 return Err(());
7980 };
7981 status = Some(u16::try_from(*value).map_err(|_| ())?);
7982 },
7983 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED => {
7984 let EvidenceValue::Boolean(value) = evidence.value() else {
7985 return Err(());
7986 };
7987 body_truncated = Some(*value);
7988 },
7989 _ => return Err(()),
7990 }
7991 ids.push(evidence.id().clone());
7992 }
7993 ids.push(response_body_lineage_from_receipt(receipt)?.id().clone());
7994 let media_predicate = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
7995 let mut media_matches = receipt
7996 .evidence()
7997 .iter()
7998 .filter(|evidence| evidence.predicate() == &media_predicate);
7999 let media = media_matches.next();
8000 if media_matches.next().is_some() || (require_html_media && media.is_none()) {
8001 return Err(());
8002 }
8003 if let Some(evidence) = media {
8004 let EvidenceValue::Text(value) = evidence.value() else {
8005 return Err(());
8006 };
8007 if evidence.subject() != receipt.case().subject()
8008 || evidence.kind() != &EvidenceKind::Http
8009 || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
8010 || evidence.source().method() != "response-media-type"
8011 || evidence.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
8012 || !matches!(evidence.origin(), EvidenceOrigin::Direct)
8013 || (require_html_media && value != "text/html")
8014 {
8015 return Err(());
8016 }
8017 ids.push(evidence.id().clone());
8018 }
8019 ids.sort();
8020 Ok(ProjectionBase {
8021 parent_ids: ids,
8022 status: status.ok_or(())?,
8023 body_truncated: body_truncated.ok_or(())?,
8024 })
8025}
8026
8027fn response_body_lineage_from_receipt(receipt: &DecisionEvidenceReceipt) -> Result<&Evidence, ()> {
8028 let digest_predicate = HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge();
8029 let digests = receipt
8030 .evidence()
8031 .iter()
8032 .filter(|evidence| evidence.predicate() == &digest_predicate)
8033 .collect::<Vec<_>>();
8034 #[cfg(feature = "secret-exposure-review")]
8035 let lineages = receipt
8036 .evidence()
8037 .iter()
8038 .filter(|evidence| {
8039 evidence.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
8040 && evidence.predicate().name() == RESPONSE_BODY_LINEAGE
8041 })
8042 .collect::<Vec<_>>();
8043 #[cfg(not(feature = "secret-exposure-review"))]
8044 let lineages: Vec<&Evidence> = Vec::new();
8045
8046 if let ([digest], []) = (digests.as_slice(), lineages.as_slice()) {
8047 let EvidenceValue::Text(value) = digest.value() else {
8048 return Err(());
8049 };
8050 if digest.subject() != receipt.case().subject()
8051 || digest.kind() != &EvidenceKind::Content
8052 || digest.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
8053 || digest.source().method() != "response-body-sha256"
8054 || digest.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
8055 || !matches!(digest.origin(), EvidenceOrigin::Direct)
8056 || !valid_sha256(value)
8057 {
8058 return Err(());
8059 }
8060 return Ok(*digest);
8061 }
8062 #[cfg(feature = "secret-exposure-review")]
8063 {
8064 let expected_parent_ids = secret_exposure_response_parent_ids(
8065 receipt.evidence(),
8066 receipt.case().subject(),
8067 receipt.case().id(),
8068 )
8069 .ok_or(())?;
8070 match (digests.as_slice(), lineages.as_slice()) {
8071 ([], [lineage])
8072 if is_secret_exposure_response_lineage(
8073 lineage,
8074 receipt.case().subject(),
8075 receipt.case().id(),
8076 &expected_parent_ids,
8077 ) =>
8078 {
8079 Ok(*lineage)
8080 },
8081 _ => Err(()),
8082 }
8083 }
8084 #[cfg(not(feature = "secret-exposure-review"))]
8085 Err(())
8086}
8087
8088fn validate_discovery_evidence(
8089 evidence: &Evidence,
8090 receipt: &DecisionEvidenceReceipt,
8091 method: &str,
8092 expected_parents: &[EvidenceId],
8093) -> Result<(), ()> {
8094 if evidence.subject() != receipt.case().subject()
8095 || evidence.kind() != &EvidenceKind::Content
8096 || evidence.source().component() != HTTP_EVIDENCE_EXECUTOR_ID
8097 || evidence.source().method() != method
8098 || evidence.source().correlation_id() != Some(BOOTSTRAP_CASE_ID)
8099 {
8100 return Err(());
8101 }
8102 let derivation = evidence.origin().derivation().ok_or(())?;
8103 if derivation.algorithm().name() != "web.discovery.html5ever-names-only"
8104 || derivation.algorithm().version() != 1
8105 || derivation.parents() != expected_parents
8106 {
8107 return Err(());
8108 }
8109 Ok(())
8110}
8111
8112fn canonical_evidence_url(
8113 evidence: &Evidence,
8114 policy: &HttpEvidencePolicy,
8115 limits: WebAssessmentLimits,
8116) -> Result<Url, ()> {
8117 let EvidenceValue::Text(value) = evidence.value() else {
8118 return Err(());
8119 };
8120 let url = Url::parse(value).map_err(|_| ())?;
8121 if url.to_string() != *value
8122 || url.query().is_some()
8123 || url.fragment().is_some()
8124 || value.len() > limits.max_canonical_url_bytes()
8125 || policy.require_permitted_target(&url).is_err()
8126 {
8127 return Err(());
8128 }
8129 Ok(url)
8130}
8131
8132#[derive(Clone, Copy)]
8133enum ProjectionFlag {
8134 RouteLimit,
8135 FormLimit,
8136 ControlLimit,
8137 QueryNameLimit,
8138 CanonicalUrlByteLimit,
8139 SubjectLimit,
8140 DepthLimit,
8141 RetainedUrlByteLimit,
8142 OutsideOriginCount,
8143}
8144
8145fn projection_flag(predicate: &termivar_core::KnowledgePredicate) -> Option<(u8, ProjectionFlag)> {
8146 let ordered = [
8147 (
8148 WebDiscoveryEvidencePredicate::DOCUMENT_ROUTE_LIMIT_REACHED,
8149 ProjectionFlag::RouteLimit,
8150 ),
8151 (
8152 WebDiscoveryEvidencePredicate::DOCUMENT_FORM_LIMIT_REACHED,
8153 ProjectionFlag::FormLimit,
8154 ),
8155 (
8156 WebDiscoveryEvidencePredicate::DOCUMENT_CONTROL_LIMIT_REACHED,
8157 ProjectionFlag::ControlLimit,
8158 ),
8159 (
8160 WebDiscoveryEvidencePredicate::DOCUMENT_QUERY_NAME_LIMIT_REACHED,
8161 ProjectionFlag::QueryNameLimit,
8162 ),
8163 (
8164 WebDiscoveryEvidencePredicate::DOCUMENT_URL_BYTE_LIMIT_REACHED,
8165 ProjectionFlag::CanonicalUrlByteLimit,
8166 ),
8167 (
8168 WebDiscoveryEvidencePredicate::ASSESSMENT_SUBJECT_LIMIT_REACHED,
8169 ProjectionFlag::SubjectLimit,
8170 ),
8171 (
8172 WebDiscoveryEvidencePredicate::ASSESSMENT_DEPTH_LIMIT_REACHED,
8173 ProjectionFlag::DepthLimit,
8174 ),
8175 (
8176 WebDiscoveryEvidencePredicate::ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED,
8177 ProjectionFlag::RetainedUrlByteLimit,
8178 ),
8179 (
8180 WebDiscoveryEvidencePredicate::DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT,
8181 ProjectionFlag::OutsideOriginCount,
8182 ),
8183 ];
8184 ordered
8185 .into_iter()
8186 .enumerate()
8187 .find_map(|(rank, (descriptor, flag))| {
8188 (predicate == &descriptor.into_knowledge()).then_some((rank as u8, flag))
8189 })
8190}
8191
8192fn set_projection_flag(
8193 projection: &mut DocumentProjection,
8194 flag: ProjectionFlag,
8195) -> Result<(), ()> {
8196 let target = match flag {
8197 ProjectionFlag::RouteLimit => &mut projection.route_limit_reached,
8198 ProjectionFlag::FormLimit => &mut projection.form_limit_reached,
8199 ProjectionFlag::ControlLimit => &mut projection.control_limit_reached,
8200 ProjectionFlag::QueryNameLimit => &mut projection.query_name_limit_reached,
8201 ProjectionFlag::CanonicalUrlByteLimit => &mut projection.canonical_url_byte_limit_reached,
8202 ProjectionFlag::SubjectLimit => &mut projection.subject_limit_reached,
8203 ProjectionFlag::DepthLimit => &mut projection.depth_limit_reached,
8204 ProjectionFlag::RetainedUrlByteLimit => &mut projection.retained_url_byte_limit_reached,
8205 ProjectionFlag::OutsideOriginCount => return Err(()),
8206 };
8207 if *target {
8208 return Err(());
8209 }
8210 *target = true;
8211 Ok(())
8212}
8213
8214fn require_true(evidence: &Evidence) -> Result<(), ()> {
8215 (evidence.value() == &EvidenceValue::Boolean(true))
8216 .then_some(())
8217 .ok_or(())
8218}
8219
8220fn discovery_method(predicate: &termivar_core::KnowledgePredicate) -> Option<&'static str> {
8221 let methods = [
8222 (
8223 WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED,
8224 "document-projected",
8225 ),
8226 (
8227 WebDiscoveryEvidencePredicate::DOCUMENT_BODY_INCOMPLETE,
8228 "document-body-incomplete",
8229 ),
8230 (
8231 WebDiscoveryEvidencePredicate::DOCUMENT_PARTIAL_REPRESENTATION,
8232 "document-partial-representation",
8233 ),
8234 (
8235 WebDiscoveryEvidencePredicate::DOCUMENT_INVALID_UTF8,
8236 "document-invalid-utf8",
8237 ),
8238 (
8239 WebDiscoveryEvidencePredicate::DOCUMENT_ROUTE_LIMIT_REACHED,
8240 "document-route-limit-reached",
8241 ),
8242 (
8243 WebDiscoveryEvidencePredicate::DOCUMENT_FORM_LIMIT_REACHED,
8244 "document-form-limit-reached",
8245 ),
8246 (
8247 WebDiscoveryEvidencePredicate::DOCUMENT_CONTROL_LIMIT_REACHED,
8248 "document-control-limit-reached",
8249 ),
8250 (
8251 WebDiscoveryEvidencePredicate::DOCUMENT_QUERY_NAME_LIMIT_REACHED,
8252 "document-query-name-limit-reached",
8253 ),
8254 (
8255 WebDiscoveryEvidencePredicate::DOCUMENT_URL_BYTE_LIMIT_REACHED,
8256 "document-url-byte-limit-reached",
8257 ),
8258 (
8259 WebDiscoveryEvidencePredicate::ASSESSMENT_SUBJECT_LIMIT_REACHED,
8260 "assessment-subject-limit-reached",
8261 ),
8262 (
8263 WebDiscoveryEvidencePredicate::ASSESSMENT_DEPTH_LIMIT_REACHED,
8264 "assessment-depth-limit-reached",
8265 ),
8266 (
8267 WebDiscoveryEvidencePredicate::ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED,
8268 "assessment-retained-url-byte-limit-reached",
8269 ),
8270 (
8271 WebDiscoveryEvidencePredicate::DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT,
8272 "document-outside-origin-reference-count",
8273 ),
8274 (WebDiscoveryEvidencePredicate::GET_ROUTE, "get-route"),
8275 (WebDiscoveryEvidencePredicate::HEAD_ROUTE, "head-route"),
8276 (
8277 WebDiscoveryEvidencePredicate::ROUTE_QUERY_PARAMETER_NAMES,
8278 "route-query-parameter-names",
8279 ),
8280 (
8281 WebDiscoveryEvidencePredicate::GET_FORM_ACTION,
8282 "get-form-action",
8283 ),
8284 (
8285 WebDiscoveryEvidencePredicate::POST_FORM_ACTION,
8286 "post-form-action",
8287 ),
8288 (
8289 WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION,
8290 "dialog-form-action",
8291 ),
8292 (
8293 WebDiscoveryEvidencePredicate::FORM_QUERY_PARAMETER_NAMES,
8294 "form-query-parameter-names",
8295 ),
8296 (
8297 WebDiscoveryEvidencePredicate::FORM_CONTROL_NAMES,
8298 "form-control-names",
8299 ),
8300 ];
8301 methods.into_iter().find_map(|(descriptor, method)| {
8302 (predicate == &descriptor.into_knowledge()).then_some(method)
8303 })
8304}
8305
8306fn validate_projection_envelope(
8307 projection: &DocumentProjection,
8308 envelope: &AssessmentEnvelope,
8309 limits: WebAssessmentLimits,
8310) -> Result<(), ()> {
8311 if projection.routes.len() > limits.max_references_per_document()
8312 || projection.forms.len() > limits.max_forms()
8313 || (!envelope.allow_children
8314 && (!projection.routes.is_empty() || !projection.forms.is_empty()))
8315 {
8316 return Err(());
8317 }
8318 let mut replay = envelope.clone();
8319 for route in &projection.routes {
8320 let key = route.url.to_string();
8321 if let Some(existing) = replay.subjects.get_mut(&key) {
8322 if existing.executed {
8323 return Err(());
8324 }
8325 let merged_method = if existing.method == WebAssessmentMethod::Get
8326 || route.method == WebAssessmentMethod::Get
8327 {
8328 WebAssessmentMethod::Get
8329 } else {
8330 WebAssessmentMethod::Head
8331 };
8332 let method_changed = merged_method != existing.method;
8333 if route
8334 .query_parameter_names
8335 .iter()
8336 .any(|name| existing.query_parameter_names.contains(name))
8337 || existing
8338 .query_parameter_names
8339 .len()
8340 .saturating_add(route.query_parameter_names.len())
8341 > limits.max_query_parameter_names()
8342 {
8343 return Err(());
8344 }
8345 if !method_changed && route.query_parameter_names.is_empty() {
8346 return Err(());
8347 }
8348 existing.method = merged_method;
8349 existing
8350 .query_parameter_names
8351 .extend(route.query_parameter_names.iter().cloned());
8352 continue;
8353 }
8354 if replay.remaining_subjects == 0 || !admit_unique_url(&mut replay, &route.url) {
8355 return Err(());
8356 }
8357 replay.remaining_subjects -= 1;
8358 replay.subjects.insert(
8359 key,
8360 SubjectAdmission {
8361 method: route.method,
8362 query_parameter_names: route.query_parameter_names.iter().cloned().collect(),
8363 executed: false,
8364 },
8365 );
8366 }
8367 for form in &projection.forms {
8368 let identity = FormIdentity {
8369 action: form.action.to_string(),
8370 method: form.method,
8371 };
8372 if replay.form_identities.contains(&identity)
8373 || replay.remaining_forms == 0
8374 || !admit_unique_url(&mut replay, &form.action)
8375 {
8376 return Err(());
8377 }
8378 replay.remaining_forms -= 1;
8379 replay.form_identities.insert(identity);
8380 }
8381 Ok(())
8382}
8383
8384fn valid_name_list(value: &EvidenceValue, limit: usize) -> Result<Vec<String>, ()> {
8385 let EvidenceValue::TextList(names) = value else {
8386 return Err(());
8387 };
8388 if names.is_empty() || names.len() > limit {
8389 return Err(());
8390 }
8391 let canonical: BTreeSet<_> = names.iter().cloned().collect();
8392 if canonical.len() != names.len()
8393 || canonical.iter().ne(names)
8394 || names.iter().any(|name| {
8395 name.is_empty()
8396 || name.len() > HARD_MAX_DISCOVERY_NAME_BYTES
8397 || name.chars().any(char::is_control)
8398 })
8399 {
8400 return Err(());
8401 }
8402 Ok(names.clone())
8403}
8404
8405fn is_form_action_predicate(predicate: &termivar_core::KnowledgePredicate) -> bool {
8406 [
8407 WebDiscoveryEvidencePredicate::GET_FORM_ACTION,
8408 WebDiscoveryEvidencePredicate::POST_FORM_ACTION,
8409 WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION,
8410 ]
8411 .into_iter()
8412 .any(|item| predicate == &item.into_knowledge())
8413}
8414
8415fn form_method_for_predicate(
8416 predicate: &termivar_core::KnowledgePredicate,
8417) -> Option<WebAssessmentFormMethod> {
8418 if predicate == &WebDiscoveryEvidencePredicate::GET_FORM_ACTION.into_knowledge() {
8419 Some(WebAssessmentFormMethod::Get)
8420 } else if predicate == &WebDiscoveryEvidencePredicate::POST_FORM_ACTION.into_knowledge() {
8421 Some(WebAssessmentFormMethod::Post)
8422 } else if predicate == &WebDiscoveryEvidencePredicate::DIALOG_FORM_ACTION.into_knowledge() {
8423 Some(WebAssessmentFormMethod::Dialog)
8424 } else {
8425 None
8426 }
8427}
8428
8429#[cfg(test)]
8430mod websocket_parent_completion_tests {
8431 #[cfg(feature = "websocket-review")]
8432 mod enabled {
8433
8434 use std::{future::pending, sync::Arc, time::Duration};
8435
8436 use futures::StreamExt;
8437 use serde_json::json;
8438 use sha2::{Digest, Sha256};
8439 use tokio::{
8440 io::{AsyncReadExt, AsyncWriteExt},
8441 net::{TcpListener, TcpStream},
8442 sync::Notify,
8443 task::JoinHandle,
8444 };
8445 use tokio_tungstenite::accept_async;
8446 use url::Url;
8447
8448 use super::super::*;
8449
8450 struct ParentStopFixture {
8451 application: Url,
8452 endpoint: Url,
8453 message_seen: Arc<Notify>,
8454 task: JoinHandle<()>,
8455 }
8456
8457 impl Drop for ParentStopFixture {
8458 fn drop(&mut self) {
8459 self.task.abort();
8460 }
8461 }
8462
8463 async fn parent_stop_fixture(stall_after_message: bool) -> ParentStopFixture {
8464 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8465 let address = listener.local_addr().unwrap();
8466 let application = Url::parse(&format!("http://{address}/app/")).unwrap();
8467 let endpoint = Url::parse(&format!("ws://{address}/app/socket")).unwrap();
8468 let message_seen = Arc::new(Notify::new());
8469 let fixture_message_seen = Arc::clone(&message_seen);
8470 let task = tokio::spawn(async move {
8471 let (mut root, _) = listener.accept().await.unwrap();
8472 read_http_headers(&mut root).await;
8473 root.write_all(
8474 b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 4\r\nConnection: close\r\n\r\nroot",
8475 )
8476 .await
8477 .unwrap();
8478 root.shutdown().await.unwrap();
8479
8480 let (stream, _) = listener.accept().await.unwrap();
8481 let mut websocket = accept_async(stream).await.unwrap();
8482 if stall_after_message {
8483 let _ = websocket.next().await;
8484 }
8485 fixture_message_seen.notify_one();
8486 pending::<()>().await;
8487 });
8488 ParentStopFixture {
8489 application,
8490 endpoint,
8491 message_seen,
8492 task,
8493 }
8494 }
8495
8496 async fn read_http_headers(stream: &mut TcpStream) {
8497 let mut bytes = Vec::new();
8498 while bytes.len() <= 16 * 1024 {
8499 let mut chunk = [0_u8; 1024];
8500 let read = stream.read(&mut chunk).await.unwrap();
8501 if read == 0 {
8502 break;
8503 }
8504 bytes.extend_from_slice(&chunk[..read]);
8505 if bytes.windows(4).any(|window| window == b"\r\n\r\n") {
8506 return;
8507 }
8508 }
8509 panic!("fixture did not receive one bounded HTTP request");
8510 }
8511
8512 fn websocket_policy(application: &Url, endpoint: &Url) -> WebSocketReviewPolicy {
8513 websocket_policy_with_wall_time(application, endpoint, 2_000)
8514 }
8515
8516 fn websocket_policy_with_wall_time(
8517 application: &Url,
8518 endpoint: &Url,
8519 max_wall_time_ms: u64,
8520 ) -> WebSocketReviewPolicy {
8521 let response = b"response";
8522 let source = serde_json::to_vec(&json!({
8523 "schema": "security.websocket-review-policy/v1",
8524 "target_authorized": true,
8525 "messages_read_only_acknowledged": true,
8526 "message_content_is_non_secret": true,
8527 "endpoint": endpoint.as_str(),
8528 "origin_mode": "application_origin",
8529 "subprotocol": null,
8530 "compression": false,
8531 "reconnect": false,
8532 "messages": [{
8533 "id": "parent-stop",
8534 "text": "request",
8535 "expected_response": {
8536 "sha256": format!("{:x}", Sha256::digest(response)),
8537 "length": response.len(),
8538 }
8539 }],
8540 "limits": {
8541 "max_inbound_message_bytes": 4096,
8542 "max_outbound_message_bytes": 4096,
8543 "max_messages": 1,
8544 "max_control_frames": 4,
8545 "max_wall_time_ms": max_wall_time_ms,
8546 }
8547 }))
8548 .unwrap();
8549 WebSocketReviewPolicy::parse_json(application, &source).unwrap()
8550 }
8551
8552 fn assert_parent_incomplete(
8553 report: &WebAssessmentRunReport,
8554 expected: WebAssessmentIncompleteReason,
8555 ) {
8556 assert!(report.completion().reasons().contains(&expected));
8557 assert!(report.websocket_review_audit().is_some());
8558 }
8559
8560 #[cfg(feature = "reporting")]
8561 fn assert_incomplete_precedes_usage_validation(report: WebAssessmentRunReport) {
8562 let profile = ScanProfileV1::web_review()
8563 .unwrap()
8564 .with_limits(report.limits)
8565 .unwrap();
8566 let result = report.into_assessment_report(profile);
8567 assert!(
8568 matches!(
8569 result.as_ref(),
8570 Err(AssessmentRunReportError::AssessmentIncomplete)
8571 ),
8572 "unexpected conversion result: {:?}",
8573 result.as_ref().err()
8574 );
8575 }
8576
8577 #[test]
8578 fn websocket_parent_refresh_is_inert_when_option_is_absent() {
8579 let application = Url::parse("http://127.0.0.1:8123/app/").unwrap();
8580 let runtime = WebAssessmentRuntime::builder(application).build().unwrap();
8581 runtime.cancellation_token().cancel();
8582 let started_at = tokio::time::Instant::now()
8583 .checked_sub(runtime.limits.max_wall_time() + Duration::from_millis(1))
8584 .unwrap();
8585 let mut reasons = BTreeSet::new();
8586
8587 runtime.refresh_parent_limits_after_websocket(&mut reasons, started_at);
8588
8589 assert!(runtime.websocket_review_audit.is_none());
8590 assert!(reasons.is_empty());
8591 }
8592
8593 #[tokio::test]
8594 async fn websocket_host_cancellation_updates_outer_completion() {
8595 let fixture = parent_stop_fixture(true).await;
8596 let policy = websocket_policy(&fixture.application, &fixture.endpoint);
8597 let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8598 .with_websocket_review(policy)
8599 .build()
8600 .unwrap();
8601 let cancellation = runtime.cancellation_token();
8602 let message_seen = Arc::clone(&fixture.message_seen);
8603 let running = tokio::spawn(async move { runtime.analyze().await });
8604 tokio::time::timeout(Duration::from_secs(2), message_seen.notified())
8605 .await
8606 .unwrap();
8607 cancellation.cancel();
8608 let report = tokio::time::timeout(Duration::from_secs(2), running)
8609 .await
8610 .unwrap()
8611 .unwrap()
8612 .unwrap();
8613
8614 assert_parent_incomplete(&report, WebAssessmentIncompleteReason::HostCancellation);
8615 let audit = report.websocket_review_audit().unwrap();
8616 assert_eq!(
8617 audit.terminal(),
8618 crate::web_runtime::websocket_runtime::WebSocketReviewTerminal::Cancelled
8619 );
8620 assert_eq!(
8621 audit.first_failure(),
8622 Some(WebSocketReviewFailureKind::Cancelled)
8623 );
8624 #[cfg(feature = "reporting")]
8625 assert_incomplete_precedes_usage_validation(report);
8626 }
8627
8628 #[tokio::test]
8629 async fn websocket_parent_deadline_updates_outer_completion() {
8630 let fixture = parent_stop_fixture(true).await;
8631 let policy = websocket_policy(&fixture.application, &fixture.endpoint);
8632 let limits = WebAssessmentLimits::default()
8633 .with_max_wall_time(Duration::from_millis(200))
8634 .unwrap();
8635 let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8636 .limits(limits)
8637 .with_websocket_review(policy)
8638 .build()
8639 .unwrap();
8640 let report = tokio::time::timeout(Duration::from_secs(2), runtime.analyze())
8641 .await
8642 .unwrap()
8643 .unwrap();
8644
8645 assert_parent_incomplete(&report, WebAssessmentIncompleteReason::WallTimeLimit);
8646 let audit = report.websocket_review_audit().unwrap();
8647 assert_eq!(
8648 audit.terminal(),
8649 crate::web_runtime::websocket_runtime::WebSocketReviewTerminal::DeadlineReached
8650 );
8651 assert_eq!(
8652 audit.first_failure(),
8653 Some(WebSocketReviewFailureKind::DeadlineReached)
8654 );
8655 #[cfg(feature = "reporting")]
8656 assert_incomplete_precedes_usage_validation(report);
8657 }
8658
8659 #[tokio::test]
8660 async fn websocket_local_deadline_does_not_change_outer_completion() {
8661 let fixture = parent_stop_fixture(true).await;
8662 let policy =
8663 websocket_policy_with_wall_time(&fixture.application, &fixture.endpoint, 100);
8664 let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8665 .with_websocket_review(policy)
8666 .build()
8667 .unwrap();
8668 let report = tokio::time::timeout(Duration::from_secs(2), runtime.analyze())
8669 .await
8670 .unwrap()
8671 .unwrap();
8672
8673 assert!(matches!(
8674 report.completion(),
8675 WebAssessmentCompletion::Complete
8676 ));
8677 let audit = report.websocket_review_audit().unwrap();
8678 assert_eq!(
8679 audit.terminal(),
8680 crate::web_runtime::websocket_runtime::WebSocketReviewTerminal::DeadlineReached
8681 );
8682 assert_eq!(
8683 audit.first_failure(),
8684 Some(WebSocketReviewFailureKind::DeadlineReached)
8685 );
8686 }
8687
8688 #[tokio::test]
8689 async fn websocket_response_overrun_updates_outer_completion() {
8690 let fixture = parent_stop_fixture(false).await;
8691 let policy = websocket_policy(&fixture.application, &fixture.endpoint);
8692 let limits = WebAssessmentLimits::default()
8693 .with_max_total_response_bytes(64)
8694 .unwrap();
8695 let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8696 .limits(limits)
8697 .with_websocket_review(policy)
8698 .build()
8699 .unwrap();
8700 let report = tokio::time::timeout(Duration::from_secs(2), runtime.analyze())
8701 .await
8702 .unwrap()
8703 .unwrap();
8704
8705 assert_parent_incomplete(&report, WebAssessmentIncompleteReason::ResponseBytesLimit);
8706 let audit = report.websocket_review_audit().unwrap();
8707 assert_eq!(
8708 audit.first_failure(),
8709 Some(WebSocketReviewFailureKind::ParentResponseBudgetReached)
8710 );
8711 assert!(report.usage().response_bytes() >= limits.max_total_response_bytes());
8712 #[cfg(feature = "reporting")]
8713 assert_incomplete_precedes_usage_validation(report);
8714 }
8715
8716 #[tokio::test]
8717 async fn websocket_parent_request_refusal_updates_outer_completion() {
8718 let fixture = parent_stop_fixture(false).await;
8719 let policy = websocket_policy(&fixture.application, &fixture.endpoint);
8720 let limits = WebAssessmentLimits::default()
8721 .with_max_total_requests(1)
8722 .unwrap();
8723 let mut runtime = WebAssessmentRuntime::builder(fixture.application.clone())
8724 .limits(limits)
8725 .with_websocket_review(policy)
8726 .build()
8727 .unwrap();
8728 let report = tokio::time::timeout(Duration::from_secs(2), runtime.analyze())
8729 .await
8730 .unwrap()
8731 .unwrap();
8732
8733 assert_parent_incomplete(&report, WebAssessmentIncompleteReason::TotalRequestLimit);
8734 let audit = report.websocket_review_audit().unwrap();
8735 assert_eq!(audit.request_attempt_count(), 1);
8736 assert_eq!(audit.request_admitted_count(), 0);
8737 assert_eq!(
8738 audit.first_failure(),
8739 Some(WebSocketReviewFailureKind::ParentAuthorityUnavailable)
8740 );
8741 assert_eq!(report.usage().total_requests(), 1);
8742 #[cfg(feature = "reporting")]
8743 assert_incomplete_precedes_usage_validation(report);
8744 }
8745 }
8746}
8747
8748#[cfg(test)]
8749#[path = "web_assessment_tests.rs"]
8750mod tests;