Skip to main content

termivar_scanner/phases/
phase8_lfi_xxe.rs

1//! # Phase 8: opt-in local-file canary verification
2//!
3//! There are no default file-system probes. A host may opt in by placing a
4//! benign, scan-specific canary file on an authorized fixture and configuring
5//! two independent version-four UUIDs: one identifies the file name and the
6//! other identifies its expected contents. The contents must be absent from a
7//! baseline and a randomized missing-file control, then present in two exact
8//! canary replays.
9//!
10//! XXE remains quarantined. The shared legacy authority currently supports
11//! only bodyless discovery requests and has no trusted callback receipt
12//! provider. Sending an XML or OOB probe would therefore be only a dispatch
13//! receipt, not evidence sufficient for an XXE claim, and this phase does
14//! neither.
15
16use std::fmt;
17
18use async_trait::async_trait;
19use termivar_core::{
20    ConfidenceScore, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue, Hypothesis,
21    KnowledgePredicate, OutcomeStatus, Probability, VerificationStage,
22};
23use url::Url;
24use uuid::{Uuid, Version};
25
26use crate::{
27    context::ScanContext,
28    contracts::{ScanFinding, ScanPhase},
29    error::ScannerError,
30    http_evidence::HttpProbeMethod,
31    legacy_discovery::BoundedHttpResponse,
32    rules::{Expression, KnowledgeLayer},
33    verification::{ActiveVerifier, VerificationCase, VerificationReport, VerificationRule},
34};
35
36const LFI_CANARY_ACTION_ID: &str = "legacy.verification.local-file-canary";
37const LFI_FILE_PREFIX: &str = "venom-lfi-canary-";
38const LFI_CONTROL_PREFIX: &str = "venom-lfi-missing-control-";
39const LFI_MARKER_PREFIX: &str = "VENOM_LFI_CANARY_CONTENT_";
40const LFI_EVIDENCE_NAMESPACE: &str = "legacy.lfi-canary";
41const LFI_EVIDENCE_NAME: &str = "bounded-four-leg-observation";
42const LFI_AUDIT_NAMESPACE: &str = "legacy.audit";
43const LFI_AUDIT_NAME: &str = "manual-review-anchor";
44const LFI_EVIDENCE_ID: &str = "legacy.lfi-canary.evidence";
45const LFI_CASE_ID: &str = "legacy.lfi-canary.case";
46const LFI_HYPOTHESIS_ID: &str = "legacy.lfi-canary.audit-anchor";
47const LFI_RULE_ID: &str = "legacy.verify.lfi-canary.needs-review";
48const LFI_RULE_RATIONALE: &str =
49    "A bounded host-provisioned canary comparison produced a reproducible manual-review observation";
50const MAX_LFI_CANARY_REQUESTS: usize = 16;
51const LFI_REQUESTS_PER_PARAMETER: usize = 4;
52
53#[derive(Clone, PartialEq, Eq)]
54struct LfiCanary {
55    file_name: String,
56    expected_marker: String,
57}
58
59impl LfiCanary {
60    fn new(file_id: Uuid, content_id: Uuid) -> Result<Self, ScannerError> {
61        if file_id == content_id || !is_random_uuid(file_id) || !is_random_uuid(content_id) {
62            return Err(ScannerError::PayloadGenerationError(
63                "LFI canary requires two distinct version-four UUIDs".to_owned(),
64            ));
65        }
66        Ok(Self {
67            file_name: format!("{LFI_FILE_PREFIX}{}.txt", file_id.simple()),
68            expected_marker: format!("{LFI_MARKER_PREFIX}{}", content_id.simple()),
69        })
70    }
71}
72
73fn is_random_uuid(value: Uuid) -> bool {
74    value.get_version() == Some(Version::Random) && !value.is_nil()
75}
76
77/// Legacy phase-eight observer with risky probes disabled by default.
78#[derive(Clone)]
79pub struct LfiXxeScanner {
80    // Retained for pre-1.0 source compatibility. A destination without a
81    // trusted nonce-correlated callback provider grants no verification
82    // authority and is never contacted.
83    oob_domain: Option<String>,
84    lfi_canary: Option<LfiCanary>,
85}
86
87impl fmt::Debug for LfiXxeScanner {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        formatter
90            .debug_struct("LfiXxeScanner")
91            .field("oob_domain_configured", &self.oob_domain.is_some())
92            .field("lfi_canary_configured", &self.lfi_canary.is_some())
93            .finish()
94    }
95}
96
97impl LfiXxeScanner {
98    /// Creates the quarantined default: no LFI or XXE requests are sent.
99    pub const fn new() -> Self {
100        Self {
101            oob_domain: None,
102            lfi_canary: None,
103        }
104    }
105
106    /// Retains the historical configuration shape without treating a
107    /// destination string as callback proof.
108    ///
109    /// No callback is contacted and no XXE request is dispatched. A future
110    /// callback-capable API must accept a trusted verifier that returns a
111    /// nonce-correlated receipt before it can support an OOB conclusion.
112    pub fn with_oob_domain(oob_domain: String) -> Self {
113        Self {
114            oob_domain: Some(oob_domain),
115            lfi_canary: None,
116        }
117    }
118
119    /// Enables verification with a benign host-provisioned canary.
120    ///
121    /// The authorized fixture must expose the file through the tested query
122    /// parameter while the file body contains exactly
123    /// `VENOM_LFI_CANARY_CONTENT_<content UUID without hyphens>`. The UUIDs
124    /// must be distinct version-four values so neither the requested name nor
125    /// ordinary baseline text can predict the file-specific marker.
126    pub fn with_lfi_canary(file_id: Uuid, content_id: Uuid) -> Result<Self, ScannerError> {
127        Ok(Self {
128            oob_domain: None,
129            lfi_canary: Some(LfiCanary::new(file_id, content_id)?),
130        })
131    }
132}
133
134impl Default for LfiXxeScanner {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140#[async_trait]
141impl ScanPhase for LfiXxeScanner {
142    fn phase_number(&self) -> u8 {
143        8
144    }
145
146    fn name(&self) -> &'static str {
147        "File Canary Observer"
148    }
149
150    async fn execute(&self, ctx: &ScanContext) -> Result<Vec<ScanFinding>, ScannerError> {
151        if self.oob_domain.is_some() {
152            ctx.log(
153                "Phase 8: XXE OOB configuration is quarantined because no trusted nonce-correlated callback verifier is configured"
154                    .to_owned(),
155            );
156        }
157        let Some(canary) = &self.lfi_canary else {
158            ctx.log(
159                "Phase 8: no benign local-file canary configured; no LFI or XXE probes dispatched"
160                    .to_owned(),
161            );
162            return Ok(Vec::new());
163        };
164
165        ctx.log("Phase 8: bounded local-file canary verification initiated".to_owned());
166        let snapshot = ctx.discovery_snapshot();
167        let mut accepted = Vec::new();
168        let mut requests = 0_usize;
169
170        'endpoints: for (endpoint, parameters) in snapshot.endpoints() {
171            let endpoint = ctx.canonicalize_discovery_url(&Url::parse(endpoint)?)?;
172            for parameter in parameters {
173                if MAX_LFI_CANARY_REQUESTS.saturating_sub(requests) < LFI_REQUESTS_PER_PARAMETER {
174                    break 'endpoints;
175                }
176                if observe_lfi_canary(ctx, &endpoint, parameter, canary).await? {
177                    accepted.push((endpoint.clone(), parameter.clone()));
178                }
179                requests += LFI_REQUESTS_PER_PARAMETER;
180            }
181        }
182
183        // Recheck cancellation and the monotonic verification deadline after
184        // the complete probe set and before the first knowledge/report write.
185        ctx.ensure_legacy_verification_commit(LFI_CANARY_ACTION_ID)?;
186        let reports = lfi_manual_review_reports(ctx, accepted.len())?;
187        ctx.record_legacy_verification_reports(reports)?;
188        let findings = accepted
189            .into_iter()
190            .map(|(endpoint, parameter)| ScanFinding {
191                phase: self.phase_number(),
192                module_name: self.name().to_owned(),
193                severity: "INFO".to_owned(),
194                description: format!(
195                    "A bounded four-leg comparison for parameter '{parameter}' reproduced the independently marked contents of a host-provisioned local-file canary. This observation requires manual review and does not establish LFI."
196                ),
197                evidence: format!(
198                    "endpoint={}; benign_canary_observed=true; randomized_missing_file_control=true; exact_replay=true; response_truncated=false",
199                    endpoint_subject(&endpoint)
200                ),
201            })
202            .collect::<Vec<_>>();
203
204        ctx.log(format!(
205            "Phase 8: local-file canary verification completed with {} INFO observations across {} requests; XXE remained quarantined",
206            findings.len(), requests
207        ));
208        Ok(findings)
209    }
210}
211
212async fn observe_lfi_canary(
213    ctx: &ScanContext,
214    endpoint: &Url,
215    parameter: &str,
216    canary: &LfiCanary,
217) -> Result<bool, ScannerError> {
218    let control_file = format!("{LFI_CONTROL_PREFIX}{}.txt", Uuid::new_v4().simple());
219    let baseline = ctx
220        .verification_request(LFI_CANARY_ACTION_ID, HttpProbeMethod::Get, endpoint.clone())
221        .await?;
222    let control = ctx
223        .verification_request(
224            LFI_CANARY_ACTION_ID,
225            HttpProbeMethod::Get,
226            replace_query_parameter(endpoint, parameter, &control_file),
227        )
228        .await?;
229    let candidate_url = replace_query_parameter(endpoint, parameter, &canary.file_name);
230    let candidate = ctx
231        .verification_request(
232            LFI_CANARY_ACTION_ID,
233            HttpProbeMethod::Get,
234            candidate_url.clone(),
235        )
236        .await?;
237    let reproduction = ctx
238        .verification_request(LFI_CANARY_ACTION_ID, HttpProbeMethod::Get, candidate_url)
239        .await?;
240
241    Ok(file_canary_observed(
242        &baseline,
243        &control,
244        &candidate,
245        &reproduction,
246        &canary.expected_marker,
247    ))
248}
249
250fn file_canary_observed(
251    baseline: &BoundedHttpResponse,
252    control: &BoundedHttpResponse,
253    candidate: &BoundedHttpResponse,
254    reproduction: &BoundedHttpResponse,
255    marker: &str,
256) -> bool {
257    ![baseline, control, candidate, reproduction]
258        .into_iter()
259        .any(|response| response.body_truncated() || !(200..300).contains(&response.status()))
260        && !body_contains(baseline.body(), marker)
261        && !body_contains(control.body(), marker)
262        && !body_contains(baseline.body(), LFI_MARKER_PREFIX)
263        && !body_contains(control.body(), LFI_MARKER_PREFIX)
264        && body_contains(candidate.body(), marker)
265        && body_contains(reproduction.body(), marker)
266        && candidate.status() == reproduction.status()
267        && candidate.content_type() == reproduction.content_type()
268        && candidate.body() == reproduction.body()
269}
270
271fn lfi_manual_review_reports(
272    ctx: &ScanContext,
273    accepted_signals: usize,
274) -> Result<Vec<VerificationReport>, ScannerError> {
275    if accepted_signals == 0 {
276        return Ok(Vec::new());
277    }
278
279    let subject = ctx.legacy_verification_subject()?;
280    let evidence_predicate = lfi_evidence_predicate()?;
281    let audit_predicate = KnowledgePredicate::new(LFI_AUDIT_NAMESPACE, LFI_AUDIT_NAME)
282        .map_err(|_| legacy_report_error())?;
283    let hypothesis = Hypothesis::with_id(
284        LFI_HYPOTHESIS_ID,
285        subject.clone(),
286        audit_predicate,
287        EvidenceValue::Text("local-file-canary-manual-review".to_owned()),
288        Probability::from_percent(50).map_err(|_| legacy_report_error())?,
289    )
290    .map_err(|_| legacy_report_error())?;
291    ctx.knowledge()
292        .upsert_hypothesis(hypothesis)
293        .map_err(|_| legacy_report_error())?;
294    let baseline = ctx.knowledge().snapshot_for_subject(&subject);
295    let source = EvidenceSource::new(LFI_CANARY_ACTION_ID, "bounded-four-leg-comparison")
296        .and_then(|source| source.with_correlation_id(LFI_CASE_ID))
297        .map_err(|_| legacy_report_error())?;
298    let evidence = Evidence::with_id_at(
299        EvidenceId::parse(LFI_EVIDENCE_ID).map_err(|_| legacy_report_error())?,
300        subject.clone(),
301        EvidenceKind::Custom("legacy-verification".to_owned()),
302        evidence_predicate.clone(),
303        EvidenceValue::Boolean(true),
304        source,
305        ConfidenceScore::MAX,
306        0,
307    );
308    let write = ctx
309        .knowledge()
310        .insert_evidence(evidence)
311        .map_err(|_| legacy_report_error())?;
312    if write == crate::knowledge::KnowledgeWrite::Unchanged {
313        // The immutable signal was already recorded before this active stage,
314        // so it cannot be presented as fresh evidence for a second outcome or
315        // silently downgraded to an unresolved raw observation.
316        return Err(ScannerError::InvalidLegacyVerificationReport);
317    }
318    let after_probe = ctx.knowledge().snapshot_for_subject(&subject);
319    let verifier = lfi_active_verifier(evidence_predicate)?;
320    let case = VerificationCase::new(
321        LFI_CASE_ID,
322        subject,
323        LFI_CANARY_ACTION_ID,
324        LFI_HYPOTHESIS_ID,
325    )
326    .map_err(|_| legacy_report_error())?
327    .without_hypothesis_transition();
328    let report = verifier
329        .verify_snapshots(&case, &baseline, &after_probe)
330        .map_err(|_| legacy_report_error())?;
331    Ok(vec![report])
332}
333
334fn lfi_active_verifier(
335    evidence_predicate: KnowledgePredicate,
336) -> Result<ActiveVerifier, ScannerError> {
337    let mut verifier = ActiveVerifier::new();
338    let rule = VerificationRule::new(
339        LFI_RULE_ID,
340        VerificationStage::Active,
341        100,
342        Expression::equals(
343            KnowledgeLayer::Evidence,
344            evidence_predicate,
345            EvidenceValue::Boolean(true),
346        ),
347        OutcomeStatus::NeedsReview,
348        Probability::from_percent(95).map_err(|_| legacy_report_error())?,
349        LFI_RULE_RATIONALE,
350    )
351    .map_err(|_| legacy_report_error())?
352    .scoped_to_action(LFI_CANARY_ACTION_ID)
353    .map_err(|_| legacy_report_error())?
354    .with_case_correlated_evidence()
355    .map_err(|_| legacy_report_error())?;
356    verifier.register(rule).map_err(|_| legacy_report_error())?;
357    Ok(verifier)
358}
359
360fn lfi_evidence_predicate() -> Result<KnowledgePredicate, ScannerError> {
361    KnowledgePredicate::new(LFI_EVIDENCE_NAMESPACE, LFI_EVIDENCE_NAME)
362        .map_err(|_| legacy_report_error())
363}
364
365fn legacy_report_error() -> ScannerError {
366    ScannerError::InvalidLegacyVerificationReport
367}
368
369fn replace_query_parameter(endpoint: &Url, name: &str, value: &str) -> Url {
370    let retained = endpoint
371        .query_pairs()
372        .filter(|(existing, _)| existing != name)
373        .map(|(existing, value)| (existing.into_owned(), value.into_owned()))
374        .collect::<Vec<_>>();
375    let mut mutated = endpoint.clone();
376    mutated
377        .query_pairs_mut()
378        .clear()
379        .extend_pairs(retained)
380        .append_pair(name, value);
381    mutated
382}
383
384fn body_contains(body: &[u8], needle: &str) -> bool {
385    !needle.is_empty()
386        && body
387            .windows(needle.len())
388            .any(|window| window == needle.as_bytes())
389}
390
391fn endpoint_subject(endpoint: &Url) -> String {
392    let mut subject = endpoint.clone();
393    subject.set_query(None);
394    subject.set_fragment(None);
395    subject.to_string()
396}
397
398#[cfg(test)]
399mod tests {
400    use std::sync::{
401        atomic::{AtomicUsize, Ordering},
402        Arc,
403    };
404    use std::time::Duration;
405
406    use tokio::{
407        io::{AsyncReadExt, AsyncWriteExt},
408        net::TcpListener,
409        task::JoinHandle,
410    };
411
412    use super::*;
413    use crate::{legacy_discovery::VerificationLimits, runner::ScanRunner};
414    use termivar_core::{HypothesisState, HypothesisStrength, SecuritySeverity};
415
416    struct LocalFixture {
417        target: Url,
418        requests: Arc<AtomicUsize>,
419        task: JoinHandle<()>,
420    }
421
422    impl Drop for LocalFixture {
423        fn drop(&mut self) {
424            self.task.abort();
425        }
426    }
427
428    async fn serve_fixture(
429        max_requests: usize,
430        handler: impl Fn(&Url) -> String + Send + Sync + 'static,
431    ) -> LocalFixture {
432        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
433        let address = listener.local_addr().unwrap();
434        let requests = Arc::new(AtomicUsize::new(0));
435        let observed_requests = Arc::clone(&requests);
436        let handler = Arc::new(handler);
437        let task = tokio::spawn(async move {
438            for _ in 0..max_requests {
439                let (mut stream, _) = listener.accept().await.unwrap();
440                let mut request = Vec::new();
441                loop {
442                    let mut chunk = [0_u8; 1_024];
443                    let read = stream.read(&mut chunk).await.unwrap();
444                    if read == 0 {
445                        break;
446                    }
447                    request.extend_from_slice(&chunk[..read]);
448                    if request.windows(4).any(|window| window == b"\r\n\r\n") {
449                        break;
450                    }
451                    assert!(request.len() <= 16 * 1_024);
452                }
453                observed_requests.fetch_add(1, Ordering::SeqCst);
454                let request_target = String::from_utf8_lossy(&request)
455                    .lines()
456                    .next()
457                    .and_then(|line| line.split_ascii_whitespace().nth(1))
458                    .unwrap()
459                    .to_owned();
460                let url = Url::parse(&format!("http://fixture{request_target}")).unwrap();
461                let body = handler(&url);
462                let wire = format!(
463                    "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
464                    body.len(), body
465                );
466                stream.write_all(wire.as_bytes()).await.unwrap();
467                stream.shutdown().await.unwrap();
468            }
469        });
470
471        LocalFixture {
472            target: Url::parse(&format!("http://{address}/include?file=default.txt")).unwrap(),
473            requests,
474            task,
475        }
476    }
477
478    fn scan_context(target: Url) -> ScanContext {
479        let limits = VerificationLimits::new()
480            .with_max_requests(8)
481            .unwrap()
482            .with_request_timeout(Duration::from_secs(1))
483            .unwrap()
484            .with_max_wall_time(Duration::from_secs(10))
485            .unwrap()
486            .with_body_limits(64 * 1_024, 16 * 1_024)
487            .unwrap();
488        let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
489        ScanContext::new_with_verification_limits(target, reqwest::Client::new(), telemetry, limits)
490    }
491
492    fn last_parameter(url: &Url, name: &str) -> Option<String> {
493        url.query_pairs()
494            .filter(|(parameter, _)| parameter == name)
495            .map(|(_, value)| value.into_owned())
496            .last()
497    }
498
499    #[test]
500    fn phase_identity_and_quarantined_default_are_stable() {
501        let scanner = LfiXxeScanner::new();
502        assert_eq!(scanner.phase_number(), 8);
503        assert_eq!(scanner.name(), "File Canary Observer");
504        assert!(scanner.lfi_canary.is_none());
505        assert!(scanner.oob_domain.is_none());
506    }
507
508    #[test]
509    fn canary_requires_distinct_random_uuids() {
510        let random = Uuid::new_v4();
511        assert!(LfiXxeScanner::with_lfi_canary(random, random).is_err());
512        assert!(LfiXxeScanner::with_lfi_canary(Uuid::nil(), Uuid::new_v4()).is_err());
513
514        let scanner = LfiXxeScanner::with_lfi_canary(Uuid::new_v4(), Uuid::new_v4()).unwrap();
515        let canary = scanner.lfi_canary.unwrap();
516        assert!(canary.file_name.starts_with(LFI_FILE_PREFIX));
517        assert!(canary.expected_marker.starts_with(LFI_MARKER_PREFIX));
518        assert!(!canary.file_name.contains(&canary.expected_marker));
519    }
520
521    #[test]
522    fn debug_output_redacts_canary_and_oob_configuration_values() {
523        let file_id = Uuid::new_v4();
524        let content_id = Uuid::new_v4();
525        let scanner = LfiXxeScanner::with_lfi_canary(file_id, content_id).unwrap();
526        let debug = format!("{scanner:?}");
527        assert!(debug.contains("lfi_canary_configured: true"));
528        assert!(!debug.contains(&file_id.simple().to_string()));
529        assert!(!debug.contains(&content_id.simple().to_string()));
530
531        let oob = LfiXxeScanner::with_oob_domain("private-callback.example".to_owned());
532        let debug = format!("{oob:?}");
533        assert!(debug.contains("oob_domain_configured: true"));
534        assert!(!debug.contains("private-callback.example"));
535    }
536
537    #[tokio::test]
538    async fn default_and_oob_compatibility_configuration_dispatch_nothing() {
539        let fixture = serve_fixture(0, |_| unreachable!()).await;
540        let context = scan_context(fixture.target.clone());
541
542        assert!(LfiXxeScanner::new()
543            .execute(&context)
544            .await
545            .unwrap()
546            .is_empty());
547        assert!(LfiXxeScanner::with_oob_domain("unused.invalid".to_owned())
548            .execute(&context)
549            .await
550            .unwrap()
551            .is_empty());
552        assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
553    }
554
555    #[tokio::test]
556    async fn independent_host_canary_is_observed_on_local_fixture() {
557        let file_id = Uuid::new_v4();
558        let content_id = Uuid::new_v4();
559        let canary = LfiCanary::new(file_id, content_id).unwrap();
560        let file_name = canary.file_name.clone();
561        let expected_marker = canary.expected_marker.clone();
562        let fixture = serve_fixture(4, move |url| {
563            if last_parameter(url, "file").as_deref() == Some(&file_name) {
564                expected_marker.clone()
565            } else {
566                "ordinary fixture response without a local-file marker".to_owned()
567            }
568        })
569        .await;
570        let context = scan_context(fixture.target.clone());
571        let scanner = LfiXxeScanner::with_lfi_canary(file_id, content_id).unwrap();
572
573        let findings = scanner.execute(&context).await.unwrap();
574
575        assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
576        assert_eq!(findings.len(), 1);
577        assert_eq!(findings[0].severity, "INFO");
578        assert!(findings[0].description.contains("host-provisioned"));
579        assert!(findings[0].description.contains("manual review"));
580        assert!(findings[0].description.contains("does not establish LFI"));
581        assert!(!findings[0].evidence.contains(&canary.expected_marker));
582    }
583
584    #[tokio::test]
585    async fn runner_projects_one_verifier_owned_needs_review_record() {
586        let file_id = Uuid::new_v4();
587        let content_id = Uuid::new_v4();
588        let canary = LfiCanary::new(file_id, content_id).unwrap();
589        let file_name = canary.file_name.clone();
590        let expected_marker = canary.expected_marker.clone();
591        let fixture = serve_fixture(4, move |url| {
592            if last_parameter(url, "file").as_deref() == Some(&file_name) {
593                expected_marker.clone()
594            } else {
595                "ordinary fixture response without a local-file marker".to_owned()
596            }
597        })
598        .await;
599        let context = scan_context(fixture.target.clone());
600        let retained_context = context.clone();
601        let mut runner = ScanRunner::new();
602        runner.register_phase(Box::new(
603            LfiXxeScanner::with_lfi_canary(file_id, content_id).unwrap(),
604        ));
605
606        let report = runner.run_pipeline(context).await.unwrap();
607
608        assert_eq!(report.outcomes().len(), 1);
609        let observation = &report.outcomes()[0];
610        assert_eq!(observation.severity(), SecuritySeverity::Info);
611        assert_eq!(observation.disposition(), OutcomeStatus::NeedsReview);
612        assert_eq!(
613            observation.evidence_ids().iter().next().unwrap().as_str(),
614            LFI_EVIDENCE_ID
615        );
616        let verified = observation.verification_outcome().unwrap();
617        assert_eq!(verified.stage(), VerificationStage::Active);
618        assert_eq!(verified.status(), OutcomeStatus::NeedsReview);
619        assert_eq!(verified.verifier_rule_id(), Some(LFI_RULE_ID));
620        assert_eq!(verified.evidence_ids(), observation.evidence_ids());
621
622        let evidence = retained_context
623            .knowledge()
624            .evidence(&EvidenceId::parse(LFI_EVIDENCE_ID).unwrap())
625            .unwrap();
626        assert_eq!(evidence.value(), &EvidenceValue::Boolean(true));
627        assert_eq!(evidence.observed_at_ms(), 0);
628        assert_eq!(evidence.source().correlation_id(), Some(LFI_CASE_ID));
629        let anchor = retained_context
630            .knowledge()
631            .hypothesis(LFI_HYPOTHESIS_ID)
632            .unwrap();
633        assert_eq!(anchor.state(), HypothesisState::Proposed);
634        assert_eq!(anchor.strength(), HypothesisStrength::Weak);
635
636        let typed = serde_json::to_string(observation).unwrap();
637        assert!(!typed.contains(&file_id.simple().to_string()));
638        assert!(!typed.contains(&content_id.simple().to_string()));
639        assert!(!typed.contains("/include"));
640        assert!(!typed.contains("default.txt"));
641    }
642
643    #[tokio::test]
644    async fn equivalent_canaries_produce_the_same_typed_fingerprint() {
645        let first_file_id = Uuid::new_v4();
646        let first_content_id = Uuid::new_v4();
647        let first = LfiCanary::new(first_file_id, first_content_id).unwrap();
648        let second_file_id = Uuid::new_v4();
649        let second_content_id = Uuid::new_v4();
650        let second = LfiCanary::new(second_file_id, second_content_id).unwrap();
651        let first_file = first.file_name.clone();
652        let first_marker = first.expected_marker.clone();
653        let second_file = second.file_name.clone();
654        let second_marker = second.expected_marker.clone();
655        let fixture = serve_fixture(8, move |url| {
656            let value = last_parameter(url, "file").unwrap();
657            if value == first_file {
658                first_marker.clone()
659            } else if value == second_file {
660                second_marker.clone()
661            } else {
662                "ordinary fixture response without a local-file marker".to_owned()
663            }
664        })
665        .await;
666
667        let mut first_runner = ScanRunner::new();
668        first_runner.register_phase(Box::new(
669            LfiXxeScanner::with_lfi_canary(first_file_id, first_content_id).unwrap(),
670        ));
671        let first_report = first_runner
672            .run_pipeline(scan_context(fixture.target.clone()))
673            .await
674            .unwrap();
675        let mut second_runner = ScanRunner::new();
676        second_runner.register_phase(Box::new(
677            LfiXxeScanner::with_lfi_canary(second_file_id, second_content_id).unwrap(),
678        ));
679        let second_report = second_runner
680            .run_pipeline(scan_context(fixture.target.clone()))
681            .await
682            .unwrap();
683
684        assert_eq!(first_report.outcomes().len(), 1);
685        assert_eq!(second_report.outcomes().len(), 1);
686        assert_eq!(
687            first_report.outcomes()[0].fingerprint(),
688            second_report.outcomes()[0].fingerprint()
689        );
690    }
691
692    #[tokio::test]
693    async fn ordinary_localhost_text_and_single_leg_marker_are_not_lfi_proof() {
694        let file_id = Uuid::new_v4();
695        let content_id = Uuid::new_v4();
696        let canary = LfiCanary::new(file_id, content_id).unwrap();
697        let file_name = canary.file_name.clone();
698        let expected_marker = canary.expected_marker.clone();
699        let candidate_count = Arc::new(AtomicUsize::new(0));
700        let observed_candidate_count = Arc::clone(&candidate_count);
701        let fixture = serve_fixture(4, move |url| {
702            let value = last_parameter(url, "file").unwrap();
703            if value == file_name && observed_candidate_count.fetch_add(1, Ordering::SeqCst) == 0 {
704                expected_marker.clone()
705            } else {
706                "127.0.0.1 localhost ordinary application text".to_owned()
707            }
708        })
709        .await;
710        let context = scan_context(fixture.target.clone());
711        let scanner = LfiXxeScanner::with_lfi_canary(file_id, content_id).unwrap();
712
713        let findings = scanner.execute(&context).await.unwrap();
714
715        assert!(findings.is_empty());
716        assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
717    }
718}