Skip to main content

termivar_scanner/web_runtime/
ssrf_oast_runtime.rs

1//! Parent-native query-only SSRF review backed by the narrowing OAST provider.
2//!
3//! The binding owns no independent authority. Target requests use the parent
4//! exact-origin broker and provider work is minted exactly once from the same
5//! parent budget, cancellation token, and deadline.
6
7use std::{
8    fmt,
9    sync::{Arc, Mutex},
10    time::Duration,
11};
12
13use async_trait::async_trait;
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16use termivar_core::{
17    ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
18    HttpEvidencePredicate, KnowledgePredicate,
19};
20use termivar_oast::{CallbackId, PublicOrigin};
21
22use super::{
23    assessment_defense::{project_assessment_defense_signal, AssessmentDefenseProjectionContext},
24    assessment_item::{
25        AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
26        AssessmentProjectionContext, StableAssessmentSubjectId,
27    },
28    SharedWebRuntimeAuthority,
29};
30use crate::{
31    http_evidence::HttpRequestBrokerError,
32    native_oast_provider::{
33        NativeOastProviderAdapter, NativeOastProviderConfiguration, NativeOastProviderErrorKind,
34        NativeOastProviderLimits, NativeOastProviderOperation,
35    },
36    oast::{OastCorrelationId, OastEventDisposition},
37    ssrf_oast_review::{
38        evaluate_ssrf_oast_review, SsrfOastAdminToken, SsrfOastCandidateSelection,
39        SsrfOastCandidateSource, SsrfOastCorrelationBinding, SsrfOastCorrelationEntropy,
40        SsrfOastCorrelationMaterial, SsrfOastMutationPlan, SsrfOastObservedEvent,
41        SsrfOastQueryCandidate, SsrfOastReviewFacts, SsrfOastReviewOutcome, SsrfOastReviewPolicy,
42        SsrfOastTargetLeg, SsrfOastTerminalState, MAX_SSRF_OAST_PROVIDER_REQUESTS,
43        SSRF_OAST_ACTIVE_VERIFICATIONS, SSRF_OAST_TARGET_REQUESTS,
44    },
45    DecisionActionExecutor, DecisionActionOrigin, DecisionExecutionFailureKind,
46    DecisionExecutionRequest, DecisionExecutionStage, DecisionExecutorError,
47    DecisionExecutorRegistry, HttpProbe, HttpProbeMethod, KnowledgeBase, RuntimeLimitExceeded,
48    TransportDispatchAudit, TransportDispatchOutcome, VerificationCase,
49};
50
51pub const SSRF_OAST_REVIEW_ACTION_ID: &str = "web.review.ssrf.oast-query@1";
52pub const SSRF_OAST_REVIEW_CAPABILITY_ID: &str = "ssrf.oast-repeated-outbound-interaction@1";
53pub const MAX_SSRF_OAST_REVIEW_RESOURCES: usize = 1;
54pub const MAX_SSRF_OAST_REVIEW_PARAMETERS: usize = 1;
55pub const MAX_SSRF_OAST_REVIEW_REQUESTS: usize = SSRF_OAST_TARGET_REQUESTS;
56pub const MAX_SSRF_OAST_REVIEW_PROVIDER_REQUESTS: usize = MAX_SSRF_OAST_PROVIDER_REQUESTS;
57pub const MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS: usize = SSRF_OAST_ACTIVE_VERIFICATIONS;
58pub(super) const SSRF_OAST_REVIEW_ACTION_CYCLE_ALLOWANCE: u32 = 1;
59
60const SSRF_OAST_EXECUTOR_ID: &str = "http.ssrf-oast-review";
61const SSRF_OAST_EVIDENCE_NAMESPACE: &str = "web.ssrf-oast-review.transport";
62const CONTROL_COMPLETE: &str = "control-complete";
63const PROVIDER_REGISTERED: &str = "provider-registered";
64const ALLOCATIONS_COMPLETE: &str = "allocations-complete";
65const PREFLIGHT_CLEAN: &str = "preflight-clean";
66const CANDIDATE_DISPATCHED: &str = "candidate-dispatched";
67const CANDIDATE_CALLBACK_CORRELATED: &str = "candidate-callback-correlated";
68const REPLAY_DISPATCHED: &str = "replay-dispatched";
69const REPLAY_CALLBACK_CORRELATED: &str = "replay-callback-correlated";
70const REPEATED_CALLBACKS_CORRELATED: &str = "repeated-callbacks-correlated";
71const CLEANUP_VERIFIED: &str = "cleanup-verified";
72const TARGET_ACCOUNTING_COMPLETE: &str = "target-accounting-complete";
73const PROVIDER_ACCOUNTING_COMPLETE: &str = "provider-accounting-complete";
74const MAX_POST_DISPATCH_POLLS: u16 = 7;
75const PROVIDER_REQUEST_BYTES: u64 = 64 * 1024;
76const PROVIDER_RESPONSE_BYTES: u64 = 1024 * 1024;
77
78const SSRF_OAST_CAPABILITY: AssessmentCapabilityDescriptor =
79    AssessmentCapabilityDescriptor::differential_review(
80        SSRF_OAST_REVIEW_CAPABILITY_ID,
81        "Repeated out-of-band interaction observed",
82        "server-side request behavior",
83        "Two independently allocated callback targets received exact correlated HTTP interactions after candidate and replay query mutations; exploitability and business impact require review.",
84        None,
85        1_000_000,
86        Some("CWE-918"),
87        "web.remediation.outbound-request-policy@1",
88        "Constrain server-side outbound destinations and manually validate the exact request path before treating this observation as a vulnerability.",
89    );
90
91/// Closed, raw-free runtime conclusion.
92#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
93#[serde(rename_all = "snake_case")]
94pub enum SsrfOastRuntimeOutcome {
95    NotEligible,
96    ControlIncomplete,
97    RegistrationIncomplete,
98    AllocationIncomplete,
99    PreflightContaminated,
100    TargetNotDispatched,
101    NoCallback,
102    CandidateOnly,
103    ReplayOnly,
104    WrongCallback,
105    EventIdentityConflict,
106    CorrelationMismatch,
107    DuplicateOnly,
108    CleanupIncomplete,
109    DefensiveInterference,
110    RateLimited,
111    ProviderAuthenticationFailed,
112    MalformedProviderResponse,
113    PollExhausted,
114    Expired,
115    Cancelled,
116    BudgetExhausted,
117    Truncated,
118    Incomplete,
119    RepeatedCallbacksObserved,
120}
121
122impl From<SsrfOastReviewOutcome> for SsrfOastRuntimeOutcome {
123    fn from(value: SsrfOastReviewOutcome) -> Self {
124        match value {
125            SsrfOastReviewOutcome::NotEligible => Self::NotEligible,
126            SsrfOastReviewOutcome::ControlIncomplete => Self::ControlIncomplete,
127            SsrfOastReviewOutcome::RegistrationIncomplete => Self::RegistrationIncomplete,
128            SsrfOastReviewOutcome::AllocationIncomplete => Self::AllocationIncomplete,
129            SsrfOastReviewOutcome::PreflightContaminated => Self::PreflightContaminated,
130            SsrfOastReviewOutcome::TargetNotDispatched => Self::TargetNotDispatched,
131            SsrfOastReviewOutcome::NoCallback => Self::NoCallback,
132            SsrfOastReviewOutcome::CandidateOnly => Self::CandidateOnly,
133            SsrfOastReviewOutcome::ReplayOnly => Self::ReplayOnly,
134            SsrfOastReviewOutcome::WrongCallback => Self::WrongCallback,
135            SsrfOastReviewOutcome::EventIdentityConflict => Self::EventIdentityConflict,
136            SsrfOastReviewOutcome::CorrelationMismatch => Self::CorrelationMismatch,
137            SsrfOastReviewOutcome::DuplicateOnly => Self::DuplicateOnly,
138            SsrfOastReviewOutcome::CleanupIncomplete => Self::CleanupIncomplete,
139            SsrfOastReviewOutcome::DefensiveInterference => Self::DefensiveInterference,
140            SsrfOastReviewOutcome::RateLimited => Self::RateLimited,
141            SsrfOastReviewOutcome::ProviderAuthenticationFailed => {
142                Self::ProviderAuthenticationFailed
143            },
144            SsrfOastReviewOutcome::MalformedProviderResponse => Self::MalformedProviderResponse,
145            SsrfOastReviewOutcome::PollExhausted => Self::PollExhausted,
146            SsrfOastReviewOutcome::Expired => Self::Expired,
147            SsrfOastReviewOutcome::Cancelled => Self::Cancelled,
148            SsrfOastReviewOutcome::BudgetExhausted => Self::BudgetExhausted,
149            SsrfOastReviewOutcome::Truncated => Self::Truncated,
150            SsrfOastReviewOutcome::Incomplete => Self::Incomplete,
151            SsrfOastReviewOutcome::RepeatedCallbacksObserved => Self::RepeatedCallbacksObserved,
152        }
153    }
154}
155
156/// Bounded provider diagnostics, separate from review or cleanup completion.
157///
158/// Access rejection describes the response class, not a guessed credential
159/// cause. No variant contains protocol data or authorizes another request.
160#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
161#[non_exhaustive]
162#[serde(rename_all = "snake_case")]
163pub enum SsrfOastProviderFailure {
164    CredentialInputInvalid,
165    AccessRejected,
166    Throttled,
167    NotFound,
168    Gone,
169    RedirectRefused,
170    ServerFailure,
171    TransportFailure,
172    RequestConstruction,
173    ClientInitialization,
174    UnexpectedStatus,
175    MalformedResponse,
176    Cancelled,
177    Timeout,
178    Expired,
179    BudgetRejected,
180    PollLimit,
181    LocalRejected,
182    CleanupUnverified,
183    InternalInvariant,
184}
185
186impl From<NativeOastProviderErrorKind> for SsrfOastProviderFailure {
187    fn from(kind: NativeOastProviderErrorKind) -> Self {
188        match kind {
189            NativeOastProviderErrorKind::ProviderRejected => Self::CredentialInputInvalid,
190            NativeOastProviderErrorKind::ProviderAccessRejected => Self::AccessRejected,
191            NativeOastProviderErrorKind::ProviderThrottled => Self::Throttled,
192            NativeOastProviderErrorKind::ProviderNotFound => Self::NotFound,
193            NativeOastProviderErrorKind::ProviderGone => Self::Gone,
194            NativeOastProviderErrorKind::ProviderRedirectRefused => Self::RedirectRefused,
195            NativeOastProviderErrorKind::ProviderServerFailure => Self::ServerFailure,
196            NativeOastProviderErrorKind::ProviderTransportFailure => Self::TransportFailure,
197            NativeOastProviderErrorKind::ProviderRequestConstruction => Self::RequestConstruction,
198            NativeOastProviderErrorKind::ProviderClientInitialization => Self::ClientInitialization,
199            NativeOastProviderErrorKind::ProviderUnexpectedStatus => Self::UnexpectedStatus,
200            NativeOastProviderErrorKind::ProviderResponseInvalid
201            | NativeOastProviderErrorKind::ProviderSessionMismatch
202            | NativeOastProviderErrorKind::ProviderCallbackMismatch
203            | NativeOastProviderErrorKind::ProviderPageIncomplete
204            | NativeOastProviderErrorKind::CorrelationRejected => Self::MalformedResponse,
205            NativeOastProviderErrorKind::Cancelled => Self::Cancelled,
206            NativeOastProviderErrorKind::DeadlineExceeded => Self::Timeout,
207            NativeOastProviderErrorKind::ProviderExpired => Self::Expired,
208            NativeOastProviderErrorKind::RuntimeBudget(_)
209            | NativeOastProviderErrorKind::ParentBudgetTooSmall
210            | NativeOastProviderErrorKind::RequestLimit
211            | NativeOastProviderErrorKind::RequestByteLimit
212            | NativeOastProviderErrorKind::ResponseByteLimit => Self::BudgetRejected,
213            NativeOastProviderErrorKind::PollLimit => Self::PollLimit,
214            NativeOastProviderErrorKind::CleanupUnverified => Self::CleanupUnverified,
215            NativeOastProviderErrorKind::InternalInvariant => Self::InternalInvariant,
216            NativeOastProviderErrorKind::InvalidLimits
217            | NativeOastProviderErrorKind::InvalidProviderOrigin
218            | NativeOastProviderErrorKind::ProviderTargetOriginOverlap
219            | NativeOastProviderErrorKind::AuthorityAlreadyMinted
220            | NativeOastProviderErrorKind::OperationNotPermitted
221            | NativeOastProviderErrorKind::InvalidLifecycle
222            | NativeOastProviderErrorKind::RegistrationLimit
223            | NativeOastProviderErrorKind::CallbackLimit => Self::LocalRejected,
224        }
225    }
226}
227
228/// Redaction-safe audit retained by the one composed assessment report.
229#[derive(Clone, Eq, PartialEq, Serialize)]
230pub struct WebAssessmentSsrfOastAudit {
231    outcome: SsrfOastRuntimeOutcome,
232    policy_id: String,
233    candidate_source: Option<&'static str>,
234    target_request_count: u8,
235    provider_request_count: u8,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    provider_failure: Option<SsrfOastProviderFailure>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    cleanup_failure: Option<SsrfOastProviderFailure>,
240    active_verification_count: u8,
241    preflight_clean: bool,
242    candidate_callback_observed: bool,
243    replay_callback_observed: bool,
244    cleanup_verified: bool,
245    item_projected: bool,
246}
247
248impl WebAssessmentSsrfOastAudit {
249    pub const fn outcome(&self) -> SsrfOastRuntimeOutcome {
250        self.outcome
251    }
252    pub fn policy_id(&self) -> &str {
253        &self.policy_id
254    }
255    pub const fn candidate_source(&self) -> Option<&'static str> {
256        self.candidate_source
257    }
258    pub const fn target_request_count(&self) -> u8 {
259        self.target_request_count
260    }
261    /// Cumulative operations admitted and charged by the parent provider permit.
262    /// Logical failure receipts and proof of server receipt are not this count.
263    pub const fn provider_request_count(&self) -> u8 {
264        self.provider_request_count
265    }
266    /// First provider failure, excluding the separate cleanup operation.
267    pub const fn provider_failure(&self) -> Option<SsrfOastProviderFailure> {
268        self.provider_failure
269    }
270    /// Failure of the explicit cleanup attempt, without replacing the cause.
271    pub const fn cleanup_failure(&self) -> Option<SsrfOastProviderFailure> {
272        self.cleanup_failure
273    }
274    pub const fn active_verification_count(&self) -> u8 {
275        self.active_verification_count
276    }
277    pub const fn preflight_clean(&self) -> bool {
278        self.preflight_clean
279    }
280    pub const fn candidate_callback_observed(&self) -> bool {
281        self.candidate_callback_observed
282    }
283    pub const fn replay_callback_observed(&self) -> bool {
284        self.replay_callback_observed
285    }
286    pub const fn cleanup_verified(&self) -> bool {
287        self.cleanup_verified
288    }
289    pub const fn item_projected(&self) -> bool {
290        self.item_projected
291    }
292}
293
294impl fmt::Debug for WebAssessmentSsrfOastAudit {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        f.debug_struct("WebAssessmentSsrfOastAudit")
297            .field("outcome", &self.outcome)
298            .field("policy_id", &self.policy_id)
299            .field("candidate_source", &self.candidate_source)
300            .field("target_request_count", &self.target_request_count)
301            .field("provider_request_count", &self.provider_request_count)
302            .field("provider_failure", &self.provider_failure)
303            .field("cleanup_failure", &self.cleanup_failure)
304            .field("active_verification_count", &self.active_verification_count)
305            .field("preflight_clean", &self.preflight_clean)
306            .field(
307                "candidate_callback_observed",
308                &self.candidate_callback_observed,
309            )
310            .field("replay_callback_observed", &self.replay_callback_observed)
311            .field("cleanup_verified", &self.cleanup_verified)
312            .field("item_projected", &self.item_projected)
313            .finish()
314    }
315}
316
317pub(super) struct SsrfOastReviewConfig {
318    policy: SsrfOastReviewPolicy,
319    administrator: SsrfOastAdminToken,
320    selection: StableSsrfOastSelectionSlot,
321}
322
323impl SsrfOastReviewConfig {
324    pub(super) fn new(
325        policy: SsrfOastReviewPolicy,
326        administrator: SsrfOastAdminToken,
327        selection: SsrfOastCandidateSelection,
328    ) -> Self {
329        Self {
330            policy,
331            administrator,
332            selection: StableSsrfOastSelectionSlot::new(selection),
333        }
334    }
335}
336
337impl fmt::Debug for SsrfOastReviewConfig {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        f.write_str("SsrfOastReviewConfig(<redacted>)")
340    }
341}
342
343#[derive(Clone, Default)]
344pub(super) struct StableSsrfOastSelectionSlot {
345    candidate: Arc<Mutex<Option<SsrfOastQueryCandidate>>>,
346}
347
348impl StableSsrfOastSelectionSlot {
349    fn new(selection: SsrfOastCandidateSelection) -> Self {
350        let candidate = match selection {
351            SsrfOastCandidateSelection::Selected(candidate) => Some(*candidate),
352            SsrfOastCandidateSelection::NotEligible => None,
353        };
354        Self {
355            candidate: Arc::new(Mutex::new(candidate)),
356        }
357    }
358
359    #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
360    pub(super) fn commit_openapi(
361        &self,
362        selection: SsrfOastCandidateSelection,
363    ) -> Result<(), SsrfOastRuntimeInvariantError> {
364        let SsrfOastCandidateSelection::Selected(candidate) = selection else {
365            return Ok(());
366        };
367        let mut slot = self
368            .candidate
369            .lock()
370            .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?;
371        if slot.is_none() {
372            *slot = Some(*candidate);
373        }
374        Ok(())
375    }
376
377    fn take(&self) -> Result<Option<SsrfOastQueryCandidate>, SsrfOastRuntimeInvariantError> {
378        self.candidate
379            .lock()
380            .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)
381            .map(|mut value| value.take())
382    }
383}
384
385impl fmt::Debug for StableSsrfOastSelectionSlot {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        f.write_str("StableSsrfOastSelectionSlot(<redacted-single-assignment>)")
388    }
389}
390
391pub(super) struct SsrfOastRuntimeBinding {
392    executor: Arc<SsrfOastDecisionExecutor>,
393    subject: EntityId,
394}
395
396impl SsrfOastRuntimeBinding {
397    pub(super) fn new(
398        config: SsrfOastReviewConfig,
399        authority: SharedWebRuntimeAuthority,
400        subject: EntityId,
401    ) -> Self {
402        let executor = Arc::new(SsrfOastDecisionExecutor {
403            authority,
404            policy: config.policy,
405            administrator: Mutex::new(Some(config.administrator)),
406            selection: config.selection,
407            subject: subject.clone(),
408            state: Mutex::new(SsrfOastExecutionState::default()),
409        });
410        Self { executor, subject }
411    }
412
413    #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
414    pub(super) fn selection_slot(&self) -> StableSsrfOastSelectionSlot {
415        self.executor.selection.clone()
416    }
417
418    pub(super) fn install_into_parent_registry(
419        &self,
420        registry: &mut DecisionExecutorRegistry,
421    ) -> Result<(), SsrfOastRuntimeInvariantError> {
422        let before = registry.len();
423        let executor: Arc<dyn DecisionActionExecutor> = self.executor.clone();
424        registry
425            .register(executor)
426            .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?;
427        for stage in [
428            DecisionExecutionStage::Passive,
429            DecisionExecutionStage::Active,
430        ] {
431            registry
432                .route_action(stage, SSRF_OAST_REVIEW_ACTION_ID, SSRF_OAST_EXECUTOR_ID)
433                .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?;
434        }
435        if registry.len() != before + 1 {
436            return Err(SsrfOastRuntimeInvariantError::Catalog);
437        }
438        Ok(())
439    }
440
441    pub(super) fn finalize(
442        self,
443        knowledge: &KnowledgeBase,
444        transport: &TransportDispatchAudit,
445        forced_terminal: Option<SsrfOastTerminalState>,
446        forced_limit: Option<RuntimeLimitExceeded>,
447    ) -> Result<SsrfOastRuntimeResult, SsrfOastRuntimeInvariantError> {
448        if transport.omitted_receipt_count() != 0 {
449            return Err(SsrfOastRuntimeInvariantError::Catalog);
450        }
451        let receipts = transport
452            .receipts()
453            .iter()
454            .filter(|receipt| receipt.action_id() == SSRF_OAST_REVIEW_ACTION_ID)
455            .collect::<Vec<_>>();
456        if !target_receipt_prefix_is_valid(&receipts) {
457            return Err(SsrfOastRuntimeInvariantError::Catalog);
458        }
459        let mut state = self.executor.take_state()?;
460        if let Some(terminal) = forced_terminal {
461            if let Some(facts) = state.facts.as_mut() {
462                facts.terminal = Some(terminal);
463            }
464        }
465        let target_count = u8::try_from(receipts.len()).unwrap_or(u8::MAX);
466        let target_accounting_complete = receipts.len() == SSRF_OAST_TARGET_REQUESTS
467            && receipts
468                .iter()
469                .all(|receipt| target_dispatch_outcome_is_accounted(receipt.outcome()));
470        if let Some(facts) = state.facts.as_mut() {
471            facts.target_accounting_complete = target_accounting_complete;
472        }
473        let outcome = match state.facts.as_ref() {
474            Some(facts) => evaluate_ssrf_oast_review(facts)
475                .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?,
476            None => {
477                if state.terminal.is_some() {
478                    SsrfOastReviewOutcome::Incomplete
479                } else {
480                    SsrfOastReviewOutcome::NotEligible
481                }
482            },
483        };
484        let projected = outcome.projects_item();
485        if projected
486            && (state.evidence_ids.is_empty()
487                || state
488                    .evidence_ids
489                    .iter()
490                    .any(|id| knowledge.evidence(id).is_none()))
491        {
492            return Err(SsrfOastRuntimeInvariantError::Catalog);
493        }
494        let audit = audit(
495            &self.executor.policy,
496            &state,
497            outcome.into(),
498            target_count,
499            projected,
500        );
501        let committed = CommittedSsrfOastReview {
502            subject: self.subject,
503            parameter_identity: state.parameter_identity,
504            outcome: outcome.into(),
505            evidence_ids: state.evidence_ids,
506            audit,
507        };
508        if matches!(
509            outcome,
510            SsrfOastReviewOutcome::Cancelled
511                | SsrfOastReviewOutcome::BudgetExhausted
512                | SsrfOastReviewOutcome::Truncated
513                | SsrfOastReviewOutcome::Incomplete
514        ) {
515            return Ok(SsrfOastRuntimeResult::Stopped {
516                audit: committed.audit,
517                runtime_limit: forced_limit.or(state.runtime_limit),
518            });
519        }
520        Ok(SsrfOastRuntimeResult::Complete(committed))
521    }
522}
523
524fn target_receipt_prefix_is_valid(receipts: &[&crate::TransportDispatchReceipt]) -> bool {
525    receipts.len() <= SSRF_OAST_TARGET_REQUESTS
526        && receipts.iter().enumerate().all(|(index, receipt)| {
527            let expected_stage = if index == 1 {
528                DecisionExecutionStage::Active
529            } else {
530                DecisionExecutionStage::Passive
531            };
532            let expected_origin = (expected_stage == DecisionExecutionStage::Passive)
533                .then_some(DecisionActionOrigin::Planned);
534            receipt.stage() == expected_stage
535                && receipt.origin() == expected_origin
536                && receipt.request_body_bytes() == 0
537        })
538}
539
540#[derive(Debug, thiserror::Error)]
541pub(super) enum SsrfOastRuntimeInvariantError {
542    #[error("SSRF OAST runtime invariant failed")]
543    Catalog,
544}
545
546pub(super) enum SsrfOastRuntimeResult {
547    Complete(CommittedSsrfOastReview),
548    Stopped {
549        audit: WebAssessmentSsrfOastAudit,
550        runtime_limit: Option<RuntimeLimitExceeded>,
551    },
552}
553
554pub(super) struct CommittedSsrfOastReview {
555    subject: EntityId,
556    parameter_identity: Option<String>,
557    outcome: SsrfOastRuntimeOutcome,
558    evidence_ids: Vec<EvidenceId>,
559    audit: WebAssessmentSsrfOastAudit,
560}
561
562impl CommittedSsrfOastReview {
563    pub(super) const fn audit(&self) -> &WebAssessmentSsrfOastAudit {
564        &self.audit
565    }
566}
567
568#[derive(Default)]
569struct SsrfOastExecutionState {
570    prepared: Option<PreparedSsrfOast>,
571    facts: Option<SsrfOastReviewFacts>,
572    terminal: Option<SsrfOastTerminalState>,
573    runtime_limit: Option<RuntimeLimitExceeded>,
574    provider_request_count: u8,
575    provider_failure: Option<SsrfOastProviderFailure>,
576    cleanup_failure: Option<SsrfOastProviderFailure>,
577    source: Option<SsrfOastCandidateSource>,
578    parameter_identity: Option<String>,
579    evidence_ids: Vec<EvidenceId>,
580    cleanup_verified: bool,
581}
582
583impl SsrfOastExecutionState {
584    fn retain_provider_audit(&mut self, provider: &NativeOastProviderAdapter) {
585        self.provider_request_count =
586            u8::try_from(provider.admitted_provider_requests()).unwrap_or(u8::MAX);
587        self.retain_provider_failures(provider.provider_failure(), provider.cleanup_failure());
588    }
589
590    fn retain_provider_failures(
591        &mut self,
592        provider_failure: Option<NativeOastProviderErrorKind>,
593        cleanup_failure: Option<NativeOastProviderErrorKind>,
594    ) {
595        self.provider_failure = self.provider_failure.or(provider_failure.map(Into::into));
596        self.cleanup_failure = self.cleanup_failure.or(cleanup_failure.map(Into::into));
597    }
598}
599
600struct PreparedSsrfOast {
601    provider: NativeOastProviderAdapter,
602    plan: SsrfOastMutationPlan,
603    candidate_callback: CallbackId,
604    replay_callback: CallbackId,
605    candidate_correlation: OastCorrelationId,
606    replay_correlation: OastCorrelationId,
607    facts: SsrfOastReviewFacts,
608}
609
610struct ProviderTerminalContext {
611    source: SsrfOastCandidateSource,
612    parameter_identity: String,
613    cleanup_verified: bool,
614}
615
616struct SsrfOastDecisionExecutor {
617    authority: SharedWebRuntimeAuthority,
618    policy: SsrfOastReviewPolicy,
619    administrator: Mutex<Option<SsrfOastAdminToken>>,
620    selection: StableSsrfOastSelectionSlot,
621    subject: EntityId,
622    state: Mutex<SsrfOastExecutionState>,
623}
624
625impl SsrfOastDecisionExecutor {
626    fn take_state(&self) -> Result<SsrfOastExecutionState, SsrfOastRuntimeInvariantError> {
627        Ok(std::mem::take(
628            &mut *self
629                .state
630                .lock()
631                .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?,
632        ))
633    }
634
635    fn stop(
636        &self,
637        terminal: SsrfOastTerminalState,
638        limit: Option<RuntimeLimitExceeded>,
639    ) -> Result<(), DecisionExecutorError> {
640        let mut state = self
641            .state
642            .lock()
643            .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
644        if state.terminal.replace(terminal).is_some() {
645            return Err(DecisionExecutorError::new(
646                "SSRF OAST terminal state is duplicated",
647            ));
648        }
649        state.runtime_limit = limit;
650        Ok(())
651    }
652
653    async fn execute_passive(
654        &self,
655        request: &DecisionExecutionRequest,
656    ) -> Result<Vec<Evidence>, DecisionExecutorError> {
657        let Some(candidate) = self
658            .selection
659            .take()
660            .map_err(|_| DecisionExecutorError::new("SSRF OAST selection failed"))?
661        else {
662            self.stop(SsrfOastTerminalState::Incomplete, None)?;
663            return phase_evidence(request, 0, SsrfOastLifecycleEvidence::terminal(), None);
664        };
665        self.authority
666            .authorize_target(candidate.execution_resource())
667            .map_err(|_| DecisionExecutorError::new("SSRF OAST target authority failed"))?;
668        let source = candidate.source();
669        let parameter_identity = candidate.parameter_id().to_owned();
670        let control_seed = random_entropy()?;
671        let correlation = SsrfOastCorrelationMaterial::derive(
672            &self.policy,
673            &candidate,
674            SsrfOastCorrelationBinding::new(
675                self.subject.as_str(),
676                SSRF_OAST_REVIEW_ACTION_ID,
677                request.case().id(),
678            ),
679            SsrfOastCorrelationEntropy::new(
680                random_entropy()?,
681                random_entropy()?,
682                random_entropy()?,
683            ),
684        )
685        .map_err(|_| DecisionExecutorError::new("SSRF OAST correlation derivation failed"))?;
686        let (epoch, candidate_token, replay_token) = correlation.into_parts();
687        let control_url = candidate
688            .control_execution_url(control_seed)
689            .map_err(|_| DecisionExecutorError::new("SSRF OAST control construction failed"))?;
690        let control_response = match collect_target(
691            &self.authority,
692            request,
693            DecisionExecutionStage::Passive,
694            Some(DecisionActionOrigin::Planned),
695            &control_url,
696        )
697        .await
698        {
699            Ok(response) => response,
700            Err(TargetFailure::Limit(limit)) => {
701                self.stop(SsrfOastTerminalState::BudgetExhausted, Some(limit))?;
702                return phase_evidence(request, 0, SsrfOastLifecycleEvidence::terminal(), None);
703            },
704            Err(TargetFailure::TimeoutAfterDispatch | TargetFailure::Transport) => {
705                self.stop(SsrfOastTerminalState::Incomplete, None)?;
706                return phase_evidence(request, 0, SsrfOastLifecycleEvidence::terminal(), None);
707            },
708        };
709        let control_bytes = u64::try_from(control_response.body().len()).unwrap_or(u64::MAX);
710        let signal = control_response.ssrf_oast_defense_signal();
711        let terminal = classify_target_response(&control_response, &control_url, &signal);
712        if let Some(terminal) = terminal {
713            self.stop(terminal, None)?;
714            return phase_evidence(
715                request,
716                control_bytes,
717                SsrfOastLifecycleEvidence::terminal(),
718                Some((&signal, control_response.status())),
719            );
720        }
721
722        let administrator = self
723            .administrator
724            .lock()
725            .map_err(|_| DecisionExecutorError::new("SSRF OAST credential is unavailable"))?
726            .take()
727            .ok_or_else(|| {
728                DecisionExecutorError::new("SSRF OAST credential was already consumed")
729            })?;
730        let limits = NativeOastProviderLimits::new(
731            1,
732            2,
733            u16::try_from(MAX_SSRF_OAST_PROVIDER_REQUESTS).unwrap_or(u16::MAX),
734            8,
735            PROVIDER_REQUEST_BYTES,
736            PROVIDER_RESPONSE_BYTES,
737            self.policy.lifetime_ms(),
738        )
739        .map_err(|_| DecisionExecutorError::new("SSRF OAST provider limits failed"))?;
740        let configuration = provider_configuration(
741            self.policy.provider_origin(),
742            &assessment_identity(&self.subject),
743            epoch.into_bytes(),
744            administrator.into_bytes(),
745            limits,
746        )
747        .map_err(|_| DecisionExecutorError::new("SSRF OAST provider configuration failed"))?;
748        let candidate_case = callback_case(request, "candidate")?;
749        let replay_case = callback_case(request, "replay")?;
750        let mut provider = match self.authority.mint_native_oast_provider(configuration) {
751            Ok(provider) => provider,
752            Err(error) => {
753                self.stop(provider_terminal(error.kind()), None)?;
754                self.state
755                    .lock()
756                    .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?
757                    .retain_provider_failures(Some(error.kind()), None);
758                return phase_evidence(
759                    request,
760                    control_bytes,
761                    SsrfOastLifecycleEvidence {
762                        control_complete: true,
763                        phase_terminal: true,
764                        ..SsrfOastLifecycleEvidence::default()
765                    },
766                    Some((&signal, control_response.status())),
767                );
768            },
769        };
770        if let Err(error) = provider.register().await {
771            self.store_provider_terminal(
772                provider,
773                None,
774                provider_terminal(error.kind()),
775                None,
776                ProviderTerminalContext {
777                    source,
778                    parameter_identity,
779                    cleanup_verified: false,
780                },
781            )?;
782            return phase_evidence(
783                request,
784                control_bytes,
785                SsrfOastLifecycleEvidence {
786                    control_complete: true,
787                    phase_terminal: true,
788                    ..SsrfOastLifecycleEvidence::default()
789                },
790                Some((&signal, control_response.status())),
791            );
792        }
793        let candidate_allocation = match provider
794            .allocate_callback(candidate_case, candidate_token)
795            .await
796        {
797            Ok(allocation) => allocation,
798            Err(error) => {
799                let terminal = provider_terminal(error.kind());
800                let cleanup_verified = cleanup_provider(&mut provider).await;
801                self.store_provider_terminal(
802                    provider,
803                    None,
804                    terminal,
805                    None,
806                    ProviderTerminalContext {
807                        source,
808                        parameter_identity,
809                        cleanup_verified,
810                    },
811                )?;
812                return phase_evidence(
813                    request,
814                    control_bytes,
815                    SsrfOastLifecycleEvidence {
816                        control_complete: true,
817                        provider_registered: true,
818                        cleanup_verified,
819                        phase_terminal: true,
820                        ..SsrfOastLifecycleEvidence::default()
821                    },
822                    Some((&signal, control_response.status())),
823                );
824            },
825        };
826        let replay_allocation = match provider.allocate_callback(replay_case, replay_token).await {
827            Ok(allocation) => allocation,
828            Err(error) => {
829                let terminal = provider_terminal(error.kind());
830                let cleanup_verified = cleanup_provider(&mut provider).await;
831                self.store_provider_terminal(
832                    provider,
833                    None,
834                    terminal,
835                    None,
836                    ProviderTerminalContext {
837                        source,
838                        parameter_identity,
839                        cleanup_verified,
840                    },
841                )?;
842                return phase_evidence(
843                    request,
844                    control_bytes,
845                    SsrfOastLifecycleEvidence {
846                        control_complete: true,
847                        provider_registered: true,
848                        cleanup_verified,
849                        phase_terminal: true,
850                        ..SsrfOastLifecycleEvidence::default()
851                    },
852                    Some((&signal, control_response.status())),
853                );
854            },
855        };
856        let candidate_callback = candidate_allocation.callback_id().clone();
857        let replay_callback = replay_allocation.callback_id().clone();
858        let candidate_correlation = candidate_allocation
859            .correlation_receipt()
860            .correlation_id()
861            .clone();
862        let replay_correlation = replay_allocation
863            .correlation_receipt()
864            .correlation_id()
865            .clone();
866        let mut facts = SsrfOastReviewFacts::new(&candidate_callback, &replay_callback);
867        facts.control_complete = true;
868        facts.provider_registered = true;
869        facts.allocations_complete = true;
870        facts.correlations_distinct = candidate_correlation != replay_correlation;
871        facts.same_correlation_scope = true;
872        let plan = match mutation_plan(
873            candidate,
874            control_seed,
875            candidate_allocation.target(),
876            replay_allocation.target(),
877            self.policy.provider_origin(),
878        ) {
879            Ok(plan) => plan,
880            Err(_) => {
881                facts.terminal = Some(SsrfOastTerminalState::MalformedProviderResponse);
882                facts.cleanup_verified = cleanup_provider(&mut provider).await;
883                facts.provider_accounting_complete = provider_accounting_is_complete(&provider);
884                let lifecycle = SsrfOastLifecycleEvidence::from_facts(&facts, true);
885                self.store_provider_terminal(
886                    provider,
887                    Some(facts),
888                    SsrfOastTerminalState::MalformedProviderResponse,
889                    None,
890                    ProviderTerminalContext {
891                        source,
892                        parameter_identity,
893                        cleanup_verified: lifecycle.cleanup_verified,
894                    },
895                )?;
896                return phase_evidence(
897                    request,
898                    control_bytes,
899                    lifecycle,
900                    Some((&signal, control_response.status())),
901                );
902            },
903        };
904        let preflight = match provider.poll().await {
905            Ok(preflight) => preflight,
906            Err(error) => {
907                let terminal = provider_terminal(error.kind());
908                facts.terminal = Some(terminal);
909                facts.cleanup_verified = cleanup_provider(&mut provider).await;
910                facts.provider_accounting_complete = provider_accounting_is_complete(&provider);
911                let lifecycle = SsrfOastLifecycleEvidence::from_facts(&facts, true);
912                self.store_provider_terminal(
913                    provider,
914                    Some(facts),
915                    terminal,
916                    None,
917                    ProviderTerminalContext {
918                        source,
919                        parameter_identity,
920                        cleanup_verified: lifecycle.cleanup_verified,
921                    },
922                )?;
923                return phase_evidence(
924                    request,
925                    control_bytes,
926                    lifecycle,
927                    Some((&signal, control_response.status())),
928                );
929            },
930        };
931        let preflight_clean = preflight
932            .correlation_receipts()
933            .iter()
934            .all(|receipt| receipt.accepted_events() == 0 && receipt.duplicate_events() == 0);
935        facts.preflight_clean = preflight_clean;
936        if !preflight_clean {
937            facts.cleanup_verified = cleanup_provider(&mut provider).await;
938            facts.provider_accounting_complete = provider_accounting_is_complete(&provider);
939            facts.terminal = None;
940            let lifecycle = SsrfOastLifecycleEvidence::from_facts(&facts, true);
941            let mut state = self
942                .state
943                .lock()
944                .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
945            state.cleanup_verified = facts.cleanup_verified;
946            state.facts = Some(facts);
947            state.retain_provider_audit(&provider);
948            state.source = Some(source);
949            state.parameter_identity = Some(parameter_identity);
950            return phase_evidence(
951                request,
952                control_bytes,
953                lifecycle,
954                Some((&signal, control_response.status())),
955            );
956        }
957        let mut state = self
958            .state
959            .lock()
960            .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
961        state.retain_provider_audit(&provider);
962        state.prepared = Some(PreparedSsrfOast {
963            provider,
964            plan,
965            candidate_callback,
966            replay_callback,
967            candidate_correlation,
968            replay_correlation,
969            facts,
970        });
971        state.source = Some(source);
972        state.parameter_identity = Some(parameter_identity);
973        let lifecycle = SsrfOastLifecycleEvidence::from_facts(
974            &state
975                .prepared
976                .as_ref()
977                .expect("prepared SSRF OAST state is present")
978                .facts,
979            false,
980        );
981        let evidence = phase_evidence(
982            request,
983            control_bytes,
984            lifecycle,
985            Some((&signal, control_response.status())),
986        )?;
987        state.evidence_ids.extend(ssrf_evidence_ids(&evidence));
988        Ok(evidence)
989    }
990
991    async fn execute_active(
992        &self,
993        request: &DecisionExecutionRequest,
994    ) -> Result<Vec<Evidence>, DecisionExecutorError> {
995        let mut prepared = self
996            .state
997            .lock()
998            .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?
999            .prepared
1000            .take()
1001            .ok_or_else(|| DecisionExecutorError::new("SSRF OAST passive state is incomplete"))?;
1002        let mut response_bytes = 0_u64;
1003        let candidate_url = prepared
1004            .plan
1005            .execution_url(SsrfOastTargetLeg::Candidate)
1006            .clone();
1007        let candidate_response = collect_target(
1008            &self.authority,
1009            request,
1010            DecisionExecutionStage::Active,
1011            None,
1012            &candidate_url,
1013        )
1014        .await;
1015        let mut last_signal = match candidate_response {
1016            Ok(response) => {
1017                response_bytes = response_bytes
1018                    .saturating_add(u64::try_from(response.body().len()).unwrap_or(u64::MAX));
1019                let signal = response.ssrf_oast_defense_signal();
1020                if let Some(terminal) = classify_target_response(&response, &candidate_url, &signal)
1021                {
1022                    prepared.facts.terminal = Some(terminal);
1023                }
1024                prepared.facts.candidate_dispatched = true;
1025                Some((signal, response.status()))
1026            },
1027            Err(TargetFailure::TimeoutAfterDispatch) => {
1028                prepared.facts.candidate_dispatched = true;
1029                prepared.facts.terminal = Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
1030                None
1031            },
1032            Err(TargetFailure::Limit(limit)) => {
1033                prepared.facts.terminal = Some(SsrfOastTerminalState::BudgetExhausted);
1034                let lifecycle = self.store_active_terminal(prepared, Some(limit)).await?;
1035                return phase_evidence(request, 0, lifecycle, None);
1036            },
1037            Err(TargetFailure::Transport) => {
1038                prepared.facts.terminal = Some(SsrfOastTerminalState::Incomplete);
1039                let lifecycle = self.store_active_terminal(prepared, None).await?;
1040                return phase_evidence(request, 0, lifecycle, None);
1041            },
1042        };
1043        if polling_may_continue(&prepared.facts) {
1044            poll_for_events(
1045                &mut prepared,
1046                self.policy.polls_per_leg(),
1047                self.policy.poll_interval_ms(),
1048                SsrfOastPollPhase::Candidate,
1049                self.authority.cancellation(),
1050            )
1051            .await?;
1052        }
1053        let replay_url = prepared
1054            .plan
1055            .execution_url(SsrfOastTargetLeg::Replay)
1056            .clone();
1057        if polling_may_continue(&prepared.facts) {
1058            match collect_target(
1059                &self.authority,
1060                request,
1061                DecisionExecutionStage::Passive,
1062                Some(DecisionActionOrigin::Planned),
1063                &replay_url,
1064            )
1065            .await
1066            {
1067                Ok(response) => {
1068                    response_bytes = response_bytes
1069                        .saturating_add(u64::try_from(response.body().len()).unwrap_or(u64::MAX));
1070                    let signal = response.ssrf_oast_defense_signal();
1071                    if let Some(terminal) =
1072                        classify_target_response(&response, &replay_url, &signal)
1073                    {
1074                        prepared.facts.terminal = Some(terminal);
1075                    }
1076                    prepared.facts.replay_dispatched = true;
1077                    last_signal = Some((signal, response.status()));
1078                },
1079                Err(TargetFailure::TimeoutAfterDispatch) => {
1080                    prepared.facts.replay_dispatched = true;
1081                    prepared.facts.terminal =
1082                        Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
1083                },
1084                Err(TargetFailure::Limit(limit)) => {
1085                    prepared.facts.terminal = Some(SsrfOastTerminalState::BudgetExhausted);
1086                    let lifecycle = self.store_active_terminal(prepared, Some(limit)).await?;
1087                    return phase_evidence(request, response_bytes, lifecycle, None);
1088                },
1089                Err(TargetFailure::Transport) => {
1090                    prepared.facts.terminal = Some(SsrfOastTerminalState::Incomplete);
1091                },
1092            }
1093        }
1094        if polling_may_continue(&prepared.facts) {
1095            let used = prepared
1096                .provider
1097                .receipts()
1098                .iter()
1099                .filter(|receipt| receipt.operation() == NativeOastProviderOperation::Poll)
1100                .count();
1101            let remaining =
1102                usize::from(MAX_POST_DISPATCH_POLLS).saturating_sub(used.saturating_sub(1));
1103            let polls = usize::from(self.policy.polls_per_leg()).min(remaining) as u16;
1104            poll_for_events(
1105                &mut prepared,
1106                polls,
1107                self.policy.poll_interval_ms(),
1108                SsrfOastPollPhase::Replay,
1109                self.authority.cancellation(),
1110            )
1111            .await?;
1112        }
1113        prepared.facts.cleanup_verified = cleanup_provider(&mut prepared.provider).await;
1114        prepared.facts.provider_accounting_complete =
1115            provider_accounting_is_complete(&prepared.provider);
1116        prepared.facts.target_accounting_complete =
1117            current_target_accounting_is_complete(&self.authority);
1118        let terminal = prepared.facts.terminal.is_some()
1119            && !matches!(
1120                prepared.facts.terminal,
1121                Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch)
1122            );
1123        let lifecycle = SsrfOastLifecycleEvidence::from_facts(&prepared.facts, terminal);
1124        let evidence = phase_evidence(
1125            request,
1126            response_bytes,
1127            lifecycle,
1128            last_signal
1129                .as_ref()
1130                .map(|(signal, status)| (signal, *status)),
1131        )?;
1132        let mut state = self
1133            .state
1134            .lock()
1135            .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
1136        state.retain_provider_audit(&prepared.provider);
1137        state.terminal = prepared.facts.terminal;
1138        state.cleanup_verified = prepared.facts.cleanup_verified;
1139        state.evidence_ids.extend(ssrf_evidence_ids(&evidence));
1140        state.facts = Some(prepared.facts);
1141        Ok(evidence)
1142    }
1143
1144    async fn store_active_terminal(
1145        &self,
1146        mut prepared: PreparedSsrfOast,
1147        limit: Option<RuntimeLimitExceeded>,
1148    ) -> Result<SsrfOastLifecycleEvidence, DecisionExecutorError> {
1149        prepared.facts.cleanup_verified = cleanup_provider(&mut prepared.provider).await;
1150        prepared.facts.provider_accounting_complete =
1151            provider_accounting_is_complete(&prepared.provider);
1152        prepared.facts.target_accounting_complete =
1153            current_target_accounting_is_complete(&self.authority);
1154        let lifecycle = SsrfOastLifecycleEvidence::from_facts(&prepared.facts, true);
1155        let mut state = self
1156            .state
1157            .lock()
1158            .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
1159        state.retain_provider_audit(&prepared.provider);
1160        state.terminal = prepared.facts.terminal;
1161        state.runtime_limit = limit;
1162        state.cleanup_verified = prepared.facts.cleanup_verified;
1163        state.facts = Some(prepared.facts);
1164        Ok(lifecycle)
1165    }
1166
1167    fn store_provider_terminal(
1168        &self,
1169        provider: NativeOastProviderAdapter,
1170        facts: Option<SsrfOastReviewFacts>,
1171        terminal: SsrfOastTerminalState,
1172        limit: Option<RuntimeLimitExceeded>,
1173        context: ProviderTerminalContext,
1174    ) -> Result<(), DecisionExecutorError> {
1175        let mut state = self
1176            .state
1177            .lock()
1178            .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
1179        state.retain_provider_audit(&provider);
1180        state.terminal = Some(terminal);
1181        state.runtime_limit = limit;
1182        state.source = Some(context.source);
1183        state.parameter_identity = Some(context.parameter_identity);
1184        state.cleanup_verified = context.cleanup_verified;
1185        state.facts = facts;
1186        Ok(())
1187    }
1188}
1189
1190#[async_trait]
1191impl DecisionActionExecutor for SsrfOastDecisionExecutor {
1192    fn id(&self) -> &str {
1193        SSRF_OAST_EXECUTOR_ID
1194    }
1195
1196    async fn execute(
1197        &self,
1198        request: &DecisionExecutionRequest,
1199    ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1200        if request.case().action_id() != SSRF_OAST_REVIEW_ACTION_ID
1201            || request.case().subject() != &self.subject
1202            || request.case().payload_strategy().is_some()
1203            || request.case().applies_hypothesis_transition()
1204            || request.delay_ms().is_some()
1205            || (request.stage() == DecisionExecutionStage::Passive && request.origin().is_none())
1206            || (request.stage() == DecisionExecutionStage::Active && request.origin().is_some())
1207        {
1208            return Err(DecisionExecutorError::new(
1209                "SSRF OAST executor route contract failed",
1210            ));
1211        }
1212        match request.stage() {
1213            DecisionExecutionStage::Passive => self.execute_passive(request).await,
1214            DecisionExecutionStage::Active => self.execute_active(request).await,
1215        }
1216    }
1217}
1218
1219enum TargetFailure {
1220    Limit(RuntimeLimitExceeded),
1221    TimeoutAfterDispatch,
1222    Transport,
1223}
1224
1225async fn collect_target(
1226    authority: &SharedWebRuntimeAuthority,
1227    request: &DecisionExecutionRequest,
1228    stage: DecisionExecutionStage,
1229    origin: Option<DecisionActionOrigin>,
1230    url: &url::Url,
1231) -> Result<crate::http_evidence::CollectedHttpResponse, TargetFailure> {
1232    let probe =
1233        HttpProbe::new(url.clone(), HttpProbeMethod::Get).map_err(|_| TargetFailure::Transport)?;
1234    authority
1235        .requests()
1236        .collect_for_runtime(
1237            SSRF_OAST_REVIEW_ACTION_ID,
1238            stage,
1239            origin,
1240            request.limits(),
1241            &probe,
1242        )
1243        .await
1244        .map_err(|error| {
1245            if let HttpRequestBrokerError::RuntimeLimit(limit) = error {
1246                return TargetFailure::Limit(limit);
1247            }
1248            if error.failure_kind() == DecisionExecutionFailureKind::RequestTimeout {
1249                TargetFailure::TimeoutAfterDispatch
1250            } else {
1251                TargetFailure::Transport
1252            }
1253        })
1254}
1255
1256fn polling_may_continue(facts: &SsrfOastReviewFacts) -> bool {
1257    facts.terminal.is_none()
1258        || matches!(
1259            facts.terminal,
1260            Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch)
1261        )
1262}
1263
1264async fn cleanup_provider(provider: &mut NativeOastProviderAdapter) -> bool {
1265    provider
1266        .cleanup()
1267        .await
1268        .as_ref()
1269        .is_ok_and(|receipt| receipt.cleanup_verified())
1270}
1271
1272fn provider_accounting_is_complete(provider: &NativeOastProviderAdapter) -> bool {
1273    let receipts = provider.receipts();
1274    receipts.len() >= 5
1275        && receipts.len() <= MAX_SSRF_OAST_PROVIDER_REQUESTS
1276        && receipts
1277            .iter()
1278            .enumerate()
1279            .all(|(index, receipt)| usize::from(receipt.request_count()) == index + 1)
1280        && receipts
1281            .first()
1282            .is_some_and(|receipt| receipt.operation() == NativeOastProviderOperation::Register)
1283        && receipts.get(1).is_some_and(|receipt| {
1284            receipt.operation() == NativeOastProviderOperation::AllocateCallback
1285        })
1286        && receipts.get(2).is_some_and(|receipt| {
1287            receipt.operation() == NativeOastProviderOperation::AllocateCallback
1288        })
1289        && receipts
1290            .get(3)
1291            .is_some_and(|receipt| receipt.operation() == NativeOastProviderOperation::Poll)
1292        && receipts[4..receipts.len() - 1]
1293            .iter()
1294            .all(|receipt| receipt.operation() == NativeOastProviderOperation::Poll)
1295        && receipts
1296            .last()
1297            .is_some_and(|receipt| receipt.operation() == NativeOastProviderOperation::Cleanup)
1298}
1299
1300fn current_target_accounting_is_complete(authority: &SharedWebRuntimeAuthority) -> bool {
1301    let audit = authority.request_accounting().dispatch_audit();
1302    let receipts = audit
1303        .receipts()
1304        .iter()
1305        .filter(|receipt| receipt.action_id() == SSRF_OAST_REVIEW_ACTION_ID)
1306        .collect::<Vec<_>>();
1307    audit.omitted_receipt_count() == 0
1308        && target_receipt_prefix_is_valid(&receipts)
1309        && receipts.len() == SSRF_OAST_TARGET_REQUESTS
1310        && receipts
1311            .iter()
1312            .all(|receipt| target_dispatch_outcome_is_accounted(receipt.outcome()))
1313}
1314
1315const fn target_dispatch_outcome_is_accounted(outcome: TransportDispatchOutcome) -> bool {
1316    matches!(
1317        outcome,
1318        TransportDispatchOutcome::Completed | TransportDispatchOutcome::RequestTimeout
1319    )
1320}
1321
1322fn classify_target_response(
1323    response: &crate::http_evidence::CollectedHttpResponse,
1324    expected: &url::Url,
1325    signal: &super::AssessmentDefenseSignal,
1326) -> Option<SsrfOastTerminalState> {
1327    if response.final_url() != expected || (300..400).contains(&response.status()) {
1328        return Some(SsrfOastTerminalState::Incomplete);
1329    }
1330    if response.body_truncated() {
1331        return Some(SsrfOastTerminalState::Incomplete);
1332    }
1333    if !response.body_complete() {
1334        return Some(SsrfOastTerminalState::Incomplete);
1335    }
1336    if signal.state().is_rate_limited() {
1337        return Some(SsrfOastTerminalState::RateLimited);
1338    }
1339    if signal.state().is_challenged() {
1340        return Some(SsrfOastTerminalState::DefensiveInterference);
1341    }
1342    None
1343}
1344
1345#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1346enum SsrfOastPollPhase {
1347    Candidate,
1348    Replay,
1349}
1350
1351impl SsrfOastPollPhase {
1352    const fn expected_event_observed(self, candidate_seen: bool, replay_seen: bool) -> bool {
1353        match self {
1354            Self::Candidate => candidate_seen,
1355            Self::Replay => candidate_seen && replay_seen,
1356        }
1357    }
1358}
1359
1360async fn poll_for_events(
1361    prepared: &mut PreparedSsrfOast,
1362    polls: u16,
1363    interval_ms: u64,
1364    phase: SsrfOastPollPhase,
1365    cancellation: &tokio_util::sync::CancellationToken,
1366) -> Result<(), DecisionExecutorError> {
1367    for _ in 0..polls {
1368        tokio::select! {
1369            _ = cancellation.cancelled() => {
1370                prepared.facts.terminal = Some(SsrfOastTerminalState::Cancelled);
1371                return Ok(());
1372            }
1373            _ = tokio::time::sleep(Duration::from_millis(interval_ms)) => {}
1374        }
1375        let page = match prepared.provider.poll().await {
1376            Ok(page) => page,
1377            Err(error) => {
1378                prepared.facts.terminal = Some(provider_terminal(error.kind()));
1379                return Ok(());
1380            },
1381        };
1382        reduce_poll(prepared, &page, phase);
1383        if !polling_may_continue(&prepared.facts) {
1384            break;
1385        }
1386        if phase.expected_event_observed(
1387            prepared.facts.candidate_event.is_some(),
1388            prepared.facts.replay_event.is_some(),
1389        ) {
1390            break;
1391        }
1392    }
1393    Ok(())
1394}
1395
1396fn reduce_poll(
1397    prepared: &mut PreparedSsrfOast,
1398    page: &crate::native_oast_provider::NativeOastPollOutcome,
1399    phase: SsrfOastPollPhase,
1400) {
1401    for receipt in page.correlation_receipts() {
1402        let is_candidate = receipt.correlation_id() == &prepared.candidate_correlation;
1403        let is_replay = receipt.correlation_id() == &prepared.replay_correlation;
1404        if !is_candidate && !is_replay {
1405            prepared.facts.terminal = Some(SsrfOastTerminalState::MalformedProviderResponse);
1406            continue;
1407        }
1408        if phase == SsrfOastPollPhase::Candidate && is_replay && receipt.accepted_events() > 0 {
1409            prepared.facts.terminal = Some(SsrfOastTerminalState::MalformedProviderResponse);
1410        }
1411        for event in receipt.event_receipts() {
1412            match event.disposition() {
1413                OastEventDisposition::Accepted => {
1414                    let observed = if is_candidate {
1415                        SsrfOastObservedEvent::from_reduced(
1416                            &prepared.candidate_callback,
1417                            event.event_key(),
1418                        )
1419                    } else {
1420                        SsrfOastObservedEvent::from_reduced(
1421                            &prepared.replay_callback,
1422                            event.event_key(),
1423                        )
1424                    };
1425                    let target = if is_candidate {
1426                        &mut prepared.facts.candidate_event
1427                    } else {
1428                        &mut prepared.facts.replay_event
1429                    };
1430                    if target.replace(observed).is_some() {
1431                        prepared.facts.terminal =
1432                            Some(SsrfOastTerminalState::MalformedProviderResponse);
1433                    }
1434                },
1435                OastEventDisposition::DuplicateSuppressed => {
1436                    if (is_candidate && prepared.facts.candidate_event.is_none())
1437                        || (is_replay && prepared.facts.replay_event.is_none())
1438                    {
1439                        prepared.facts.duplicate_only_substitution = true;
1440                    }
1441                },
1442            }
1443        }
1444    }
1445}
1446
1447fn provider_terminal(kind: NativeOastProviderErrorKind) -> SsrfOastTerminalState {
1448    match kind {
1449        NativeOastProviderErrorKind::Cancelled => SsrfOastTerminalState::Cancelled,
1450        NativeOastProviderErrorKind::DeadlineExceeded
1451        | NativeOastProviderErrorKind::ProviderExpired => SsrfOastTerminalState::Expired,
1452        NativeOastProviderErrorKind::RuntimeBudget(_)
1453        | NativeOastProviderErrorKind::ParentBudgetTooSmall
1454        | NativeOastProviderErrorKind::RequestLimit
1455        | NativeOastProviderErrorKind::ResponseByteLimit
1456        | NativeOastProviderErrorKind::RequestByteLimit => SsrfOastTerminalState::BudgetExhausted,
1457        NativeOastProviderErrorKind::PollLimit => SsrfOastTerminalState::PollExhausted,
1458        NativeOastProviderErrorKind::ProviderAccessRejected => {
1459            SsrfOastTerminalState::ProviderAuthenticationFailed
1460        },
1461        NativeOastProviderErrorKind::ProviderResponseInvalid
1462        | NativeOastProviderErrorKind::ProviderSessionMismatch
1463        | NativeOastProviderErrorKind::ProviderCallbackMismatch
1464        | NativeOastProviderErrorKind::ProviderPageIncomplete
1465        | NativeOastProviderErrorKind::CorrelationRejected => {
1466            SsrfOastTerminalState::MalformedProviderResponse
1467        },
1468        _ => SsrfOastTerminalState::Incomplete,
1469    }
1470}
1471
1472fn callback_case(
1473    request: &DecisionExecutionRequest,
1474    role: &str,
1475) -> Result<VerificationCase, DecisionExecutorError> {
1476    VerificationCase::new(
1477        format!("{}:{role}", request.case().id()),
1478        request.case().subject().clone(),
1479        request.case().action_id(),
1480        request.case().hypothesis_id(),
1481    )
1482    .map(VerificationCase::without_hypothesis_transition)
1483    .map_err(|_| DecisionExecutorError::new("SSRF OAST callback case failed"))
1484}
1485
1486fn random_entropy() -> Result<[u8; 32], DecisionExecutorError> {
1487    let mut bytes = [0_u8; 32];
1488    getrandom::getrandom(&mut bytes)
1489        .map_err(|_| DecisionExecutorError::new("SSRF OAST entropy source failed"))?;
1490    Ok(bytes)
1491}
1492
1493fn provider_configuration(
1494    origin: &PublicOrigin,
1495    assessment_id: &str,
1496    epoch: [u8; 32],
1497    administrator: Vec<u8>,
1498    limits: NativeOastProviderLimits,
1499) -> Result<NativeOastProviderConfiguration, crate::native_oast_provider::NativeOastProviderError> {
1500    #[cfg(test)]
1501    if origin.as_str().starts_with("http://") {
1502        return NativeOastProviderConfiguration::for_loopback(
1503            origin.clone(),
1504            assessment_id,
1505            epoch,
1506            administrator,
1507            limits,
1508        );
1509    }
1510    NativeOastProviderConfiguration::new(
1511        origin.as_str(),
1512        assessment_id,
1513        epoch,
1514        administrator,
1515        limits,
1516    )
1517}
1518
1519fn mutation_plan(
1520    candidate: SsrfOastQueryCandidate,
1521    control_seed: [u8; 32],
1522    candidate_target: &termivar_oast::CallbackTarget,
1523    replay_target: &termivar_oast::CallbackTarget,
1524    provider: &PublicOrigin,
1525) -> Result<SsrfOastMutationPlan, crate::ssrf_oast_review::SsrfOastContractError> {
1526    #[cfg(test)]
1527    if provider.as_str().starts_with("http://") {
1528        return SsrfOastMutationPlan::new_for_loopback(
1529            candidate,
1530            control_seed,
1531            candidate_target,
1532            replay_target,
1533            provider,
1534        );
1535    }
1536    SsrfOastMutationPlan::new(
1537        candidate,
1538        control_seed,
1539        candidate_target,
1540        replay_target,
1541        provider,
1542    )
1543}
1544
1545fn assessment_identity(subject: &EntityId) -> String {
1546    let digest = Sha256::digest(subject.as_str().as_bytes());
1547    format!("assessment-{digest:x}")
1548}
1549
1550#[derive(Clone, Copy, Default)]
1551struct SsrfOastLifecycleEvidence {
1552    control_complete: bool,
1553    provider_registered: bool,
1554    allocations_complete: bool,
1555    preflight_clean: bool,
1556    candidate_dispatched: bool,
1557    candidate_callback_correlated: bool,
1558    replay_dispatched: bool,
1559    replay_callback_correlated: bool,
1560    repeated_callbacks_correlated: bool,
1561    cleanup_verified: bool,
1562    target_accounting_complete: bool,
1563    provider_accounting_complete: bool,
1564    phase_terminal: bool,
1565}
1566
1567impl SsrfOastLifecycleEvidence {
1568    const fn terminal() -> Self {
1569        Self {
1570            phase_terminal: true,
1571            ..Self::default_const()
1572        }
1573    }
1574
1575    const fn default_const() -> Self {
1576        Self {
1577            control_complete: false,
1578            provider_registered: false,
1579            allocations_complete: false,
1580            preflight_clean: false,
1581            candidate_dispatched: false,
1582            candidate_callback_correlated: false,
1583            replay_dispatched: false,
1584            replay_callback_correlated: false,
1585            repeated_callbacks_correlated: false,
1586            cleanup_verified: false,
1587            target_accounting_complete: false,
1588            provider_accounting_complete: false,
1589            phase_terminal: false,
1590        }
1591    }
1592
1593    fn from_facts(facts: &SsrfOastReviewFacts, phase_terminal: bool) -> Self {
1594        Self {
1595            control_complete: facts.control_complete,
1596            provider_registered: facts.provider_registered,
1597            allocations_complete: facts.allocations_complete,
1598            preflight_clean: facts.preflight_clean,
1599            candidate_dispatched: facts.candidate_dispatched,
1600            candidate_callback_correlated: facts.candidate_event.is_some(),
1601            replay_dispatched: facts.replay_dispatched,
1602            replay_callback_correlated: facts.replay_event.is_some(),
1603            repeated_callbacks_correlated: evaluate_ssrf_oast_review(facts)
1604                .is_ok_and(SsrfOastReviewOutcome::projects_item),
1605            cleanup_verified: facts.cleanup_verified,
1606            target_accounting_complete: facts.target_accounting_complete,
1607            provider_accounting_complete: facts.provider_accounting_complete,
1608            phase_terminal,
1609        }
1610    }
1611
1612    const fn candidate_ready(self) -> bool {
1613        self.control_complete
1614            && self.provider_registered
1615            && self.allocations_complete
1616            && self.preflight_clean
1617            && !self.phase_terminal
1618    }
1619}
1620
1621fn phase_evidence(
1622    request: &DecisionExecutionRequest,
1623    response_bytes: u64,
1624    lifecycle: SsrfOastLifecycleEvidence,
1625    defense: Option<(&super::AssessmentDefenseSignal, u16)>,
1626) -> Result<Vec<Evidence>, DecisionExecutorError> {
1627    let mut evidence = vec![
1628        make_evidence(
1629            request,
1630            EvidenceKind::Content,
1631            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1632            EvidenceValue::Unsigned(response_bytes),
1633            "response-body-size",
1634        )?,
1635        make_evidence(
1636            request,
1637            EvidenceKind::Custom("ssrf-oast-phase".into()),
1638            crate::web_actions::ssrf_oast_review_candidate_ready_predicate(),
1639            EvidenceValue::Boolean(lifecycle.candidate_ready()),
1640            "candidate-ready",
1641        )?,
1642        make_evidence(
1643            request,
1644            EvidenceKind::Custom("ssrf-oast-phase".into()),
1645            crate::web_actions::ssrf_oast_review_phase_terminal_predicate(),
1646            EvidenceValue::Boolean(lifecycle.phase_terminal),
1647            "phase-terminal",
1648        )?,
1649    ];
1650    for (name, value) in [
1651        (CONTROL_COMPLETE, lifecycle.control_complete),
1652        (PROVIDER_REGISTERED, lifecycle.provider_registered),
1653        (ALLOCATIONS_COMPLETE, lifecycle.allocations_complete),
1654        (PREFLIGHT_CLEAN, lifecycle.preflight_clean),
1655        (CANDIDATE_DISPATCHED, lifecycle.candidate_dispatched),
1656        (
1657            CANDIDATE_CALLBACK_CORRELATED,
1658            lifecycle.candidate_callback_correlated,
1659        ),
1660        (REPLAY_DISPATCHED, lifecycle.replay_dispatched),
1661        (
1662            REPLAY_CALLBACK_CORRELATED,
1663            lifecycle.replay_callback_correlated,
1664        ),
1665        (
1666            REPEATED_CALLBACKS_CORRELATED,
1667            lifecycle.repeated_callbacks_correlated,
1668        ),
1669        (CLEANUP_VERIFIED, lifecycle.cleanup_verified),
1670        (
1671            TARGET_ACCOUNTING_COMPLETE,
1672            lifecycle.target_accounting_complete,
1673        ),
1674        (
1675            PROVIDER_ACCOUNTING_COMPLETE,
1676            lifecycle.provider_accounting_complete,
1677        ),
1678    ] {
1679        evidence.push(make_evidence(
1680            request,
1681            EvidenceKind::Custom("ssrf-oast-lifecycle".into()),
1682            KnowledgePredicate::new(SSRF_OAST_EVIDENCE_NAMESPACE, name)
1683                .map_err(|_| DecisionExecutorError::new("SSRF OAST predicate failed"))?,
1684            EvidenceValue::Boolean(value),
1685            name,
1686        )?);
1687    }
1688    if request.stage() == DecisionExecutionStage::Active && !lifecycle.phase_terminal {
1689        evidence.push(make_evidence(
1690            request,
1691            EvidenceKind::Custom("native-web-review-response".into()),
1692            crate::web_actions::native_web_review_response_marker_predicate(),
1693            EvidenceValue::Boolean(true),
1694            "complete",
1695        )?);
1696    }
1697    if let Some((signal, status)) = defense {
1698        let base = make_evidence(
1699            request,
1700            EvidenceKind::Http,
1701            HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
1702            EvidenceValue::Unsigned(u64::from(status)),
1703            "response-status",
1704        )?;
1705        let parents = vec![evidence[0].id().clone(), base.id().clone()];
1706        evidence.push(base);
1707        evidence.extend(
1708            project_assessment_defense_signal(
1709                signal,
1710                AssessmentDefenseProjectionContext {
1711                    subject: request.case().subject(),
1712                    case_id: request.case().id(),
1713                    executor_id: SSRF_OAST_EXECUTOR_ID,
1714                    reliability: ConfidenceScore::MAX,
1715                    parents,
1716                },
1717            )
1718            .map_err(|_| DecisionExecutorError::new("SSRF OAST defense projection failed"))?,
1719        );
1720    }
1721    Ok(evidence)
1722}
1723
1724fn make_evidence(
1725    request: &DecisionExecutionRequest,
1726    kind: EvidenceKind,
1727    predicate: KnowledgePredicate,
1728    value: EvidenceValue,
1729    method: &'static str,
1730) -> Result<Evidence, DecisionExecutorError> {
1731    Ok(Evidence::new(
1732        request.case().subject().clone(),
1733        kind,
1734        predicate,
1735        value,
1736        EvidenceSource::new(SSRF_OAST_EXECUTOR_ID, method)
1737            .and_then(|source| source.with_correlation_id(request.case().id()))
1738            .map_err(|_| DecisionExecutorError::new("SSRF OAST evidence source failed"))?,
1739        ConfidenceScore::MAX,
1740    ))
1741}
1742
1743fn ssrf_evidence_ids(evidence: &[Evidence]) -> Vec<EvidenceId> {
1744    evidence
1745        .iter()
1746        .filter(|item| item.predicate().namespace() == SSRF_OAST_EVIDENCE_NAMESPACE)
1747        .map(|item| item.id().clone())
1748        .collect()
1749}
1750
1751fn audit(
1752    policy: &SsrfOastReviewPolicy,
1753    state: &SsrfOastExecutionState,
1754    outcome: SsrfOastRuntimeOutcome,
1755    target_requests: u8,
1756    projected: bool,
1757) -> WebAssessmentSsrfOastAudit {
1758    let facts = state.facts.as_ref();
1759    WebAssessmentSsrfOastAudit {
1760        outcome,
1761        policy_id: policy.policy_id().to_wire(),
1762        candidate_source: state.source.map(SsrfOastCandidateSource::as_str),
1763        target_request_count: target_requests,
1764        provider_request_count: state.provider_request_count,
1765        provider_failure: state.provider_failure,
1766        cleanup_failure: state.cleanup_failure,
1767        active_verification_count: u8::from(target_requests >= 2),
1768        preflight_clean: facts.is_some_and(|facts| facts.preflight_clean),
1769        candidate_callback_observed: facts.is_some_and(|facts| facts.candidate_event.is_some()),
1770        replay_callback_observed: facts.is_some_and(|facts| facts.replay_event.is_some()),
1771        cleanup_verified: facts.map_or(state.cleanup_verified, |facts| facts.cleanup_verified),
1772        item_projected: projected,
1773    }
1774}
1775
1776pub(super) fn project_ssrf_oast_item(
1777    context: &mut AssessmentProjectionContext,
1778    knowledge: &KnowledgeBase,
1779    review: &CommittedSsrfOastReview,
1780) -> Result<(), AssessmentItemProjectionError> {
1781    if review.outcome != SsrfOastRuntimeOutcome::RepeatedCallbacksObserved {
1782        return Ok(());
1783    }
1784    if !positive_evidence_contract(knowledge, &review.evidence_ids) {
1785        return Err(AssessmentItemProjectionError::MissingEvidence);
1786    }
1787    let parameter = review
1788        .parameter_identity
1789        .clone()
1790        .ok_or(AssessmentItemProjectionError::InvalidStableSubjectIdentity)?;
1791    let target = AssessmentItemTarget::ssrf_oast_query(parameter.clone())?;
1792    if !context.has_subject(&review.subject) {
1793        context.register_subject(
1794            review.subject.clone(),
1795            StableAssessmentSubjectId::new(parameter)?,
1796            Vec::new(),
1797        )?;
1798    }
1799    for evidence_id in &review.evidence_ids {
1800        context.register_evidence(knowledge, evidence_id)?;
1801    }
1802    let midpoint = review.evidence_ids.len() / 2;
1803    context.project_differential(
1804        &SSRF_OAST_CAPABILITY,
1805        knowledge,
1806        &review.subject,
1807        &target,
1808        &review.evidence_ids[..midpoint],
1809        &review.evidence_ids[midpoint..],
1810    )?;
1811    Ok(())
1812}
1813
1814fn positive_evidence_contract(knowledge: &KnowledgeBase, evidence_ids: &[EvidenceId]) -> bool {
1815    evidence_ids.len().is_multiple_of(2)
1816        && [
1817            CONTROL_COMPLETE,
1818            PROVIDER_REGISTERED,
1819            ALLOCATIONS_COMPLETE,
1820            PREFLIGHT_CLEAN,
1821            CANDIDATE_DISPATCHED,
1822            CANDIDATE_CALLBACK_CORRELATED,
1823            REPLAY_DISPATCHED,
1824            REPLAY_CALLBACK_CORRELATED,
1825            REPEATED_CALLBACKS_CORRELATED,
1826            CLEANUP_VERIFIED,
1827            TARGET_ACCOUNTING_COMPLETE,
1828            PROVIDER_ACCOUNTING_COMPLETE,
1829        ]
1830        .into_iter()
1831        .all(|name| {
1832            evidence_ids.iter().any(|id| {
1833                knowledge.evidence(id).is_some_and(|evidence| {
1834                    evidence.predicate().namespace() == SSRF_OAST_EVIDENCE_NAMESPACE
1835                        && evidence.predicate().name() == name
1836                        && evidence.value() == &EvidenceValue::Boolean(true)
1837                        && evidence.source().component() == SSRF_OAST_EXECUTOR_ID
1838                })
1839            })
1840        })
1841}
1842
1843#[cfg(test)]
1844mod tests {
1845    use super::*;
1846    use std::sync::{
1847        atomic::{AtomicUsize, Ordering},
1848        Arc,
1849    };
1850
1851    use termivar_oast::{
1852        serve_provider_on_listener, serve_provider_on_listener_with_poll_barrier, AdminToken,
1853        LoopbackBind, ProviderConfig, ProviderLimits, ProviderState, ProviderTestPollBarrier,
1854        CALLBACK_SCHEMA, CLEANUP_SCHEMA, NATIVE_OAST_PROTOCOL_REVISION, SESSION_SCHEMA,
1855    };
1856    use tokio::{
1857        io::{AsyncReadExt, AsyncWriteExt},
1858        net::TcpListener,
1859        sync::Mutex as AsyncMutex,
1860        task::JoinHandle,
1861    };
1862
1863    use crate::{
1864        ssrf_oast_review::{
1865            SsrfOastAdminToken, SsrfOastReviewPolicy, MIN_SSRF_OAST_ADMIN_TOKEN_BYTES,
1866        },
1867        web_runtime::{AssessmentDisposition, WebAssessmentLimits, WebAssessmentRuntime},
1868    };
1869
1870    const ADMIN_SECRET: &[u8] = b"SSRF-OAST-RUNTIME-ADMIN-MUST-NOT-LEAK-91C8";
1871
1872    #[test]
1873    fn assessment_identity_keeps_the_existing_prefix_and_digest_bytes() {
1874        let subject = EntityId::new("endpoint:https://example.test/").unwrap();
1875        assert_eq!(
1876            assessment_identity(&subject),
1877            "assessment-4d5c4f4e055344698360e30846d555a83c7bc086cdd21af36a5bf73d7505dda9"
1878        );
1879    }
1880
1881    struct LoopbackFixture {
1882        target: url::Url,
1883        target_requests: Arc<AsyncMutex<Vec<String>>>,
1884        callback_attempts: Arc<AtomicUsize>,
1885        target_task: JoinHandle<()>,
1886        provider_task: JoinHandle<()>,
1887        provider_origin: PublicOrigin,
1888    }
1889
1890    impl Drop for LoopbackFixture {
1891        fn drop(&mut self) {
1892            self.target_task.abort();
1893            self.provider_task.abort();
1894        }
1895    }
1896
1897    async fn loopback_fixture() -> LoopbackFixture {
1898        loopback_fixture_with_replay_barrier(None).await
1899    }
1900
1901    async fn loopback_fixture_with_replay_barrier(
1902        replay_barrier: Option<ProviderTestPollBarrier>,
1903    ) -> LoopbackFixture {
1904        let provider_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1905        let provider_address = provider_listener.local_addr().unwrap();
1906        let provider_origin = PublicOrigin::from_test_loopback(provider_address).unwrap();
1907        let provider = ProviderState::new(
1908            ProviderConfig::new(
1909                LoopbackBind::new(provider_address).unwrap(),
1910                provider_origin.clone(),
1911                ProviderLimits::new(
1912                    1,
1913                    2,
1914                    termivar_oast::HARD_MAX_EVENTS_PER_SESSION,
1915                    8,
1916                    termivar_oast::HARD_MAX_POLL_EVENTS_PER_RESPONSE,
1917                    20_000,
1918                    16,
1919                )
1920                .unwrap(),
1921            ),
1922            AdminToken::new(ADMIN_SECRET.to_vec()).unwrap(),
1923        )
1924        .unwrap();
1925        let provider_barrier = replay_barrier.clone();
1926        let provider_task = tokio::spawn(async move {
1927            let _ = if let Some(barrier) = provider_barrier {
1928                serve_provider_on_listener_with_poll_barrier(provider_listener, provider, barrier)
1929                    .await
1930            } else {
1931                serve_provider_on_listener(provider_listener, provider).await
1932            };
1933        });
1934
1935        let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1936        let target_address = target_listener.local_addr().unwrap();
1937        let target_requests = Arc::new(AsyncMutex::new(Vec::new()));
1938        let recorded = target_requests.clone();
1939        let callback_attempts = Arc::new(AtomicUsize::new(0));
1940        let callback_ordinal = callback_attempts.clone();
1941        let target_task = tokio::spawn(async move {
1942            loop {
1943                let Ok((mut stream, _)) = target_listener.accept().await else {
1944                    break;
1945                };
1946                let mut request = Vec::new();
1947                loop {
1948                    let mut chunk = [0_u8; 1_024];
1949                    let Ok(read) = stream.read(&mut chunk).await else {
1950                        break;
1951                    };
1952                    if read == 0 {
1953                        break;
1954                    }
1955                    request.extend_from_slice(&chunk[..read]);
1956                    if request.windows(4).any(|window| window == b"\r\n\r\n") {
1957                        break;
1958                    }
1959                }
1960                let line = String::from_utf8_lossy(&request)
1961                    .lines()
1962                    .next()
1963                    .unwrap_or_default()
1964                    .to_owned();
1965                recorded.lock().await.push(line.clone());
1966                let request_target = line.split_whitespace().nth(1).unwrap_or("/");
1967                let parsed = url::Url::parse(&format!("http://fixture.invalid{request_target}"));
1968                if let Ok(parsed) = parsed {
1969                    if let Some((_, callback)) =
1970                        parsed.query_pairs().find(|(name, _)| name == "url")
1971                    {
1972                        if let Ok(callback) = url::Url::parse(callback.as_ref()) {
1973                            let is_inert = callback
1974                                .host_str()
1975                                .is_some_and(|host| host.ends_with(".invalid"));
1976                            if !is_inert {
1977                                let ordinal = callback_ordinal.fetch_add(1, Ordering::SeqCst);
1978                                if let (1, Some(barrier)) = (ordinal, replay_barrier.as_ref()) {
1979                                    barrier.arm();
1980                                    let barrier = barrier.clone();
1981                                    tokio::spawn(async move {
1982                                        if !barrier.wait_for_poll_snapshot().await {
1983                                            return;
1984                                        }
1985                                        let client = reqwest::Client::builder()
1986                                            .redirect(reqwest::redirect::Policy::none())
1987                                            .build()
1988                                            .unwrap();
1989                                        let _ = client.get(callback).send().await;
1990                                    });
1991                                } else {
1992                                    let client = reqwest::Client::builder()
1993                                        .redirect(reqwest::redirect::Policy::none())
1994                                        .build()
1995                                        .unwrap();
1996                                    let _ = client.get(callback).send().await;
1997                                }
1998                            }
1999                        }
2000                    }
2001                }
2002                let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}";
2003                let _ = stream.write_all(response).await;
2004                let _ = stream.shutdown().await;
2005            }
2006        });
2007        LoopbackFixture {
2008            target: url::Url::parse(&format!(
2009                "http://{target_address}/proxy?url=https%3A%2F%2Fseed.example.invalid%2F"
2010            ))
2011            .unwrap(),
2012            target_requests,
2013            callback_attempts,
2014            target_task,
2015            provider_task,
2016            provider_origin,
2017        }
2018    }
2019
2020    async fn provider_rejecting_second_allocation() -> (PublicOrigin, JoinHandle<()>) {
2021        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2022        let address = listener.local_addr().unwrap();
2023        let origin = PublicOrigin::from_test_loopback(address).unwrap();
2024        let session_id = "BwcHBwcHBwcHBwcHBwcHBw";
2025        let callback_id = "CAgICAgICAgICAgICAgICA";
2026        let session_token = "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk";
2027        let callback_target = format!("{}c/{session_id}/{callback_id}", origin.as_str());
2028        let registration = serde_json::to_vec(&serde_json::json!({
2029            "schema": SESSION_SCHEMA,
2030            "session_id": session_id,
2031            "session_token": session_token,
2032            "expires_after_ms": 5_000,
2033            "protocol_revision": NATIVE_OAST_PROTOCOL_REVISION,
2034        }))
2035        .unwrap();
2036        let allocation = serde_json::to_vec(&serde_json::json!({
2037            "schema": CALLBACK_SCHEMA,
2038            "callback_id": callback_id,
2039            "callback_target": callback_target,
2040        }))
2041        .unwrap();
2042        let rejection = br#"{"error":"request rejected"}"#.to_vec();
2043        let cleanup = serde_json::to_vec(&serde_json::json!({
2044            "schema": CLEANUP_SCHEMA,
2045            "removed": true,
2046        }))
2047        .unwrap();
2048        let task = tokio::spawn(async move {
2049            for (status, body) in [
2050                ("201 Created", registration),
2051                ("201 Created", allocation),
2052                ("500 Internal Server Error", rejection),
2053                ("200 OK", cleanup),
2054            ] {
2055                let (mut stream, _) = listener.accept().await.unwrap();
2056                let mut request = vec![0_u8; 16 * 1_024];
2057                let _ = stream.read(&mut request).await.unwrap();
2058                let head = format!(
2059                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2060                    body.len()
2061                );
2062                stream.write_all(head.as_bytes()).await.unwrap();
2063                stream.write_all(&body).await.unwrap();
2064                stream.shutdown().await.unwrap();
2065            }
2066        });
2067        (origin, task)
2068    }
2069
2070    #[test]
2071    fn provider_request_arithmetic_never_exceeds_twelve() {
2072        assert_eq!(1 + 2 + 1 + usize::from(MAX_POST_DISPATCH_POLLS) + 1, 12);
2073        assert_eq!(MAX_SSRF_OAST_REVIEW_REQUESTS, 3);
2074        assert_eq!(MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS, 1);
2075    }
2076
2077    #[test]
2078    fn provider_errors_reduce_to_closed_terminal_classes() {
2079        use crate::RuntimeBudgetDimension;
2080
2081        for (kind, expected) in [
2082            (
2083                NativeOastProviderErrorKind::Cancelled,
2084                SsrfOastTerminalState::Cancelled,
2085            ),
2086            (
2087                NativeOastProviderErrorKind::DeadlineExceeded,
2088                SsrfOastTerminalState::Expired,
2089            ),
2090            (
2091                NativeOastProviderErrorKind::PollLimit,
2092                SsrfOastTerminalState::PollExhausted,
2093            ),
2094            (
2095                NativeOastProviderErrorKind::ProviderAccessRejected,
2096                SsrfOastTerminalState::ProviderAuthenticationFailed,
2097            ),
2098            (
2099                NativeOastProviderErrorKind::ProviderResponseInvalid,
2100                SsrfOastTerminalState::MalformedProviderResponse,
2101            ),
2102            (
2103                NativeOastProviderErrorKind::RuntimeBudget(RuntimeBudgetDimension::TotalRequests),
2104                SsrfOastTerminalState::BudgetExhausted,
2105            ),
2106            (
2107                NativeOastProviderErrorKind::InvalidLifecycle,
2108                SsrfOastTerminalState::Incomplete,
2109            ),
2110        ] {
2111            assert_eq!(provider_terminal(kind), expected);
2112        }
2113    }
2114
2115    #[test]
2116    fn local_credential_rejection_is_not_remote_authentication_evidence() {
2117        assert_eq!(
2118            provider_terminal(NativeOastProviderErrorKind::ProviderRejected),
2119            SsrfOastTerminalState::Incomplete
2120        );
2121    }
2122
2123    #[test]
2124    fn provider_unavailability_is_not_remote_authentication_evidence() {
2125        for kind in [
2126            NativeOastProviderErrorKind::ProviderThrottled,
2127            NativeOastProviderErrorKind::ProviderNotFound,
2128            NativeOastProviderErrorKind::ProviderGone,
2129            NativeOastProviderErrorKind::ProviderRedirectRefused,
2130            NativeOastProviderErrorKind::ProviderServerFailure,
2131            NativeOastProviderErrorKind::ProviderTransportFailure,
2132            NativeOastProviderErrorKind::ProviderRequestConstruction,
2133            NativeOastProviderErrorKind::ProviderClientInitialization,
2134            NativeOastProviderErrorKind::ProviderUnexpectedStatus,
2135        ] {
2136            assert_eq!(provider_terminal(kind), SsrfOastTerminalState::Incomplete);
2137        }
2138        // Preserve the coarse public category while the sidecar reports only
2139        // access rejection, without guessing whether a token caused it.
2140        assert_eq!(
2141            provider_terminal(NativeOastProviderErrorKind::ProviderAccessRejected),
2142            SsrfOastTerminalState::ProviderAuthenticationFailed
2143        );
2144    }
2145
2146    #[test]
2147    fn provider_failure_sidecars_have_closed_raw_free_wire_values() {
2148        use NativeOastProviderErrorKind as Kind;
2149        use SsrfOastProviderFailure as Failure;
2150
2151        for (kind, expected, wire) in [
2152            (
2153                Kind::ProviderRejected,
2154                Failure::CredentialInputInvalid,
2155                "credential_input_invalid",
2156            ),
2157            (
2158                Kind::ProviderAccessRejected,
2159                Failure::AccessRejected,
2160                "access_rejected",
2161            ),
2162            (Kind::ProviderThrottled, Failure::Throttled, "throttled"),
2163            (Kind::ProviderNotFound, Failure::NotFound, "not_found"),
2164            (Kind::ProviderGone, Failure::Gone, "gone"),
2165            (
2166                Kind::ProviderRedirectRefused,
2167                Failure::RedirectRefused,
2168                "redirect_refused",
2169            ),
2170            (
2171                Kind::ProviderServerFailure,
2172                Failure::ServerFailure,
2173                "server_failure",
2174            ),
2175            (
2176                Kind::ProviderTransportFailure,
2177                Failure::TransportFailure,
2178                "transport_failure",
2179            ),
2180            (
2181                Kind::ProviderRequestConstruction,
2182                Failure::RequestConstruction,
2183                "request_construction",
2184            ),
2185            (
2186                Kind::ProviderClientInitialization,
2187                Failure::ClientInitialization,
2188                "client_initialization",
2189            ),
2190            (
2191                Kind::ProviderUnexpectedStatus,
2192                Failure::UnexpectedStatus,
2193                "unexpected_status",
2194            ),
2195            (
2196                Kind::ProviderResponseInvalid,
2197                Failure::MalformedResponse,
2198                "malformed_response",
2199            ),
2200            (
2201                Kind::ProviderSessionMismatch,
2202                Failure::MalformedResponse,
2203                "malformed_response",
2204            ),
2205            (
2206                Kind::ProviderCallbackMismatch,
2207                Failure::MalformedResponse,
2208                "malformed_response",
2209            ),
2210            (
2211                Kind::ProviderPageIncomplete,
2212                Failure::MalformedResponse,
2213                "malformed_response",
2214            ),
2215            (
2216                Kind::CorrelationRejected,
2217                Failure::MalformedResponse,
2218                "malformed_response",
2219            ),
2220            (Kind::Cancelled, Failure::Cancelled, "cancelled"),
2221            (Kind::DeadlineExceeded, Failure::Timeout, "timeout"),
2222            (Kind::ProviderExpired, Failure::Expired, "expired"),
2223            (
2224                Kind::RuntimeBudget(crate::RuntimeBudgetDimension::TotalRequests),
2225                Failure::BudgetRejected,
2226                "budget_rejected",
2227            ),
2228            (
2229                Kind::ParentBudgetTooSmall,
2230                Failure::BudgetRejected,
2231                "budget_rejected",
2232            ),
2233            (
2234                Kind::RequestLimit,
2235                Failure::BudgetRejected,
2236                "budget_rejected",
2237            ),
2238            (
2239                Kind::RequestByteLimit,
2240                Failure::BudgetRejected,
2241                "budget_rejected",
2242            ),
2243            (
2244                Kind::ResponseByteLimit,
2245                Failure::BudgetRejected,
2246                "budget_rejected",
2247            ),
2248            (Kind::PollLimit, Failure::PollLimit, "poll_limit"),
2249            (
2250                Kind::CleanupUnverified,
2251                Failure::CleanupUnverified,
2252                "cleanup_unverified",
2253            ),
2254            (
2255                Kind::InternalInvariant,
2256                Failure::InternalInvariant,
2257                "internal_invariant",
2258            ),
2259            (
2260                Kind::InvalidLimits,
2261                Failure::LocalRejected,
2262                "local_rejected",
2263            ),
2264            (
2265                Kind::InvalidProviderOrigin,
2266                Failure::LocalRejected,
2267                "local_rejected",
2268            ),
2269            (
2270                Kind::ProviderTargetOriginOverlap,
2271                Failure::LocalRejected,
2272                "local_rejected",
2273            ),
2274            (
2275                Kind::AuthorityAlreadyMinted,
2276                Failure::LocalRejected,
2277                "local_rejected",
2278            ),
2279            (
2280                Kind::OperationNotPermitted,
2281                Failure::LocalRejected,
2282                "local_rejected",
2283            ),
2284            (
2285                Kind::InvalidLifecycle,
2286                Failure::LocalRejected,
2287                "local_rejected",
2288            ),
2289            (
2290                Kind::RegistrationLimit,
2291                Failure::LocalRejected,
2292                "local_rejected",
2293            ),
2294            (
2295                Kind::CallbackLimit,
2296                Failure::LocalRejected,
2297                "local_rejected",
2298            ),
2299        ] {
2300            let actual = SsrfOastProviderFailure::from(kind);
2301            assert_eq!(actual, expected);
2302            assert_eq!(serde_json::to_value(actual).unwrap(), wire);
2303        }
2304    }
2305
2306    #[test]
2307    fn client_failure_classes_reach_the_serializable_audit_without_response_data() {
2308        use crate::native_oast_provider::provider_client_failure_kind;
2309        use termivar_oast::{
2310            NativeOastBoundaryRejection, NativeOastClientErrorKind as Client,
2311            NativeOastHttpFailure as Http,
2312        };
2313        use SsrfOastProviderFailure as Failure;
2314        use SsrfOastTerminalState as Terminal;
2315
2316        let target = url::Url::parse("http://127.0.0.1:41001/").unwrap();
2317        let provider =
2318            PublicOrigin::from_test_loopback("127.0.0.1:41002".parse().unwrap()).unwrap();
2319        let policy = SsrfOastReviewPolicy::for_loopback(target, provider, 1, 250, 5_000).unwrap();
2320        // Independent expectations include every status class and the unknown
2321        // fallback. No test constructs a request or reads response prose.
2322        for (input, http, failure, terminal, wire) in [
2323            (
2324                Client::UnexpectedStatus,
2325                Some(Http::AccessRejected),
2326                Failure::AccessRejected,
2327                Terminal::ProviderAuthenticationFailed,
2328                "access_rejected",
2329            ),
2330            (
2331                Client::UnexpectedStatus,
2332                Some(Http::Throttled),
2333                Failure::Throttled,
2334                Terminal::Incomplete,
2335                "throttled",
2336            ),
2337            (
2338                Client::UnexpectedStatus,
2339                Some(Http::NotFound),
2340                Failure::NotFound,
2341                Terminal::Incomplete,
2342                "not_found",
2343            ),
2344            (
2345                Client::UnexpectedStatus,
2346                Some(Http::Gone),
2347                Failure::Gone,
2348                Terminal::Incomplete,
2349                "gone",
2350            ),
2351            (
2352                Client::UnexpectedStatus,
2353                Some(Http::RedirectRefused),
2354                Failure::RedirectRefused,
2355                Terminal::Incomplete,
2356                "redirect_refused",
2357            ),
2358            (
2359                Client::UnexpectedStatus,
2360                Some(Http::ServerFailure),
2361                Failure::ServerFailure,
2362                Terminal::Incomplete,
2363                "server_failure",
2364            ),
2365            (
2366                Client::UnexpectedStatus,
2367                Some(Http::Unexpected),
2368                Failure::UnexpectedStatus,
2369                Terminal::Incomplete,
2370                "unexpected_status",
2371            ),
2372            (
2373                Client::UnexpectedStatus,
2374                None,
2375                Failure::UnexpectedStatus,
2376                Terminal::Incomplete,
2377                "unexpected_status",
2378            ),
2379            (
2380                Client::TransportFailure,
2381                None,
2382                Failure::TransportFailure,
2383                Terminal::Incomplete,
2384                "transport_failure",
2385            ),
2386            (
2387                Client::RequestConstruction,
2388                None,
2389                Failure::RequestConstruction,
2390                Terminal::Incomplete,
2391                "request_construction",
2392            ),
2393            (
2394                Client::ClientInitialization,
2395                None,
2396                Failure::ClientInitialization,
2397                Terminal::Incomplete,
2398                "client_initialization",
2399            ),
2400            (
2401                Client::Cancelled,
2402                None,
2403                Failure::Cancelled,
2404                Terminal::Cancelled,
2405                "cancelled",
2406            ),
2407            (
2408                Client::DeadlineExceeded,
2409                None,
2410                Failure::Timeout,
2411                Terminal::Expired,
2412                "timeout",
2413            ),
2414            (
2415                Client::MalformedResponse,
2416                None,
2417                Failure::MalformedResponse,
2418                Terminal::MalformedProviderResponse,
2419                "malformed_response",
2420            ),
2421            (
2422                Client::ProtocolMismatch,
2423                None,
2424                Failure::MalformedResponse,
2425                Terminal::MalformedProviderResponse,
2426                "malformed_response",
2427            ),
2428            (
2429                Client::UnsupportedMedia,
2430                None,
2431                Failure::MalformedResponse,
2432                Terminal::MalformedProviderResponse,
2433                "malformed_response",
2434            ),
2435            (
2436                Client::ResponseOriginMismatch,
2437                None,
2438                Failure::MalformedResponse,
2439                Terminal::MalformedProviderResponse,
2440                "malformed_response",
2441            ),
2442            (
2443                Client::AccountingInvariant,
2444                None,
2445                Failure::MalformedResponse,
2446                Terminal::MalformedProviderResponse,
2447                "malformed_response",
2448            ),
2449            (
2450                Client::CallbackTargetMismatch,
2451                None,
2452                Failure::MalformedResponse,
2453                Terminal::MalformedProviderResponse,
2454                "malformed_response",
2455            ),
2456            (
2457                Client::ResponseTooLarge,
2458                None,
2459                Failure::BudgetRejected,
2460                Terminal::BudgetExhausted,
2461                "budget_rejected",
2462            ),
2463            (
2464                Client::BoundaryRejected(NativeOastBoundaryRejection::OperationNotPermitted),
2465                None,
2466                Failure::LocalRejected,
2467                Terminal::Incomplete,
2468                "local_rejected",
2469            ),
2470        ] {
2471            let provider_kind = provider_client_failure_kind(input, http);
2472            assert_eq!(provider_terminal(provider_kind), terminal);
2473            let mut state = SsrfOastExecutionState::default();
2474            state.retain_provider_failures(Some(provider_kind), None);
2475            let audit = audit(
2476                &policy,
2477                &state,
2478                SsrfOastRuntimeOutcome::Incomplete,
2479                0,
2480                false,
2481            );
2482            assert_eq!(audit.provider_failure(), Some(failure));
2483            assert_eq!(audit.cleanup_failure(), None);
2484            assert_eq!(audit.provider_request_count(), 0);
2485            assert!(!audit.item_projected());
2486            let serialized = serde_json::to_value(audit).unwrap();
2487            assert_eq!(serialized["provider_failure"], wire);
2488            assert!(serialized.get("cleanup_failure").is_none());
2489        }
2490    }
2491
2492    #[test]
2493    fn provider_diagnostics_preserve_first_error_and_separate_cleanup_without_facts() {
2494        let target = url::Url::parse("http://127.0.0.1:41001/").unwrap();
2495        let provider =
2496            PublicOrigin::from_test_loopback("127.0.0.1:41002".parse().unwrap()).unwrap();
2497        let policy = SsrfOastReviewPolicy::for_loopback(target, provider, 1, 250, 5_000).unwrap();
2498        let mut state = SsrfOastExecutionState::default();
2499        state.retain_provider_failures(
2500            Some(NativeOastProviderErrorKind::ProviderServerFailure),
2501            None,
2502        );
2503        state.retain_provider_failures(
2504            Some(NativeOastProviderErrorKind::ProviderTransportFailure),
2505            Some(NativeOastProviderErrorKind::ProviderAccessRejected),
2506        );
2507        state.retain_provider_failures(None, None);
2508        let primary = audit(
2509            &policy,
2510            &state,
2511            SsrfOastRuntimeOutcome::Incomplete,
2512            0,
2513            false,
2514        );
2515        assert_eq!(
2516            primary.provider_failure(),
2517            Some(SsrfOastProviderFailure::ServerFailure)
2518        );
2519        assert_eq!(
2520            primary.cleanup_failure(),
2521            Some(SsrfOastProviderFailure::AccessRejected)
2522        );
2523        assert_eq!(primary.outcome(), SsrfOastRuntimeOutcome::Incomplete);
2524        assert!(!primary.item_projected());
2525        let serialized = serde_json::to_value(&primary).unwrap();
2526        assert_eq!(serialized["provider_failure"], "server_failure");
2527        assert_eq!(serialized["cleanup_failure"], "access_rejected");
2528
2529        let mut state = SsrfOastExecutionState::default();
2530        state.retain_provider_failures(None, Some(NativeOastProviderErrorKind::Cancelled));
2531        let cleanup = audit(
2532            &policy,
2533            &state,
2534            SsrfOastRuntimeOutcome::Incomplete,
2535            0,
2536            false,
2537        );
2538        assert_eq!(cleanup.provider_failure(), None);
2539        assert_eq!(
2540            cleanup.cleanup_failure(),
2541            Some(SsrfOastProviderFailure::Cancelled)
2542        );
2543        assert_eq!(cleanup.provider_request_count(), 0);
2544        assert!(!cleanup.cleanup_verified());
2545        let serialized = serde_json::to_value(&cleanup).unwrap();
2546        assert!(serialized.get("provider_failure").is_none());
2547        assert_eq!(serialized["cleanup_failure"], "cancelled");
2548    }
2549
2550    #[test]
2551    fn every_domain_outcome_has_one_closed_runtime_projection() {
2552        use SsrfOastReviewOutcome as Domain;
2553        use SsrfOastRuntimeOutcome as Runtime;
2554
2555        for (domain, runtime) in [
2556            (Domain::NotEligible, Runtime::NotEligible),
2557            (Domain::ControlIncomplete, Runtime::ControlIncomplete),
2558            (
2559                Domain::RegistrationIncomplete,
2560                Runtime::RegistrationIncomplete,
2561            ),
2562            (Domain::AllocationIncomplete, Runtime::AllocationIncomplete),
2563            (
2564                Domain::PreflightContaminated,
2565                Runtime::PreflightContaminated,
2566            ),
2567            (Domain::TargetNotDispatched, Runtime::TargetNotDispatched),
2568            (Domain::NoCallback, Runtime::NoCallback),
2569            (Domain::CandidateOnly, Runtime::CandidateOnly),
2570            (Domain::ReplayOnly, Runtime::ReplayOnly),
2571            (Domain::WrongCallback, Runtime::WrongCallback),
2572            (
2573                Domain::EventIdentityConflict,
2574                Runtime::EventIdentityConflict,
2575            ),
2576            (Domain::CorrelationMismatch, Runtime::CorrelationMismatch),
2577            (Domain::DuplicateOnly, Runtime::DuplicateOnly),
2578            (Domain::CleanupIncomplete, Runtime::CleanupIncomplete),
2579            (
2580                Domain::DefensiveInterference,
2581                Runtime::DefensiveInterference,
2582            ),
2583            (Domain::RateLimited, Runtime::RateLimited),
2584            (
2585                Domain::ProviderAuthenticationFailed,
2586                Runtime::ProviderAuthenticationFailed,
2587            ),
2588            (
2589                Domain::MalformedProviderResponse,
2590                Runtime::MalformedProviderResponse,
2591            ),
2592            (Domain::PollExhausted, Runtime::PollExhausted),
2593            (Domain::Expired, Runtime::Expired),
2594            (Domain::Cancelled, Runtime::Cancelled),
2595            (Domain::BudgetExhausted, Runtime::BudgetExhausted),
2596            (Domain::Truncated, Runtime::Truncated),
2597            (Domain::Incomplete, Runtime::Incomplete),
2598            (
2599                Domain::RepeatedCallbacksObserved,
2600                Runtime::RepeatedCallbacksObserved,
2601            ),
2602        ] {
2603            assert_eq!(SsrfOastRuntimeOutcome::from(domain), runtime);
2604        }
2605    }
2606
2607    #[test]
2608    fn polling_phase_requires_its_expected_callback_set() {
2609        assert!(!SsrfOastPollPhase::Candidate.expected_event_observed(false, false));
2610        assert!(!SsrfOastPollPhase::Candidate.expected_event_observed(false, true));
2611        assert!(SsrfOastPollPhase::Candidate.expected_event_observed(true, false));
2612        assert!(SsrfOastPollPhase::Candidate.expected_event_observed(true, true));
2613
2614        assert!(!SsrfOastPollPhase::Replay.expected_event_observed(false, false));
2615        assert!(!SsrfOastPollPhase::Replay.expected_event_observed(true, false));
2616        assert!(!SsrfOastPollPhase::Replay.expected_event_observed(false, true));
2617        assert!(SsrfOastPollPhase::Replay.expected_event_observed(true, true));
2618    }
2619
2620    #[test]
2621    fn lifecycle_gate_requires_clean_preflight_and_stops_on_non_timeout_terminal() {
2622        let candidate: CallbackId = "AQEBAQEBAQEBAQEBAQEBAQ".parse().unwrap();
2623        let replay: CallbackId = "AgICAgICAgICAgICAgICAg".parse().unwrap();
2624        let mut facts = SsrfOastReviewFacts::new(&candidate, &replay);
2625        facts.control_complete = true;
2626        facts.provider_registered = true;
2627        facts.allocations_complete = true;
2628        facts.preflight_clean = true;
2629        let lifecycle = SsrfOastLifecycleEvidence::from_facts(&facts, false);
2630        assert!(lifecycle.candidate_ready());
2631        assert!(polling_may_continue(&facts));
2632
2633        facts.terminal = Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
2634        assert!(polling_may_continue(&facts));
2635        facts.terminal = Some(SsrfOastTerminalState::Cancelled);
2636        assert!(!polling_may_continue(&facts));
2637
2638        facts.terminal = None;
2639        facts.preflight_clean = false;
2640        assert!(!SsrfOastLifecycleEvidence::from_facts(&facts, false).candidate_ready());
2641        assert!(!SsrfOastLifecycleEvidence::terminal().candidate_ready());
2642    }
2643
2644    #[test]
2645    fn post_dispatch_timeout_preserves_callback_eligibility_but_pre_dispatch_failure_does_not() {
2646        use crate::oast::OastEventKey;
2647
2648        assert!(target_dispatch_outcome_is_accounted(
2649            TransportDispatchOutcome::RequestTimeout
2650        ));
2651        assert!(!target_dispatch_outcome_is_accounted(
2652            TransportDispatchOutcome::TransportFailure
2653        ));
2654
2655        let candidate: CallbackId = "AQEBAQEBAQEBAQEBAQEBAQ".parse().unwrap();
2656        let replay: CallbackId = "AgICAgICAgICAgICAgICAg".parse().unwrap();
2657        let mut facts = SsrfOastReviewFacts::new(&candidate, &replay);
2658        facts.control_complete = true;
2659        facts.provider_registered = true;
2660        facts.allocations_complete = true;
2661        facts.preflight_clean = true;
2662        facts.candidate_dispatched = true;
2663        facts.replay_dispatched = true;
2664        facts.candidate_event = Some(SsrfOastObservedEvent::from_reduced(
2665            &candidate,
2666            &OastEventKey::new([3; 32]).unwrap(),
2667        ));
2668        facts.replay_event = Some(SsrfOastObservedEvent::from_reduced(
2669            &replay,
2670            &OastEventKey::new([4; 32]).unwrap(),
2671        ));
2672        facts.correlations_distinct = true;
2673        facts.same_correlation_scope = true;
2674        facts.cleanup_verified = true;
2675        facts.target_accounting_complete = [
2676            TransportDispatchOutcome::Completed,
2677            TransportDispatchOutcome::RequestTimeout,
2678            TransportDispatchOutcome::Completed,
2679        ]
2680        .into_iter()
2681        .all(target_dispatch_outcome_is_accounted);
2682        facts.provider_accounting_complete = true;
2683        facts.terminal = Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
2684        assert_eq!(
2685            evaluate_ssrf_oast_review(&facts).unwrap(),
2686            SsrfOastReviewOutcome::RepeatedCallbacksObserved
2687        );
2688
2689        facts.terminal = None;
2690        facts.replay_dispatched = false;
2691        facts.target_accounting_complete =
2692            target_dispatch_outcome_is_accounted(TransportDispatchOutcome::TransportFailure);
2693        assert_eq!(
2694            evaluate_ssrf_oast_review(&facts).unwrap(),
2695            SsrfOastReviewOutcome::TargetNotDispatched
2696        );
2697    }
2698
2699    #[test]
2700    fn audit_is_raw_free_and_uses_exact_bounded_counts() {
2701        let target =
2702            url::Url::parse("http://127.0.0.1:41001/proxy?url=https://seed.invalid/").unwrap();
2703        let provider =
2704            PublicOrigin::from_test_loopback("127.0.0.1:41002".parse().unwrap()).unwrap();
2705        let policy = SsrfOastReviewPolicy::for_loopback(target, provider, 1, 250, 5_000).unwrap();
2706        let candidate: CallbackId = "AQEBAQEBAQEBAQEBAQEBAQ".parse().unwrap();
2707        let replay: CallbackId = "AgICAgICAgICAgICAgICAg".parse().unwrap();
2708        let mut facts = SsrfOastReviewFacts::new(&candidate, &replay);
2709        facts.preflight_clean = true;
2710        facts.cleanup_verified = true;
2711        let state = SsrfOastExecutionState {
2712            facts: Some(facts),
2713            provider_request_count: 7,
2714            source: Some(SsrfOastCandidateSource::ObservedUrlQuery),
2715            cleanup_verified: true,
2716            ..SsrfOastExecutionState::default()
2717        };
2718        let audit = audit(
2719            &policy,
2720            &state,
2721            SsrfOastRuntimeOutcome::NoCallback,
2722            3,
2723            false,
2724        );
2725        assert_eq!(audit.outcome(), SsrfOastRuntimeOutcome::NoCallback);
2726        assert_eq!(audit.candidate_source(), Some("observed_url_query"));
2727        assert_eq!(audit.target_request_count(), 3);
2728        assert_eq!(audit.provider_request_count(), 7);
2729        assert_eq!(audit.provider_failure(), None);
2730        assert_eq!(audit.cleanup_failure(), None);
2731        let serialized = serde_json::to_value(&audit).unwrap();
2732        assert!(serialized.get("provider_failure").is_none());
2733        assert!(serialized.get("cleanup_failure").is_none());
2734        assert_eq!(audit.active_verification_count(), 1);
2735        assert!(audit.preflight_clean());
2736        assert!(audit.cleanup_verified());
2737        assert!(!audit.candidate_callback_observed());
2738        assert!(!audit.replay_callback_observed());
2739        assert!(!audit.item_projected());
2740        let rendered = format!("{audit:?}");
2741        assert!(!rendered.contains("seed.invalid"));
2742        assert!(!rendered.contains("127.0.0.1"));
2743    }
2744
2745    #[tokio::test]
2746    async fn loopback_target_reaches_two_distinct_callbacks_and_projects_one_item() {
2747        assert!(ADMIN_SECRET.len() >= MIN_SSRF_OAST_ADMIN_TOKEN_BYTES);
2748        let fixture = loopback_fixture().await;
2749        let policy = SsrfOastReviewPolicy::for_loopback(
2750            fixture.target.clone(),
2751            fixture.provider_origin.clone(),
2752            1,
2753            250,
2754            5_000,
2755        )
2756        .unwrap();
2757        let administrator = SsrfOastAdminToken::new(ADMIN_SECRET.to_vec()).unwrap();
2758        let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2759            .with_ssrf_oast_review(policy, administrator)
2760            .build()
2761            .unwrap();
2762        let report = runtime.analyze().await.unwrap();
2763
2764        let audit = report.ssrf_oast_review_audit().unwrap();
2765        assert_eq!(
2766            audit.outcome(),
2767            SsrfOastRuntimeOutcome::RepeatedCallbacksObserved
2768        );
2769        assert_eq!(audit.target_request_count(), 3);
2770        assert_eq!(audit.active_verification_count(), 1);
2771        assert!(audit.preflight_clean());
2772        assert!(audit.candidate_callback_observed());
2773        assert!(audit.replay_callback_observed());
2774        assert!(audit.cleanup_verified());
2775        assert!(audit.item_projected());
2776        assert!(audit.provider_request_count() <= 12);
2777        let item = report
2778            .assessment_items()
2779            .iter()
2780            .find(|item| item.capability_id() == SSRF_OAST_REVIEW_CAPABILITY_ID)
2781            .unwrap();
2782        assert_eq!(item.disposition(), AssessmentDisposition::NeedsReview);
2783        assert_eq!(report.usage().active_verifications(), 1);
2784
2785        let requests = fixture.target_requests.lock().await;
2786        assert_eq!(requests.len(), 4);
2787        assert!(requests.iter().all(|request| request.starts_with("GET ")));
2788        assert_eq!(
2789            requests
2790                .iter()
2791                .filter(|request| request.contains('?'))
2792                .count(),
2793            3
2794        );
2795        assert_eq!(fixture.callback_attempts.load(Ordering::SeqCst), 2);
2796        let debug = format!("{report:?}");
2797        assert!(!debug.contains(std::str::from_utf8(ADMIN_SECRET).unwrap()));
2798        assert!(!debug.contains("seed.example.invalid"));
2799    }
2800
2801    #[tokio::test]
2802    async fn polling_waits_for_the_expected_replay_event_after_an_empty_first_poll() {
2803        let fixture =
2804            loopback_fixture_with_replay_barrier(Some(ProviderTestPollBarrier::new())).await;
2805        let policy = SsrfOastReviewPolicy::for_loopback(
2806            fixture.target.clone(),
2807            fixture.provider_origin.clone(),
2808            3,
2809            250,
2810            5_000,
2811        )
2812        .unwrap();
2813        let administrator = SsrfOastAdminToken::new(ADMIN_SECRET.to_vec()).unwrap();
2814        let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2815            .with_ssrf_oast_review(policy, administrator)
2816            .build()
2817            .unwrap();
2818
2819        let report = runtime.analyze().await.unwrap();
2820        let audit = report.ssrf_oast_review_audit().unwrap();
2821        assert_eq!(
2822            audit.outcome(),
2823            SsrfOastRuntimeOutcome::RepeatedCallbacksObserved
2824        );
2825        assert!(audit.candidate_callback_observed());
2826        assert!(audit.replay_callback_observed());
2827        assert!(audit.cleanup_verified());
2828        assert!(audit.item_projected());
2829        assert_eq!(audit.provider_request_count(), 8);
2830        assert_eq!(audit.target_request_count(), 3);
2831        let mut matching_items = report
2832            .assessment_items()
2833            .iter()
2834            .filter(|item| item.capability_id() == SSRF_OAST_REVIEW_CAPABILITY_ID);
2835        let matching_item = matching_items.next().unwrap();
2836        assert!(matching_items.next().is_none());
2837        assert_eq!(
2838            matching_item.disposition(),
2839            AssessmentDisposition::NeedsReview
2840        );
2841        assert_eq!(fixture.target_requests.lock().await.len(), 4);
2842        assert_eq!(fixture.callback_attempts.load(Ordering::SeqCst), 2);
2843    }
2844
2845    #[tokio::test]
2846    async fn insufficient_parent_budget_stops_after_control_before_provider_dispatch() {
2847        let fixture = loopback_fixture().await;
2848        let policy = SsrfOastReviewPolicy::for_loopback(
2849            fixture.target.clone(),
2850            fixture.provider_origin.clone(),
2851            1,
2852            250,
2853            5_000,
2854        )
2855        .unwrap();
2856        let administrator = SsrfOastAdminToken::new(ADMIN_SECRET.to_vec()).unwrap();
2857        let limits = WebAssessmentLimits::default()
2858            .with_max_total_requests(3)
2859            .unwrap();
2860        let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2861            .limits(limits)
2862            .with_ssrf_oast_review(policy, administrator)
2863            .build()
2864            .unwrap();
2865
2866        let report = runtime.analyze().await.unwrap();
2867        let audit = report.ssrf_oast_review_audit().unwrap();
2868        assert_eq!(audit.outcome(), SsrfOastRuntimeOutcome::Incomplete);
2869        assert_eq!(audit.target_request_count(), 1);
2870        assert_eq!(audit.provider_request_count(), 0);
2871        assert_eq!(audit.active_verification_count(), 0);
2872        assert!(!audit.cleanup_verified());
2873        assert!(!audit.item_projected());
2874        assert_eq!(fixture.target_requests.lock().await.len(), 2);
2875    }
2876
2877    #[tokio::test]
2878    async fn rejected_provider_registration_is_metered_and_never_reaches_active_dispatch() {
2879        let fixture = loopback_fixture().await;
2880        let policy = SsrfOastReviewPolicy::for_loopback(
2881            fixture.target.clone(),
2882            fixture.provider_origin.clone(),
2883            1,
2884            250,
2885            5_000,
2886        )
2887        .unwrap();
2888        let wrong_administrator =
2889            SsrfOastAdminToken::new(b"WRONG-OAST-ADMIN-CREDENTIAL-DOES-NOT-MATCH-55D1".to_vec())
2890                .unwrap();
2891        let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2892            .with_ssrf_oast_review(policy, wrong_administrator)
2893            .build()
2894            .unwrap();
2895
2896        let report = runtime.analyze().await.unwrap();
2897        let audit = report.ssrf_oast_review_audit().unwrap();
2898        assert_eq!(audit.outcome(), SsrfOastRuntimeOutcome::Incomplete);
2899        assert_eq!(audit.target_request_count(), 1);
2900        assert_eq!(audit.provider_request_count(), 1);
2901        assert_eq!(audit.active_verification_count(), 0);
2902        assert!(!audit.cleanup_verified());
2903        assert!(!audit.item_projected());
2904        assert_eq!(fixture.target_requests.lock().await.len(), 2);
2905    }
2906
2907    #[tokio::test]
2908    async fn second_allocation_rejection_cleans_up_and_never_reaches_active_dispatch() {
2909        let fixture = loopback_fixture().await;
2910        let (provider_origin, provider_task) = provider_rejecting_second_allocation().await;
2911        let policy = SsrfOastReviewPolicy::for_loopback(
2912            fixture.target.clone(),
2913            provider_origin,
2914            1,
2915            250,
2916            5_000,
2917        )
2918        .unwrap();
2919        let administrator = SsrfOastAdminToken::new(ADMIN_SECRET.to_vec()).unwrap();
2920        let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2921            .with_ssrf_oast_review(policy, administrator)
2922            .build()
2923            .unwrap();
2924
2925        let report = runtime.analyze().await.unwrap();
2926        let audit = report.ssrf_oast_review_audit().unwrap();
2927        assert_eq!(audit.outcome(), SsrfOastRuntimeOutcome::Incomplete);
2928        assert_eq!(audit.target_request_count(), 1);
2929        assert_eq!(audit.provider_request_count(), 4);
2930        assert_eq!(audit.active_verification_count(), 0);
2931        assert!(audit.cleanup_verified());
2932        assert!(!audit.item_projected());
2933        assert_eq!(fixture.target_requests.lock().await.len(), 2);
2934        provider_task.await.unwrap();
2935    }
2936}