1use std::collections::BTreeSet;
9
10use async_trait::async_trait;
11use url::Url;
12use uuid::Uuid;
13
14use super::phase3_fuzzer::ResponseSignature;
15use crate::{
16 context::ScanContext,
17 contracts::{ScanFinding, ScanPhase},
18 error::ScannerError,
19 http_evidence::HttpProbeMethod,
20 legacy_discovery::{BoundedHttpResponse, DiscoveryDelta},
21};
22
23const PARAMETER_ACTION_ID: &str = "legacy.discovery.parameter";
24const MAX_PARAMETER_NAME_BYTES: usize = 64;
25const MAX_PARAMETER_CANDIDATES: usize = 256;
26const CANDIDATE_MARKER_PREFIX: &str = "venom-candidate-";
27
28#[derive(Debug)]
30pub struct ParameterDiscoverer {
31 param_wordlist: Vec<String>,
32 candidate_input_within_limits: bool,
33}
34
35impl ParameterDiscoverer {
36 pub fn new(param_wordlist: Vec<String>, concurrency_limit: usize) -> Self {
43 if concurrency_limit == 0 {
44 Self {
45 param_wordlist: Vec::new(),
46 candidate_input_within_limits: true,
47 }
48 } else {
49 Self::sequential(param_wordlist)
50 }
51 }
52
53 pub fn sequential(param_wordlist: Vec<String>) -> Self {
56 let candidate_input_within_limits = param_wordlist.len() <= MAX_PARAMETER_CANDIDATES;
57 let param_wordlist = param_wordlist
58 .into_iter()
59 .filter(|name| valid_parameter_name(name))
60 .collect::<BTreeSet<_>>()
61 .into_iter()
62 .collect();
63 Self {
64 param_wordlist,
65 candidate_input_within_limits,
66 }
67 }
68
69 pub fn with_default_wordlist(concurrency_limit: usize) -> Self {
71 if concurrency_limit == 0 {
72 Self {
73 param_wordlist: Vec::new(),
74 candidate_input_within_limits: true,
75 }
76 } else {
77 Self::with_default_wordlist_sequential()
78 }
79 }
80
81 pub fn with_default_wordlist_sequential() -> Self {
84 Self::sequential(
85 [
86 "id", "lang", "limit", "page", "q", "query", "search", "sort",
87 ]
88 .into_iter()
89 .map(str::to_owned)
90 .collect(),
91 )
92 }
93}
94
95fn valid_parameter_name(name: &str) -> bool {
96 !name.is_empty()
97 && name.len() <= MAX_PARAMETER_NAME_BYTES
98 && name.bytes().all(|byte| {
99 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'[' | b']')
100 })
101}
102
103#[async_trait]
104impl ScanPhase for ParameterDiscoverer {
105 fn phase_number(&self) -> u8 {
106 4
107 }
108
109 fn name(&self) -> &'static str {
110 "Hidden Parameter Miner"
112 }
113
114 async fn execute(&self, ctx: &ScanContext) -> Result<Vec<ScanFinding>, ScannerError> {
115 if !self.candidate_input_within_limits {
116 return Err(ScannerError::DiscoveryStateLimitExceeded);
117 }
118 ctx.log("Phase 4: bounded differential parameter discovery initiated".to_owned());
119 let snapshot = ctx.discovery_snapshot();
120 let mut delta = DiscoveryDelta::new();
121 let mut findings = Vec::new();
122
123 for (endpoint, existing_parameters) in snapshot.endpoints() {
124 let endpoint = ctx.canonicalize_discovery_url(&Url::parse(endpoint)?)?;
125 let endpoint_query_names = endpoint
126 .query_pairs()
127 .map(|(name, _)| name.into_owned())
128 .collect::<BTreeSet<_>>();
129 let mut observed_parameters = BTreeSet::new();
130 for parameter in &self.param_wordlist {
131 if existing_parameters.contains(parameter)
132 || endpoint_query_names.contains(parameter)
133 {
134 continue;
135 }
136 if parameter_is_observable(ctx, &endpoint, parameter).await? {
137 observed_parameters.insert(parameter.clone());
138 findings.push(ScanFinding {
139 phase: self.phase_number(),
140 module_name: self.name().to_owned(),
141 severity: "INFO".to_owned(),
142 description: "A query parameter produced a reproducible response differential from both the baseline and randomized unknown-parameter control."
143 .to_owned(),
144 evidence: format!(
145 "Parameter observation: '{}' on {}",
146 parameter,
147 endpoint_subject(&endpoint)
148 ),
149 });
150 }
151 }
152 if !observed_parameters.is_empty() {
153 let mut all_parameters = existing_parameters.clone();
154 all_parameters.extend(observed_parameters);
155 delta.record_endpoint(endpoint, all_parameters);
156 }
157 }
158
159 ctx.commit_discovery(PARAMETER_ACTION_ID, delta)?;
162 ctx.log(format!(
163 "Phase 4: differential parameter discovery completed with {} observations",
164 findings.len()
165 ));
166 Ok(findings)
167 }
168}
169
170async fn parameter_is_observable(
171 ctx: &ScanContext,
172 endpoint: &Url,
173 parameter: &str,
174) -> Result<bool, ScannerError> {
175 let control_nonce = Uuid::new_v4().simple().to_string();
176 let candidate_nonce = Uuid::new_v4().simple().to_string();
177 let control_name = format!("_venom_control_{control_nonce}");
178 let control_marker = format!("{CANDIDATE_MARKER_PREFIX}{control_nonce}");
182 let candidate_marker = format!("{CANDIDATE_MARKER_PREFIX}{candidate_nonce}");
183
184 let baseline = ctx
185 .request(PARAMETER_ACTION_ID, HttpProbeMethod::Get, endpoint.clone())
186 .await?;
187 let negative_url = append_query_parameter(endpoint, &control_name, &control_marker);
188 let negative = ctx
189 .request(PARAMETER_ACTION_ID, HttpProbeMethod::Get, negative_url)
190 .await?;
191 let candidate_url = append_query_parameter(endpoint, parameter, &candidate_marker);
192 let candidate = ctx
193 .request(
194 PARAMETER_ACTION_ID,
195 HttpProbeMethod::Get,
196 candidate_url.clone(),
197 )
198 .await?;
199 let reproduction = ctx
200 .request(PARAMETER_ACTION_ID, HttpProbeMethod::Get, candidate_url)
201 .await?;
202
203 let dynamic_values = DynamicProbeValues {
204 parameter,
205 control_nonce: &control_nonce,
206 candidate_nonce: &candidate_nonce,
207 control_name: &control_name,
208 control_marker: &control_marker,
209 candidate_marker: &candidate_marker,
210 };
211 Ok(parameter_differential(
212 &baseline,
213 &negative,
214 &candidate,
215 &reproduction,
216 dynamic_values,
217 ))
218}
219
220#[derive(Debug, Clone, Copy)]
221struct DynamicProbeValues<'a> {
222 parameter: &'a str,
223 control_nonce: &'a str,
224 candidate_nonce: &'a str,
225 control_name: &'a str,
226 control_marker: &'a str,
227 candidate_marker: &'a str,
228}
229
230fn parameter_differential(
231 baseline: &BoundedHttpResponse,
232 negative: &BoundedHttpResponse,
233 candidate: &BoundedHttpResponse,
234 reproduction: &BoundedHttpResponse,
235 values: DynamicProbeValues<'_>,
236) -> bool {
237 let any_truncated = [baseline, negative, candidate, reproduction]
238 .into_iter()
239 .any(|response| response.body_truncated());
240
241 let marker_preexisting = body_contains(baseline.body(), CANDIDATE_MARKER_PREFIX)
245 || body_contains(negative.body(), CANDIDATE_MARKER_PREFIX);
246
247 let raw_values = [
251 values.parameter,
252 values.control_nonce,
253 values.candidate_nonce,
254 values.control_name,
255 values.control_marker,
256 values.candidate_marker,
257 ];
258 let scrubbed_values =
259 symmetric_scrub_values([baseline, negative, candidate, reproduction], &raw_values);
260 let scrubbed_refs = scrubbed_values
261 .iter()
262 .map(String::as_str)
263 .collect::<Vec<_>>();
264 let baseline_signature = ResponseSignature::capture(baseline, &scrubbed_refs);
265 let negative_signature = ResponseSignature::capture(negative, &scrubbed_refs);
266 let candidate_signature = ResponseSignature::capture(candidate, &scrubbed_refs);
267 let reproduction_signature = ResponseSignature::capture(reproduction, &scrubbed_refs);
268
269 profiles_support_observation(
270 &baseline_signature,
271 &negative_signature,
272 &candidate_signature,
273 &reproduction_signature,
274 any_truncated,
275 marker_preexisting,
276 )
277}
278
279fn symmetric_scrub_values(
280 responses: [&BoundedHttpResponse; 4],
281 dynamic_values: &[&str],
282) -> Vec<String> {
283 let mut values = Vec::new();
284 for value in dynamic_values {
285 add_scrub_variants(&mut values, value);
286 }
287 for response in responses {
288 let request_url = response.request_url();
289 add_scrub_variants(&mut values, request_url.as_str());
290 add_scrub_variants(&mut values, request_url.path());
291 if let Some(query) = request_url.query() {
292 add_scrub_variants(&mut values, query);
293 add_scrub_variants(&mut values, &format!("{}?{query}", request_url.path()));
294 }
295 }
296 values.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
297 values.dedup();
298 values
299}
300
301fn add_scrub_variants(values: &mut Vec<String>, value: &str) {
302 if value.is_empty() {
303 return;
304 }
305 values.push(value.to_owned());
306 let encoded = percent_encode_non_alphanumeric(value.as_bytes());
307 values.push(encoded.clone());
308 values.push(normalize_percent_escape_case(
309 &encoded,
310 u8::to_ascii_lowercase,
311 ));
312 values.push(normalize_percent_escape_case(value, u8::to_ascii_lowercase));
313 values.push(normalize_percent_escape_case(value, u8::to_ascii_uppercase));
314}
315
316fn normalize_percent_escape_case(value: &str, map: fn(&u8) -> u8) -> String {
317 let mut bytes = value.as_bytes().to_vec();
318 let mut index = 0;
319 while index + 2 < bytes.len() {
320 if bytes[index] == b'%' {
321 bytes[index + 1] = map(&bytes[index + 1]);
322 bytes[index + 2] = map(&bytes[index + 2]);
323 index += 3;
324 } else {
325 index += 1;
326 }
327 }
328 String::from_utf8(bytes).expect("changing ASCII percent escapes preserves UTF-8")
329}
330
331fn percent_encode_non_alphanumeric(value: &[u8]) -> String {
332 const HEX: &[u8; 16] = b"0123456789ABCDEF";
333 let mut encoded = String::with_capacity(value.len().saturating_mul(3));
334 for byte in value {
335 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
336 encoded.push(char::from(*byte));
337 } else {
338 encoded.push('%');
339 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
340 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
341 }
342 }
343 encoded
344}
345
346fn profiles_support_observation(
347 baseline: &ResponseSignature,
348 negative: &ResponseSignature,
349 candidate: &ResponseSignature,
350 reproduction: &ResponseSignature,
351 any_truncated: bool,
352 marker_preexisting: bool,
353) -> bool {
354 !any_truncated
355 && !marker_preexisting
356 && candidate == reproduction
357 && candidate != baseline
358 && candidate != negative
359}
360
361fn append_query_parameter(endpoint: &Url, name: &str, value: &str) -> Url {
362 let mut mutated = endpoint.clone();
363 mutated.query_pairs_mut().append_pair(name, value);
364 mutated
365}
366
367fn body_contains(body: &[u8], needle: &str) -> bool {
368 !needle.is_empty()
369 && body
370 .windows(needle.len())
371 .any(|window| window == needle.as_bytes())
372}
373
374fn endpoint_subject(endpoint: &Url) -> String {
375 let mut subject = endpoint.clone();
376 subject.set_query(None);
377 subject.set_fragment(None);
378 subject.to_string()
379}
380
381#[cfg(test)]
382mod tests {
383 use std::sync::{
384 atomic::{AtomicUsize, Ordering},
385 Arc, Mutex,
386 };
387 use std::time::Duration;
388
389 use tokio::{
390 io::{AsyncReadExt, AsyncWriteExt},
391 net::TcpListener,
392 task::JoinHandle,
393 };
394
395 use super::*;
396 use crate::legacy_discovery::DiscoveryLimits;
397
398 struct LocalFixture {
399 target: Url,
400 requests: Arc<AtomicUsize>,
401 targets: Arc<Mutex<Vec<String>>>,
402 task: JoinHandle<()>,
403 }
404
405 impl Drop for LocalFixture {
406 fn drop(&mut self) {
407 self.task.abort();
408 }
409 }
410
411 async fn serve_fixture(
412 max_requests: usize,
413 handler: impl Fn(&str) -> String + Send + Sync + 'static,
414 ) -> LocalFixture {
415 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
416 let address = listener.local_addr().unwrap();
417 let requests = Arc::new(AtomicUsize::new(0));
418 let observed_requests = Arc::clone(&requests);
419 let targets = Arc::new(Mutex::new(Vec::new()));
420 let observed_targets = Arc::clone(&targets);
421 let handler = Arc::new(handler);
422 let task = tokio::spawn(async move {
423 for _ in 0..max_requests {
424 let (mut stream, _) = listener.accept().await.unwrap();
425 let mut request = Vec::new();
426 loop {
427 let mut chunk = [0_u8; 1_024];
428 let read = stream.read(&mut chunk).await.unwrap();
429 if read == 0 {
430 break;
431 }
432 request.extend_from_slice(&chunk[..read]);
433 if request.windows(4).any(|window| window == b"\r\n\r\n") {
434 break;
435 }
436 assert!(request.len() <= 16 * 1_024, "fixture request too large");
437 }
438 observed_requests.fetch_add(1, Ordering::SeqCst);
439 let target = String::from_utf8_lossy(&request)
440 .lines()
441 .next()
442 .and_then(|line| line.split_ascii_whitespace().nth(1))
443 .unwrap()
444 .to_owned();
445 observed_targets
446 .lock()
447 .unwrap_or_else(std::sync::PoisonError::into_inner)
448 .push(target.clone());
449 let body = handler(&target);
450 let wire = format!(
451 "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
452 body.len(),
453 body
454 );
455 stream.write_all(wire.as_bytes()).await.unwrap();
456 stream.shutdown().await.unwrap();
457 }
458 });
459
460 LocalFixture {
461 target: Url::parse(&format!("http://{address}/search")).unwrap(),
462 requests,
463 targets,
464 task,
465 }
466 }
467
468 fn scan_context(target: Url) -> ScanContext {
469 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
470 let context = ScanContext::new(target.clone(), reqwest::Client::new(), telemetry);
471 let mut delta = DiscoveryDelta::new();
472 delta.record_endpoint(target, ["existing".to_owned()]);
473 context
474 .commit_discovery(PARAMETER_ACTION_ID, delta)
475 .unwrap();
476 context
477 }
478
479 #[test]
480 fn phase_identity_and_default_bounds_are_stable() {
481 let discoverer = ParameterDiscoverer::with_default_wordlist_sequential();
482 assert_eq!(discoverer.phase_number(), 4);
483 assert_eq!(discoverer.name(), "Hidden Parameter Miner");
484 assert_eq!(discoverer.param_wordlist.len(), 8);
485 }
486
487 #[test]
488 fn candidates_are_bounded_deduplicated_and_deterministic() {
489 let discoverer = ParameterDiscoverer::sequential(vec![
490 "z".to_owned(),
491 "a".to_owned(),
492 "z".to_owned(),
493 String::new(),
494 "x".repeat(MAX_PARAMETER_NAME_BYTES + 1),
495 "line\nbreak".to_owned(),
496 ]);
497 assert_eq!(discoverer.param_wordlist, vec!["a", "z"]);
498 }
499
500 #[test]
501 fn oversized_candidate_input_is_rejected_before_execution() {
502 let discoverer = ParameterDiscoverer::sequential(
503 (0..=MAX_PARAMETER_CANDIDATES)
504 .map(|index| format!("p{index}"))
505 .collect(),
506 );
507 assert!(!discoverer.candidate_input_within_limits);
508 assert!(discoverer.param_wordlist.len() > MAX_PARAMETER_CANDIDATES);
509 }
510
511 #[test]
512 fn url_mutation_preserves_and_encodes_existing_query() {
513 let endpoint = Url::parse("https://example.test/search?scope=docs%20and%20api").unwrap();
514 let mutated = append_query_parameter(&endpoint, "new name", "a&b=c");
515 assert_eq!(
516 mutated.as_str(),
517 "https://example.test/search?scope=docs%20and%20api&new+name=a%26b%3Dc"
518 );
519 assert_eq!(endpoint.query(), Some("scope=docs%20and%20api"));
520 }
521
522 #[test]
523 fn public_subject_omits_query_values() {
524 let endpoint = Url::parse("https://example.test/search?token=secret#part").unwrap();
525 assert_eq!(endpoint_subject(&endpoint), "https://example.test/search");
526 }
527
528 #[test]
529 fn byte_search_handles_empty_and_binary_bodies() {
530 assert!(!body_contains(b"prefix\0marker", ""));
531 assert!(body_contains(b"prefix\0marker", "marker"));
532 assert!(!body_contains(b"prefix\0marker", "other"));
533 }
534
535 fn signature(status: u16, body: &str) -> ResponseSignature {
536 ResponseSignature::from_parts(status, None, body)
537 }
538
539 #[test]
540 fn invariant_200_application_produces_no_observation() {
541 let same = signature(200, "unchanged application response");
542 assert!(!profiles_support_observation(
543 &same, &same, &same, &same, false, false
544 ));
545 }
546
547 #[test]
548 fn positive_differential_must_be_distinct_and_reproducible() {
549 let baseline = signature(200, "ordinary response");
550 let negative = signature(200, "unknown parameter ignored");
551 let recognized = signature(200, "recognized parameter response");
552 assert!(profiles_support_observation(
553 &baseline,
554 &negative,
555 &recognized,
556 &recognized,
557 false,
558 false,
559 ));
560
561 let non_reproduction = signature(200, "one-off response");
562 assert!(!profiles_support_observation(
563 &baseline,
564 &negative,
565 &recognized,
566 &non_reproduction,
567 false,
568 false,
569 ));
570 }
571
572 #[test]
573 fn negative_control_equivalence_and_preexisting_marker_fail_closed() {
574 let baseline = signature(200, "ordinary response");
575 let generic_query_response = signature(200, "query echoed");
576 assert!(!profiles_support_observation(
577 &baseline,
578 &generic_query_response,
579 &generic_query_response,
580 &generic_query_response,
581 false,
582 false,
583 ));
584
585 let recognized = signature(200, "recognized parameter response");
586 assert!(!profiles_support_observation(
587 &baseline,
588 &generic_query_response,
589 &recognized,
590 &recognized,
591 false,
592 true,
593 ));
594 assert!(!profiles_support_observation(
595 &baseline,
596 &generic_query_response,
597 &recognized,
598 &recognized,
599 true,
600 false,
601 ));
602 }
603
604 #[tokio::test]
605 async fn ignored_unknown_parameters_with_invariant_200_produce_zero_observations() {
606 let fixture = serve_fixture(4, |_| "ordinary application page".to_owned()).await;
607 let context = scan_context(fixture.target.clone());
608 let findings = ParameterDiscoverer::sequential(vec!["q".to_owned()])
609 .execute(&context)
610 .await
611 .unwrap();
612
613 assert!(findings.is_empty());
614 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
615 assert_eq!(
616 context
617 .discovery_snapshot()
618 .endpoints()
619 .get(fixture.target.as_str())
620 .unwrap(),
621 &BTreeSet::from(["existing".to_owned()])
622 );
623 }
624
625 #[tokio::test]
626 async fn generic_query_key_echo_produces_zero_observations() {
627 let fixture = serve_fixture(4, |target| {
628 let parsed = Url::parse(&format!("http://fixture.test{target}")).unwrap();
629 let names = parsed
630 .query_pairs()
631 .map(|(name, _)| name.into_owned())
632 .collect::<Vec<_>>()
633 .join(",");
634 format!("query keys: {names}")
635 })
636 .await;
637 let context = scan_context(fixture.target.clone());
638
639 let findings = ParameterDiscoverer::sequential(vec!["q".to_owned()])
640 .execute(&context)
641 .await
642 .unwrap();
643
644 assert!(findings.is_empty());
645 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
646 }
647
648 #[tokio::test]
649 async fn bare_random_nonce_echo_produces_zero_observations() {
650 let fixture = serve_fixture(4, |target| {
651 let parsed = Url::parse(&format!("http://fixture.test{target}")).unwrap();
652 parsed
653 .query_pairs()
654 .next()
655 .map(|(_, value)| {
656 value
657 .strip_prefix(CANDIDATE_MARKER_PREFIX)
658 .unwrap_or(&value)
659 .to_owned()
660 })
661 .unwrap_or_else(|| "ordinary application page".to_owned())
662 })
663 .await;
664 let context = scan_context(fixture.target.clone());
665
666 let findings = ParameterDiscoverer::sequential(vec!["q".to_owned()])
667 .execute(&context)
668 .await
669 .unwrap();
670
671 assert!(findings.is_empty());
672 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
673 }
674
675 #[tokio::test]
676 async fn preexisting_marker_namespace_suppresses_an_apparent_differential() {
677 let fixture = serve_fixture(4, |target| {
678 if target.contains("q=") {
679 "recognized candidate response".to_owned()
680 } else {
681 "documentation example venom-candidate-already-present".to_owned()
682 }
683 })
684 .await;
685 let context = scan_context(fixture.target.clone());
686 let findings = ParameterDiscoverer::sequential(vec!["q".to_owned()])
687 .execute(&context)
688 .await
689 .unwrap();
690
691 assert!(findings.is_empty());
692 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
693 }
694
695 #[tokio::test]
696 async fn reproducible_candidate_differential_records_one_info_observation() {
697 let fixture = serve_fixture(4, |target| {
698 if target.contains("q=") {
699 "recognized query behavior".to_owned()
700 } else {
701 "ordinary application page".to_owned()
702 }
703 })
704 .await;
705 let context = scan_context(fixture.target.clone());
706 let findings = ParameterDiscoverer::sequential(vec!["q".to_owned()])
707 .execute(&context)
708 .await
709 .unwrap();
710
711 assert_eq!(findings.len(), 1);
712 assert_eq!(findings[0].severity, "INFO");
713 let public_text = format!("{} {}", findings[0].description, findings[0].evidence);
714 assert!(!public_text.contains("venom-candidate-"));
715 assert!(!public_text.to_lowercase().contains("vulnerab"));
716 assert!(!public_text.to_lowercase().contains("confirm"));
717 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
718 let targets = fixture
719 .targets
720 .lock()
721 .unwrap_or_else(std::sync::PoisonError::into_inner);
722 assert_eq!(targets[0], "/search");
723 assert!(targets[1].contains("_venom_control_"));
724 assert!(targets[1].contains("venom-candidate-"));
725 assert_eq!(targets[2], targets[3]);
726 assert!(targets[2].contains("q=venom-candidate-"));
727 assert_eq!(
728 context
729 .discovery_snapshot()
730 .endpoints()
731 .get(fixture.target.as_str())
732 .unwrap(),
733 &BTreeSet::from(["existing".to_owned(), "q".to_owned()])
734 );
735 }
736
737 #[tokio::test]
738 async fn marker_shape_reaction_is_not_a_parameter_name_observation() {
739 let fixture = serve_fixture(4, |target| {
740 if target.contains("venom-candidate-") {
741 "generic marker-shaped value response".to_owned()
742 } else {
743 "ordinary application page".to_owned()
744 }
745 })
746 .await;
747 let context = scan_context(fixture.target.clone());
748
749 let findings = ParameterDiscoverer::sequential(vec!["q".to_owned()])
750 .execute(&context)
751 .await
752 .unwrap();
753
754 assert!(findings.is_empty());
755 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
756 }
757
758 #[tokio::test]
759 async fn percent_encoded_query_key_echo_produces_zero_observations() {
760 let fixture = serve_fixture(4, |target| {
761 let key = target
762 .split_once('?')
763 .and_then(|(_, query)| query.rsplit('&').next())
764 .and_then(|pair| pair.split_once('=').map(|(name, _)| name))
765 .unwrap_or("none");
766 format!("wire query key: {key}")
767 })
768 .await;
769 let context = scan_context(fixture.target.clone());
770
771 let findings = ParameterDiscoverer::sequential(vec!["filters[id]".to_owned()])
772 .execute(&context)
773 .await
774 .unwrap();
775
776 assert!(findings.is_empty());
777 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
778 }
779
780 #[tokio::test]
781 async fn an_already_known_parameter_is_not_probed_again() {
782 let fixture = serve_fixture(0, |_| unreachable!()).await;
783 let context = scan_context(fixture.target.clone());
784 let findings = ParameterDiscoverer::sequential(vec!["existing".to_owned()])
785 .execute(&context)
786 .await
787 .unwrap();
788 assert!(findings.is_empty());
789 assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
790 }
791
792 #[tokio::test]
793 async fn compatibility_zero_concurrency_preserves_no_request_authority() {
794 let fixture = serve_fixture(0, |_| unreachable!()).await;
795 let context = scan_context(fixture.target.clone());
796
797 let explicit = ParameterDiscoverer::new(vec!["q".to_owned()], 0)
798 .execute(&context)
799 .await
800 .unwrap();
801 let defaults = ParameterDiscoverer::with_default_wordlist(0)
802 .execute(&context)
803 .await
804 .unwrap();
805
806 assert!(explicit.is_empty());
807 assert!(defaults.is_empty());
808 assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
809 }
810
811 #[tokio::test]
812 async fn root_query_parameter_is_known_without_running_the_crawler() {
813 let fixture = serve_fixture(0, |_| unreachable!()).await;
814 let mut target = fixture.target.clone();
815 target.set_query(Some("q=known"));
816 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
817 let context = ScanContext::new(target, reqwest::Client::new(), telemetry);
818
819 let findings = ParameterDiscoverer::sequential(vec!["q".to_owned()])
820 .execute(&context)
821 .await
822 .unwrap();
823
824 assert!(findings.is_empty());
825 assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
826 }
827
828 #[tokio::test]
829 async fn query_name_beyond_retained_state_sample_is_still_never_probed() {
830 let fixture = serve_fixture(0, |_| unreachable!()).await;
831 let mut target = fixture.target.clone();
832 {
833 let mut query = target.query_pairs_mut();
834 for index in 0..256 {
835 query.append_pair(&format!("a{index:03}"), "known");
836 }
837 query.append_pair("q", "known");
838 }
839 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
840 let context = ScanContext::new(target, reqwest::Client::new(), telemetry);
841
842 let findings = ParameterDiscoverer::sequential(vec!["q".to_owned()])
843 .execute(&context)
844 .await
845 .unwrap();
846
847 assert!(findings.is_empty());
848 assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
849 }
850
851 #[tokio::test]
852 async fn public_host_seed_query_parameter_is_not_probed_again() {
853 let fixture = serve_fixture(0, |_| unreachable!()).await;
854 let mut target = fixture.target.clone();
855 target.set_query(Some("seeded=root"));
856 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
857 let context = ScanContext::new(target, reqwest::Client::new(), telemetry);
858 context.add_endpoint("/other?seeded=known".to_owned(), Vec::new());
859
860 let findings = ParameterDiscoverer::sequential(vec!["seeded".to_owned()])
861 .execute(&context)
862 .await
863 .unwrap();
864
865 assert!(findings.is_empty());
866 assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
867 }
868
869 #[tokio::test]
870 async fn cancelled_empty_candidate_phase_cannot_commit_success() {
871 let fixture = serve_fixture(0, |_| unreachable!()).await;
872 let cancellation = tokio_util::sync::CancellationToken::new();
873 cancellation.cancel();
874 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
875 let context = ScanContext::with_cancellation(
876 fixture.target.clone(),
877 reqwest::Client::new(),
878 telemetry,
879 30,
880 cancellation,
881 );
882
883 let error = ParameterDiscoverer::new(Vec::new(), 0)
884 .execute(&context)
885 .await
886 .unwrap_err();
887
888 assert!(matches!(error, ScannerError::Cancelled));
889 assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
890 }
891
892 #[tokio::test]
893 async fn fourth_leg_budget_denial_is_typed_and_does_not_partially_commit() {
894 let fixture = serve_fixture(3, |target| {
895 if target.contains("q=") {
896 "recognized query behavior".to_owned()
897 } else {
898 "ordinary application page".to_owned()
899 }
900 })
901 .await;
902 let limits = DiscoveryLimits::new()
903 .with_max_depth(1)
904 .with_max_pages(1)
905 .unwrap()
906 .with_max_requests(3)
907 .with_request_timeout(Duration::from_secs(1))
908 .unwrap()
909 .with_max_wall_time(Duration::from_secs(10))
910 .unwrap()
911 .with_body_limits(128 * 1_024, 64 * 1_024)
912 .unwrap();
913 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
914 let context = ScanContext::new_with_discovery_limits(
915 fixture.target.clone(),
916 reqwest::Client::new(),
917 telemetry,
918 limits,
919 );
920 let mut seed = DiscoveryDelta::new();
921 seed.record_endpoint(fixture.target.clone(), ["existing".to_owned()]);
922 context.commit_discovery(PARAMETER_ACTION_ID, seed).unwrap();
923 let before = context.discovery_snapshot();
924
925 let error = ParameterDiscoverer::sequential(vec!["q".to_owned()])
926 .execute(&context)
927 .await
928 .unwrap_err();
929 assert!(matches!(error, ScannerError::BudgetExceeded(_)));
930 assert_eq!(context.discovery_snapshot(), before);
931 assert_eq!(fixture.requests.load(Ordering::SeqCst), 3);
932 }
933}