1use 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#[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 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}