Skip to main content

termivar_scanner/phases/
phase5_sqli.rs

1//! # Phase 5: bounded SQL-behavior observations
2//!
3//! This legacy-only phase performs conservative differential probes. It does
4//! not treat an HTTP 500, a generic error string, or latency by itself as SQL
5//! injection confirmation. Any retained signal is an informational
6//! observation that requires review outside this phase.
7
8use std::{
9    collections::BTreeSet,
10    time::{Duration, Instant},
11};
12
13use async_trait::async_trait;
14use termivar_core::{
15    ConfidenceScore, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue, Hypothesis,
16    KnowledgePredicate, OutcomeStatus, Probability, VerificationStage,
17};
18use url::Url;
19use uuid::Uuid;
20
21use crate::{
22    context::ScanContext,
23    contracts::{ScanFinding, ScanPhase},
24    error::ScannerError,
25    http_evidence::HttpProbeMethod,
26    legacy_discovery::BoundedHttpResponse,
27    ActiveVerifier, Expression, KnowledgeLayer, VerificationCase, VerificationReport,
28    VerificationRule,
29};
30
31const SQLI_ACTION_ID: &str = "legacy.observer.sql_behavior";
32const MAX_SQLI_REQUESTS: usize = 20;
33const REQUESTS_PER_PARAMETER: usize = 9;
34const TIMING_SAMPLE_PAIRS: usize = 3;
35const MIN_MEDIAN_DELAY: Duration = Duration::from_millis(125);
36const MIN_PAIRED_DELAY: Duration = Duration::from_millis(100);
37
38/// Legacy SQL-behavior scanner.
39#[derive(Debug)]
40pub struct SqliScanner;
41
42#[derive(Debug)]
43struct TimedResponse {
44    response: BoundedHttpResponse,
45    elapsed: Duration,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum SqlErrorFamily {
50    SqlState,
51    MySql,
52    PostgreSql,
53    Oracle,
54    MicrosoftSqlServer,
55    Sqlite,
56}
57
58impl SqlErrorFamily {
59    const fn label(self) -> &'static str {
60        match self {
61            Self::SqlState => "SQLSTATE-shaped diagnostic",
62            Self::MySql => "MySQL-shaped diagnostic",
63            Self::PostgreSql => "PostgreSQL-shaped diagnostic",
64            Self::Oracle => "Oracle-shaped diagnostic",
65            Self::MicrosoftSqlServer => "SQL Server-shaped diagnostic",
66            Self::Sqlite => "SQLite-shaped diagnostic",
67        }
68    }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72struct TimingSummary {
73    control_median: Duration,
74    test_median: Duration,
75    control_mad: Duration,
76    test_mad: Duration,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
80enum SqlReviewKind {
81    ReproducedDiagnostic,
82    TimingDifferential,
83}
84
85impl SqlReviewKind {
86    const fn slug(self) -> &'static str {
87        match self {
88            Self::ReproducedDiagnostic => "diagnostic",
89            Self::TimingDifferential => "timing",
90        }
91    }
92
93    const fn predicate_name(self) -> &'static str {
94        match self {
95            Self::ReproducedDiagnostic => "reproduced-db-diagnostic",
96            Self::TimingDifferential => "robust-timing-differential",
97        }
98    }
99
100    const fn evidence_kind(self) -> EvidenceKind {
101        match self {
102            Self::ReproducedDiagnostic => EvidenceKind::Http,
103            Self::TimingDifferential => EvidenceKind::Timing,
104        }
105    }
106
107    const fn source_method(self) -> &'static str {
108        match self {
109            Self::ReproducedDiagnostic => "differential-response-review",
110            Self::TimingDifferential => "paired-timing-review",
111        }
112    }
113
114    const fn confidence(self) -> u8 {
115        match self {
116            Self::ReproducedDiagnostic => 75,
117            Self::TimingDifferential => 65,
118        }
119    }
120
121    const fn rationale(self) -> &'static str {
122        match self {
123            Self::ReproducedDiagnostic => {
124                "A reproduced database diagnostic differential requires manual review"
125            },
126            Self::TimingDifferential => {
127                "A repeated paired timing differential requires manual review"
128            },
129        }
130    }
131}
132
133#[async_trait]
134impl ScanPhase for SqliScanner {
135    fn phase_number(&self) -> u8 {
136        5
137    }
138
139    fn name(&self) -> &'static str {
140        "SQL Behavior Observer"
141    }
142
143    async fn execute(&self, ctx: &ScanContext) -> Result<Vec<ScanFinding>, ScannerError> {
144        ctx.log("Phase 5: bounded SQL-behavior probes initiated...".to_owned());
145        let snapshot = ctx.discovery_snapshot();
146        let mut findings = Vec::new();
147        let mut review_signals = BTreeSet::new();
148        let mut requests = 0_usize;
149
150        'endpoints: for (raw_endpoint, parameters) in snapshot.endpoints() {
151            if parameters.is_empty()
152                || MAX_SQLI_REQUESTS.saturating_sub(requests) < REQUESTS_PER_PARAMETER + 1
153            {
154                continue;
155            }
156            let Ok(endpoint) = Url::parse(raw_endpoint) else {
157                continue;
158            };
159            let baseline = request(ctx, endpoint.clone(), &mut requests).await?;
160            if !usable_text_response(&baseline.response) {
161                continue;
162            }
163
164            // Documentation pages and generic error templates commonly contain
165            // database diagnostics. A pre-existing token invalidates every
166            // differential for this endpoint rather than being projected as a
167            // target behavior.
168            if sql_error_family(baseline.response.body()).is_some() {
169                ctx.log(format!(
170                    "SQL probes skipped for {} because the baseline already contains a database diagnostic.",
171                    endpoint_subject(&endpoint)
172                ));
173                continue;
174            }
175
176            for parameter in parameters {
177                if MAX_SQLI_REQUESTS.saturating_sub(requests) < REQUESTS_PER_PARAMETER {
178                    break 'endpoints;
179                }
180                let nonce = Uuid::new_v4().simple().to_string();
181
182                let error_control =
183                    replace_parameter(&endpoint, parameter, &format!("venom-sql-control-{nonce}"));
184                let error_candidate =
185                    replace_parameter(&endpoint, parameter, &format!("'venom-sql-{nonce}"));
186                let control = request(ctx, error_control, &mut requests).await?;
187                let candidate = request(ctx, error_candidate.clone(), &mut requests).await?;
188                let replay = request(ctx, error_candidate, &mut requests).await?;
189
190                if let Some(family) = reproduced_error_differential(
191                    &baseline.response,
192                    &control.response,
193                    &candidate.response,
194                    &replay.response,
195                ) {
196                    findings.push(error_observation(self, &endpoint, parameter, family));
197                    review_signals.insert(SqlReviewKind::ReproducedDiagnostic);
198                }
199
200                let timing_nonce = Uuid::new_v4().simple().to_string();
201                let timing_control = replace_parameter(
202                    &endpoint,
203                    parameter,
204                    &format!("1' AND SL33P(0.25)=0-- {timing_nonce}"),
205                );
206                let timing_test = replace_parameter(
207                    &endpoint,
208                    parameter,
209                    &format!("1' AND SLEEP(0.25)=0-- {timing_nonce}"),
210                );
211                let start_test_first = Uuid::new_v4().as_u128() & 1 == 1;
212                let mut controls = Vec::with_capacity(TIMING_SAMPLE_PAIRS);
213                let mut tests = Vec::with_capacity(TIMING_SAMPLE_PAIRS);
214                let mut comparable = true;
215
216                for round in 0..TIMING_SAMPLE_PAIRS {
217                    let test_first = start_test_first ^ !round.is_multiple_of(2);
218                    let (control_response, test_response) = if test_first {
219                        let test = request(ctx, timing_test.clone(), &mut requests).await?;
220                        let control = request(ctx, timing_control.clone(), &mut requests).await?;
221                        (control, test)
222                    } else {
223                        let control = request(ctx, timing_control.clone(), &mut requests).await?;
224                        let test = request(ctx, timing_test.clone(), &mut requests).await?;
225                        (control, test)
226                    };
227                    comparable &= timing_pair_is_comparable(
228                        &baseline.response,
229                        &control_response.response,
230                        &test_response.response,
231                    );
232                    controls.push(control_response.elapsed);
233                    tests.push(test_response.elapsed);
234                }
235
236                if comparable {
237                    if let Some(summary) = supported_timing_differential(&controls, &tests) {
238                        findings.push(timing_observation(self, &endpoint, parameter, summary));
239                        review_signals.insert(SqlReviewKind::TimingDifferential);
240                    }
241                }
242            }
243        }
244
245        ctx.ensure_legacy_verification_commit(SQLI_ACTION_ID)?;
246        let reports = build_sql_review_reports(ctx, &review_signals)?;
247        ctx.record_legacy_verification_reports(reports)?;
248
249        ctx.log(format!(
250            "Phase 5: bounded SQL-behavior probes completed with {} review observation(s) across {} request(s).",
251            findings.len(), requests
252        ));
253        Ok(findings)
254    }
255}
256
257fn build_sql_review_reports(
258    ctx: &ScanContext,
259    signals: &BTreeSet<SqlReviewKind>,
260) -> Result<Vec<VerificationReport>, ScannerError> {
261    if signals.is_empty() {
262        return Ok(Vec::new());
263    }
264    let subject = ctx.legacy_verification_subject()?;
265    let anchor_predicate =
266        KnowledgePredicate::new("legacy.verification", "manual-review-audit-anchor")
267            .map_err(|_| invalid_verification_report())?;
268    let prior = Probability::from_percent(50).map_err(|_| invalid_verification_report())?;
269    let hypotheses = signals
270        .iter()
271        .map(|kind| {
272            Hypothesis::with_id(
273                hypothesis_id(*kind),
274                subject.clone(),
275                anchor_predicate.clone(),
276                EvidenceValue::Text(format!("{}-observation-review", kind.slug())),
277                prior,
278            )
279            .map_err(|_| invalid_verification_report())
280        })
281        .collect::<Result<Vec<_>, _>>()?;
282    ctx.knowledge()
283        .upsert_hypothesis_batch(hypotheses)
284        .map_err(|_| invalid_verification_report())?;
285    let baseline = ctx.knowledge().snapshot_for_subject(&subject);
286
287    let evidence = signals
288        .iter()
289        .map(|kind| sql_review_evidence(&subject, *kind))
290        .collect::<Result<Vec<_>, _>>()?;
291    ctx.knowledge()
292        .insert_evidence_batch(evidence)
293        .map_err(|_| invalid_verification_report())?;
294    let after_probe = ctx.knowledge().snapshot_for_subject(&subject);
295
296    signals
297        .iter()
298        .map(|kind| {
299            let case = VerificationCase::new(
300                case_id(*kind),
301                subject.clone(),
302                SQLI_ACTION_ID,
303                hypothesis_id(*kind),
304            )
305            .map_err(|_| invalid_verification_report())?
306            .without_hypothesis_transition();
307            let predicate = KnowledgePredicate::new("legacy.sql-behavior", kind.predicate_name())
308                .map_err(|_| invalid_verification_report())?;
309            let rule = VerificationRule::new(
310                format!("verify:legacy.sql.{}.needs-review", kind.slug()),
311                VerificationStage::Active,
312                100,
313                Expression::equals(
314                    KnowledgeLayer::Evidence,
315                    predicate,
316                    EvidenceValue::Boolean(true),
317                ),
318                OutcomeStatus::NeedsReview,
319                Probability::from_percent(kind.confidence())
320                    .map_err(|_| invalid_verification_report())?,
321                kind.rationale(),
322            )
323            .map_err(|_| invalid_verification_report())?
324            .scoped_to_action(SQLI_ACTION_ID)
325            .map_err(|_| invalid_verification_report())?
326            .with_case_correlated_evidence()
327            .map_err(|_| invalid_verification_report())?;
328            let mut verifier = ActiveVerifier::new();
329            verifier
330                .register(rule)
331                .map_err(|_| invalid_verification_report())?;
332            verifier
333                .verify_snapshots(&case, &baseline, &after_probe)
334                .map_err(|_| invalid_verification_report())
335        })
336        .collect()
337}
338
339fn sql_review_evidence(
340    subject: &termivar_core::EntityId,
341    kind: SqlReviewKind,
342) -> Result<Evidence, ScannerError> {
343    let case_id = case_id(kind);
344    let predicate = KnowledgePredicate::new("legacy.sql-behavior", kind.predicate_name())
345        .map_err(|_| invalid_verification_report())?;
346    let source = EvidenceSource::new(SQLI_ACTION_ID, kind.source_method())
347        .and_then(|source| source.with_correlation_id(case_id))
348        .map_err(|_| invalid_verification_report())?;
349    let evidence_id = EvidenceId::parse(format!("evidence:legacy.sql.{}", kind.slug()))
350        .map_err(|_| invalid_verification_report())?;
351    Ok(Evidence::with_id_at(
352        evidence_id,
353        subject.clone(),
354        kind.evidence_kind(),
355        predicate,
356        EvidenceValue::Boolean(true),
357        source,
358        ConfidenceScore::from_percent(kind.confidence())
359            .map_err(|_| invalid_verification_report())?,
360        0,
361    ))
362}
363
364fn case_id(kind: SqlReviewKind) -> String {
365    format!("case:legacy.sql.{}", kind.slug())
366}
367
368fn hypothesis_id(kind: SqlReviewKind) -> String {
369    format!("hypothesis:legacy.sql.{}.review", kind.slug())
370}
371
372const fn invalid_verification_report() -> ScannerError {
373    ScannerError::InvalidLegacyVerificationReport
374}
375
376async fn request(
377    ctx: &ScanContext,
378    url: Url,
379    requests: &mut usize,
380) -> Result<TimedResponse, ScannerError> {
381    debug_assert!(*requests < MAX_SQLI_REQUESTS);
382    *requests += 1;
383    let started = Instant::now();
384    let response = ctx
385        .verification_request(SQLI_ACTION_ID, HttpProbeMethod::Get, url)
386        .await?;
387    Ok(TimedResponse {
388        response,
389        elapsed: started.elapsed(),
390    })
391}
392
393fn replace_parameter(endpoint: &Url, parameter: &str, value: &str) -> Url {
394    let retained = endpoint
395        .query_pairs()
396        .filter(|(name, _)| name != parameter)
397        .map(|(name, value)| (name.into_owned(), value.into_owned()))
398        .collect::<Vec<_>>();
399    let mut url = endpoint.clone();
400    url.query_pairs_mut()
401        .clear()
402        .extend_pairs(retained)
403        .append_pair(parameter, value);
404    url
405}
406
407fn endpoint_subject(endpoint: &Url) -> String {
408    let mut subject = endpoint.clone();
409    subject.set_query(None);
410    subject.set_fragment(None);
411    subject.to_string()
412}
413
414fn usable_text_response(response: &BoundedHttpResponse) -> bool {
415    !response.body_truncated()
416        && response.content_type().is_some_and(is_textual_content_type)
417        && std::str::from_utf8(response.body()).is_ok()
418}
419
420fn is_textual_content_type(content_type: &str) -> bool {
421    let media_type = content_type
422        .split(';')
423        .next()
424        .unwrap_or_default()
425        .trim()
426        .to_ascii_lowercase();
427    media_type.starts_with("text/")
428        || matches!(
429            media_type.as_str(),
430            "application/json"
431                | "application/problem+json"
432                | "application/xml"
433                | "application/xhtml+xml"
434        )
435        || media_type.ends_with("+json")
436        || media_type.ends_with("+xml")
437}
438
439fn sql_error_family(body: &[u8]) -> Option<SqlErrorFamily> {
440    let body = std::str::from_utf8(body).ok()?.to_ascii_lowercase();
441    let patterns = [
442        (SqlErrorFamily::SqlState, "sqlstate["),
443        (
444            SqlErrorFamily::MySql,
445            "you have an error in your sql syntax",
446        ),
447        (SqlErrorFamily::MySql, "warning: mysql_"),
448        (SqlErrorFamily::MySql, "mysql_fetch_"),
449        (
450            SqlErrorFamily::PostgreSql,
451            "unterminated quoted string at or near",
452        ),
453        (SqlErrorFamily::PostgreSql, "warning: pg_query"),
454        (SqlErrorFamily::Oracle, "ora-01756"),
455        (
456            SqlErrorFamily::MicrosoftSqlServer,
457            "unclosed quotation mark after the character string",
458        ),
459        (
460            SqlErrorFamily::MicrosoftSqlServer,
461            "microsoft ole db provider for sql server",
462        ),
463        (SqlErrorFamily::Sqlite, "sqlite error"),
464        (SqlErrorFamily::Sqlite, "sqlite_exception"),
465    ];
466    patterns
467        .into_iter()
468        .find_map(|(family, pattern)| body.contains(pattern).then_some(family))
469}
470
471fn reproduced_error_differential(
472    baseline: &BoundedHttpResponse,
473    control: &BoundedHttpResponse,
474    candidate: &BoundedHttpResponse,
475    replay: &BoundedHttpResponse,
476) -> Option<SqlErrorFamily> {
477    if ![baseline, control, candidate, replay]
478        .into_iter()
479        .all(usable_text_response)
480        || sql_error_family(baseline.body()).is_some()
481        || sql_error_family(control.body()).is_some()
482        || candidate.status() != replay.status()
483        || candidate.content_type() != replay.content_type()
484    {
485        return None;
486    }
487    let first = sql_error_family(candidate.body())?;
488    (sql_error_family(replay.body()) == Some(first)).then_some(first)
489}
490
491fn timing_pair_is_comparable(
492    baseline: &BoundedHttpResponse,
493    control: &BoundedHttpResponse,
494    test: &BoundedHttpResponse,
495) -> bool {
496    [baseline, control, test]
497        .into_iter()
498        .all(usable_text_response)
499        && baseline.status() == control.status()
500        && control.status() == test.status()
501        && baseline.content_type() == control.content_type()
502        && control.content_type() == test.content_type()
503        && control.body() == test.body()
504}
505
506fn supported_timing_differential(
507    controls: &[Duration],
508    tests: &[Duration],
509) -> Option<TimingSummary> {
510    if controls.len() != TIMING_SAMPLE_PAIRS || tests.len() != TIMING_SAMPLE_PAIRS {
511        return None;
512    }
513    let control_median = median(controls)?;
514    let test_median = median(tests)?;
515    let control_mad = median_absolute_deviation(controls, control_median)?;
516    let test_mad = median_absolute_deviation(tests, test_median)?;
517    let noise = control_mad.max(test_mad);
518    let required_median = MIN_MEDIAN_DELAY.saturating_add(noise.saturating_mul(4));
519    let paired_support = controls
520        .iter()
521        .zip(tests)
522        .filter(|(control, test)| test.saturating_sub(**control) >= MIN_PAIRED_DELAY)
523        .count();
524
525    (test_median.saturating_sub(control_median) >= required_median && paired_support >= 2)
526        .then_some(TimingSummary {
527            control_median,
528            test_median,
529            control_mad,
530            test_mad,
531        })
532}
533
534fn median(samples: &[Duration]) -> Option<Duration> {
535    if samples.is_empty() {
536        return None;
537    }
538    let mut samples = samples.to_vec();
539    samples.sort_unstable();
540    Some(samples[samples.len() / 2])
541}
542
543fn median_absolute_deviation(samples: &[Duration], sample_median: Duration) -> Option<Duration> {
544    let deviations = samples
545        .iter()
546        .map(|sample| sample.abs_diff(sample_median))
547        .collect::<Vec<_>>();
548    median(&deviations)
549}
550
551fn error_observation(
552    scanner: &SqliScanner,
553    endpoint: &Url,
554    parameter: &str,
555    family: SqlErrorFamily,
556) -> ScanFinding {
557    ScanFinding {
558        phase: scanner.phase_number(),
559        module_name: scanner.name().to_owned(),
560        severity: "INFO".to_owned(),
561        description: format!(
562            "A reproduced, parameter-specific {} appeared only after a quote-shaped probe; manual review is required.",
563            family.label()
564        ),
565        evidence: format!(
566            "endpoint={} parameter={} baseline/control negative; candidate replay matched; this does not establish SQL injection",
567            endpoint_subject(endpoint), parameter
568        ),
569    }
570}
571
572fn timing_observation(
573    scanner: &SqliScanner,
574    endpoint: &Url,
575    parameter: &str,
576    summary: TimingSummary,
577) -> ScanFinding {
578    ScanFinding {
579        phase: scanner.phase_number(),
580        module_name: scanner.name().to_owned(),
581        severity: "INFO".to_owned(),
582        description: "Repeated control/test probes produced a robust timing differential; manual review is required.".to_owned(),
583        evidence: format!(
584            "endpoint={} parameter={} samples={} control_median_ms={} test_median_ms={} control_mad_ms={} test_mad_ms={}; timing alone does not establish SQL injection",
585            endpoint_subject(endpoint),
586            parameter,
587            TIMING_SAMPLE_PAIRS,
588            summary.control_median.as_millis(),
589            summary.test_median.as_millis(),
590            summary.control_mad.as_millis(),
591            summary.test_mad.as_millis()
592        ),
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use std::sync::{
599        atomic::{AtomicUsize, Ordering},
600        Arc, Mutex,
601    };
602
603    use tokio::{
604        io::{AsyncReadExt, AsyncWriteExt},
605        net::TcpListener,
606        task::JoinHandle,
607    };
608
609    use super::*;
610    use crate::VerificationLimits;
611    use termivar_core::{HypothesisState, RunStepStatus, SecuritySeverity};
612    struct FixtureResponse {
613        status: u16,
614        body: String,
615        delay: Duration,
616    }
617
618    impl FixtureResponse {
619        fn ok(body: impl Into<String>) -> Self {
620            Self {
621                status: 200,
622                body: body.into(),
623                delay: Duration::ZERO,
624            }
625        }
626    }
627
628    struct LocalFixture {
629        target: Url,
630        requests: Arc<AtomicUsize>,
631        targets: Arc<Mutex<Vec<String>>>,
632        task: JoinHandle<()>,
633    }
634
635    impl Drop for LocalFixture {
636        fn drop(&mut self) {
637            self.task.abort();
638        }
639    }
640
641    async fn serve_fixture(
642        max_requests: usize,
643        handler: impl Fn(&str) -> FixtureResponse + Send + Sync + 'static,
644    ) -> LocalFixture {
645        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
646        let address = listener.local_addr().unwrap();
647        let requests = Arc::new(AtomicUsize::new(0));
648        let observed_requests = Arc::clone(&requests);
649        let targets = Arc::new(Mutex::new(Vec::new()));
650        let observed_targets = Arc::clone(&targets);
651        let handler = Arc::new(handler);
652        let task = tokio::spawn(async move {
653            for _ in 0..max_requests {
654                let (mut stream, _) = listener.accept().await.unwrap();
655                let mut request = Vec::new();
656                loop {
657                    let mut chunk = [0_u8; 1_024];
658                    let read = stream.read(&mut chunk).await.unwrap();
659                    if read == 0 {
660                        break;
661                    }
662                    request.extend_from_slice(&chunk[..read]);
663                    if request.windows(4).any(|window| window == b"\r\n\r\n") {
664                        break;
665                    }
666                    assert!(request.len() <= 16 * 1_024);
667                }
668                observed_requests.fetch_add(1, Ordering::SeqCst);
669                let target = String::from_utf8_lossy(&request)
670                    .lines()
671                    .next()
672                    .and_then(|line| line.split_ascii_whitespace().nth(1))
673                    .unwrap()
674                    .to_owned();
675                observed_targets
676                    .lock()
677                    .unwrap_or_else(std::sync::PoisonError::into_inner)
678                    .push(target.clone());
679                let response = handler(&target);
680                tokio::time::sleep(response.delay).await;
681                let reason = if response.status == 200 {
682                    "OK"
683                } else {
684                    "Internal Server Error"
685                };
686                let wire = format!(
687                    "HTTP/1.1 {} {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
688                    response.status,
689                    reason,
690                    response.body.len(),
691                    response.body
692                );
693                stream.write_all(wire.as_bytes()).await.unwrap();
694                stream.shutdown().await.unwrap();
695            }
696        });
697        let mut target = Url::parse(&format!("http://{address}/search")).unwrap();
698        target.query_pairs_mut().append_pair("q", "known");
699        LocalFixture {
700            target,
701            requests,
702            targets,
703            task,
704        }
705    }
706
707    fn scan_context(target: Url) -> ScanContext {
708        scan_context_with_requests(target, 10)
709    }
710
711    fn scan_context_with_requests(target: Url, max_requests: u32) -> ScanContext {
712        let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
713        ScanContext::new_with_verification_limits(
714            target,
715            reqwest::Client::new(),
716            telemetry,
717            VerificationLimits::new()
718                .with_max_requests(max_requests)
719                .unwrap(),
720        )
721    }
722
723    fn query_value(target: &str, parameter: &str) -> String {
724        Url::parse(&format!("http://fixture.test{target}"))
725            .unwrap()
726            .query_pairs()
727            .find_map(|(name, value)| (name == parameter).then(|| value.into_owned()))
728            .unwrap_or_default()
729    }
730
731    #[test]
732    fn phase_identity_is_stable() {
733        let scanner = SqliScanner;
734        assert_eq!(scanner.phase_number(), 5);
735        assert_eq!(scanner.name(), "SQL Behavior Observer");
736    }
737
738    #[test]
739    fn generic_syntax_text_is_not_a_database_signature() {
740        assert_eq!(sql_error_family(b"Syntax error"), None);
741        assert_eq!(
742            sql_error_family(b"SQLSTATE[42000]: malformed query"),
743            Some(SqlErrorFamily::SqlState)
744        );
745    }
746
747    #[test]
748    fn robust_timing_requires_repetition_and_rejects_noise() {
749        let controls = [
750            Duration::from_millis(10),
751            Duration::from_millis(11),
752            Duration::from_millis(12),
753        ];
754        let tests = [
755            Duration::from_millis(210),
756            Duration::from_millis(212),
757            Duration::from_millis(211),
758        ];
759        assert!(supported_timing_differential(&controls, &tests).is_some());
760        assert!(supported_timing_differential(&controls[..2], &tests[..2]).is_none());
761
762        let noisy = [
763            Duration::from_millis(10),
764            Duration::from_millis(250),
765            Duration::from_millis(11),
766        ];
767        assert!(supported_timing_differential(&controls, &noisy).is_none());
768    }
769
770    #[test]
771    fn typed_signal_identity_is_independent_of_insertion_order() {
772        let target = Url::parse("https://example.test/").unwrap();
773        let forward_context = scan_context(target.clone());
774        let mut forward = BTreeSet::new();
775        forward.insert(SqlReviewKind::ReproducedDiagnostic);
776        forward.insert(SqlReviewKind::TimingDifferential);
777        let reports = build_sql_review_reports(&forward_context, &forward).unwrap();
778        forward_context
779            .record_legacy_verification_reports(reports)
780            .unwrap();
781
782        let reverse_context = scan_context(target);
783        let mut reverse = BTreeSet::new();
784        reverse.insert(SqlReviewKind::TimingDifferential);
785        reverse.insert(SqlReviewKind::ReproducedDiagnostic);
786        let reports = build_sql_review_reports(&reverse_context, &reverse).unwrap();
787        reverse_context
788            .record_legacy_verification_reports(reports)
789            .unwrap();
790
791        let identity = |context: &ScanContext| {
792            context
793                .legacy_verification_outcomes_since(0)
794                .into_iter()
795                .map(|record| {
796                    (
797                        record.action_id().to_owned(),
798                        record.fingerprint().to_owned(),
799                    )
800                })
801                .collect::<Vec<_>>()
802        };
803        assert_eq!(identity(&forward_context), identity(&reverse_context));
804        assert_eq!(identity(&forward_context).len(), 2);
805    }
806
807    #[tokio::test]
808    async fn single_500_and_generic_syntax_produce_no_observation() {
809        let fixture = serve_fixture(10, |target| {
810            let value = query_value(target, "q");
811            if value.starts_with("'venom-sql-") {
812                FixtureResponse {
813                    status: 500,
814                    body: "Syntax error".to_owned(),
815                    delay: Duration::ZERO,
816                }
817            } else {
818                FixtureResponse::ok("ordinary page")
819            }
820        })
821        .await;
822        let context = scan_context(fixture.target.clone());
823
824        let findings = SqliScanner.execute(&context).await.unwrap();
825
826        assert!(findings.is_empty());
827        assert_eq!(fixture.requests.load(Ordering::SeqCst), 10);
828    }
829
830    #[tokio::test]
831    async fn baseline_database_text_suppresses_every_probe_for_the_endpoint() {
832        let fixture = serve_fixture(1, |_| {
833            FixtureResponse::ok("Troubleshooting example: SQLSTATE[42000]")
834        })
835        .await;
836        let context = scan_context(fixture.target.clone());
837
838        let findings = SqliScanner.execute(&context).await.unwrap();
839
840        assert!(findings.is_empty());
841        assert_eq!(fixture.requests.load(Ordering::SeqCst), 1);
842    }
843
844    #[tokio::test]
845    async fn reproduced_specific_error_is_info_review_observation_only() {
846        let fixture = serve_fixture(10, |target| {
847            let value = query_value(target, "q");
848            if value.starts_with("'venom-sql-") {
849                FixtureResponse {
850                    status: 500,
851                    body: "SQLSTATE[42000]: quoted input rejected".to_owned(),
852                    delay: Duration::ZERO,
853                }
854            } else {
855                FixtureResponse::ok("ordinary page")
856            }
857        })
858        .await;
859        let context = scan_context(fixture.target.clone());
860
861        let findings = SqliScanner.execute(&context).await.unwrap();
862
863        assert_eq!(findings.len(), 1);
864        assert_eq!(findings[0].severity, "INFO");
865        let public_text = format!("{} {}", findings[0].description, findings[0].evidence);
866        assert!(public_text.contains("manual review"));
867        assert!(!public_text.to_ascii_lowercase().contains("confirmed"));
868        assert!(!public_text.to_ascii_lowercase().contains("vulnerab"));
869        assert_eq!(fixture.requests.load(Ordering::SeqCst), 10);
870    }
871
872    #[tokio::test]
873    async fn repeated_timing_differential_is_never_confirmation() {
874        let fixture = serve_fixture(10, |target| {
875            let value = query_value(target, "q");
876            let delay = if value.contains("SLEEP(0.25)") {
877                Duration::from_millis(220)
878            } else {
879                Duration::ZERO
880            };
881            FixtureResponse {
882                status: 200,
883                body: "ordinary page".to_owned(),
884                delay,
885            }
886        })
887        .await;
888        let context = scan_context(fixture.target.clone());
889
890        let findings = SqliScanner.execute(&context).await.unwrap();
891
892        assert_eq!(findings.len(), 1);
893        assert_eq!(findings[0].severity, "INFO");
894        let public_text = format!("{} {}", findings[0].description, findings[0].evidence);
895        assert!(public_text.contains("manual review"));
896        assert!(!public_text.to_ascii_lowercase().contains("confirmed"));
897        assert!(!public_text.to_ascii_lowercase().contains("vulnerab"));
898        assert_eq!(fixture.requests.load(Ordering::SeqCst), 10);
899
900        let targets = fixture
901            .targets
902            .lock()
903            .unwrap_or_else(std::sync::PoisonError::into_inner);
904        let timing_kinds = targets[4..]
905            .iter()
906            .map(|target| query_value(target, "q").contains("SLEEP(0.25)"))
907            .collect::<Vec<_>>();
908        assert_ne!(timing_kinds[0], timing_kinds[1]);
909        assert_ne!(timing_kinds[2], timing_kinds[3]);
910        assert_ne!(timing_kinds[4], timing_kinds[5]);
911        assert_ne!(timing_kinds[0], timing_kinds[2]);
912        assert_eq!(timing_kinds[0], timing_kinds[4]);
913    }
914
915    #[tokio::test]
916    async fn timing_projects_deterministic_needs_review_through_runner() {
917        let fixture = serve_fixture(30, |target| {
918            let value = query_value(target, "q");
919            let delay = if value.contains("SLEEP(0.25)") {
920                Duration::from_millis(220)
921            } else {
922                Duration::ZERO
923            };
924            FixtureResponse {
925                status: 200,
926                body: "ordinary page".to_owned(),
927                delay,
928            }
929        })
930        .await;
931
932        let direct = SqliScanner
933            .execute(&scan_context(fixture.target.clone()))
934            .await
935            .unwrap();
936        assert_eq!(direct.len(), 1);
937        assert_eq!(direct[0].severity, "INFO");
938
939        let mut runner = crate::ScanRunner::new();
940        runner.register_phase(Box::new(SqliScanner));
941        let first_context = scan_context(fixture.target.clone());
942        let first_knowledge = first_context.clone();
943        let first = runner.run_pipeline(first_context).await.unwrap();
944        let second = runner
945            .run_pipeline(scan_context(fixture.target.clone()))
946            .await
947            .unwrap();
948        assert_eq!(first.outcomes().len(), 1);
949        assert_eq!(second.outcomes().len(), 1);
950        let first_review = first
951            .outcomes()
952            .iter()
953            .find(|record| record.disposition() == OutcomeStatus::NeedsReview)
954            .unwrap();
955        let second_review = second
956            .outcomes()
957            .iter()
958            .find(|record| record.disposition() == OutcomeStatus::NeedsReview)
959            .unwrap();
960
961        assert_eq!(first_review.severity(), SecuritySeverity::Info);
962        assert_eq!(first_review.fingerprint(), second_review.fingerprint());
963        assert_eq!(first_review.evidence_ids(), second_review.evidence_ids());
964        assert_eq!(first_review.evidence_ids().len(), 1);
965        let verified = first_review.verification_outcome().unwrap();
966        assert_eq!(verified.status(), OutcomeStatus::NeedsReview);
967        assert_eq!(verified.stage(), VerificationStage::Active);
968        assert_eq!(verified.case_id(), "case:legacy.sql.timing");
969        assert!(verified.verifier_rule_id().is_some());
970        assert_eq!(verified.evidence_ids(), first_review.evidence_ids());
971        assert_eq!(
972            first_knowledge
973                .knowledge()
974                .hypothesis("hypothesis:legacy.sql.timing.review")
975                .unwrap()
976                .state(),
977            HypothesisState::Proposed
978        );
979
980        let encoded = serde_json::to_string(first_review).unwrap();
981        for secret in ["/search", "q=", "known", "SLEEP", "venom-sql-"] {
982            assert!(!encoded.contains(secret));
983        }
984        assert_eq!(fixture.requests.load(Ordering::SeqCst), 30);
985    }
986
987    #[tokio::test]
988    async fn budget_exhaustion_projects_no_typed_outcome() {
989        let fixture = serve_fixture(5, |_| FixtureResponse::ok("ordinary page")).await;
990        let context = scan_context_with_requests(fixture.target.clone(), 5);
991        let retained = context.clone();
992        let mut runner = crate::ScanRunner::new();
993        runner.register_phase(Box::new(SqliScanner));
994
995        let report = runner.run_pipeline(context).await.unwrap();
996
997        assert!(report.outcomes().is_empty());
998        assert_eq!(report.steps()[0].status(), RunStepStatus::BudgetExhausted);
999        assert!(retained.legacy_verification_outcomes_since(0).is_empty());
1000        assert_eq!(fixture.requests.load(Ordering::SeqCst), 5);
1001    }
1002
1003    #[test]
1004    fn url_mutation_replaces_only_the_named_parameter() {
1005        let endpoint = Url::parse("https://example.test/search?q=old&mode=safe").unwrap();
1006        let mutated = replace_parameter(&endpoint, "q", "a&b");
1007        assert_eq!(
1008            mutated.as_str(),
1009            "https://example.test/search?mode=safe&q=a%26b"
1010        );
1011        assert_eq!(endpoint.query(), Some("q=old&mode=safe"));
1012    }
1013}