Skip to main content

termivar_scanner/phases/
phase4_param.rs

1//! # Phase 4: bounded differential parameter discovery
2//!
3//! Each candidate is compared with a baseline and a randomized unknown-query
4//! control, then repeated as the exact same candidate request. A reproducible difference is an
5//! endpoint observation only; reflection or status alone is not a vulnerability
6//! claim.
7
8use 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/// Differential query-parameter discovery.
29#[derive(Debug)]
30pub struct ParameterDiscoverer {
31    param_wordlist: Vec<String>,
32    candidate_input_within_limits: bool,
33}
34
35impl ParameterDiscoverer {
36    /// Creates a discoverer from a bounded set of candidate parameter names.
37    /// Probes execute in canonical endpoint/parameter order. The compatibility
38    /// concurrency argument now selects only whether authority exists: zero
39    /// preserves the historical no-dispatch boundary, while every positive
40    /// value is conservatively narrowed to sequential execution so request
41    /// selection and budget exhaustion remain deterministic.
42    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    /// Creates the deterministic sequential form without a compatibility
54    /// concurrency argument.
55    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    /// Uses a conservative set of ordinary navigation/query parameters.
70    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    /// Uses the conservative default set with deterministic sequential
82    /// dispatch.
83    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        // Retained as the stable phase/action identity for existing reports.
111        "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        // No discovery mutation is visible unless every requested comparison
160        // completed successfully within the shared transport/body budget.
161        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    // Keep the negative and candidate values structurally identical so a
179    // generic rule reacting to the marker shape cannot be mistaken for a
180    // parameter-name differential.
181    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    // A pre-existing occurrence cannot be correlated to this attempt. This is
242    // practically unlikely with UUID markers but is a required fail-closed
243    // boundary and keeps deterministic fixtures honest.
244    let marker_preexisting = body_contains(baseline.body(), CANDIDATE_MARKER_PREFIX)
245        || body_contains(negative.body(), CANDIDATE_MARKER_PREFIX);
246
247    // Normalize every leg with the same value universe. Using asymmetric
248    // scrubbers would itself manufacture a differential when an ordinary page
249    // happened to contain a short candidate name such as `q`.
250    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}