1use 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#[derive(Clone)]
79pub struct LfiXxeScanner {
80 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 pub const fn new() -> Self {
100 Self {
101 oob_domain: None,
102 lfi_canary: None,
103 }
104 }
105
106 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 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 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 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}