1use async_trait::async_trait;
10use termivar_core::{
11 ConfidenceScore, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue, Hypothesis,
12 KnowledgePredicate, OutcomeStatus, Probability, VerificationStage,
13};
14use url::Url;
15use uuid::Uuid;
16
17use crate::{
18 context::ScanContext,
19 contracts::{ScanFinding, ScanPhase},
20 error::ScannerError,
21 http_evidence::HttpProbeMethod,
22 legacy_discovery::BoundedHttpResponse,
23 ActiveVerifier, Expression, KnowledgeLayer, VerificationCase, VerificationReport,
24 VerificationRule,
25};
26
27const SSTI_ACTION_ID: &str = "legacy.observer.template_arithmetic";
28const TEMPLATE_CASE_ID: &str = "case:legacy.template-arithmetic";
29const TEMPLATE_HYPOTHESIS_ID: &str = "hypothesis:legacy.template-arithmetic.review";
30const MAX_SSTI_REQUESTS: usize = 16;
31const REQUESTS_PER_PARAMETER: usize = 3;
32
33#[derive(Debug)]
35pub struct SstiScanner;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct ArithmeticProbe {
39 payload: String,
40 control: String,
41 expected: String,
42}
43
44#[async_trait]
45impl ScanPhase for SstiScanner {
46 fn phase_number(&self) -> u8 {
47 7
48 }
49
50 fn name(&self) -> &'static str {
51 "Template Arithmetic Observer"
52 }
53
54 async fn execute(&self, ctx: &ScanContext) -> Result<Vec<ScanFinding>, ScannerError> {
55 ctx.log("Phase 7: bounded template-arithmetic probes initiated...".to_owned());
56 let snapshot = ctx.discovery_snapshot();
57 let mut findings = Vec::new();
58 let mut accepted_signal = false;
59 let mut requests = 0_usize;
60
61 'endpoints: for (raw_endpoint, parameters) in snapshot.endpoints() {
62 if parameters.is_empty()
63 || MAX_SSTI_REQUESTS.saturating_sub(requests) < REQUESTS_PER_PARAMETER + 1
64 {
65 continue;
66 }
67 let Ok(endpoint) = Url::parse(raw_endpoint) else {
68 continue;
69 };
70 let baseline = request(ctx, endpoint.clone(), &mut requests).await?;
71 if !usable_text_response(&baseline) {
72 continue;
73 }
74
75 for parameter in parameters {
76 if MAX_SSTI_REQUESTS.saturating_sub(requests) < REQUESTS_PER_PARAMETER {
77 break 'endpoints;
78 }
79 let probe = ArithmeticProbe::randomized();
80 let control = request(
81 ctx,
82 replace_parameter(&endpoint, parameter, &probe.control),
83 &mut requests,
84 )
85 .await?;
86 let candidate_url = replace_parameter(&endpoint, parameter, &probe.payload);
87 let candidate = request(ctx, candidate_url.clone(), &mut requests).await?;
88 let replay = request(ctx, candidate_url, &mut requests).await?;
89
90 if supports_template_arithmetic(&baseline, &control, &candidate, &replay, &probe) {
91 findings.push(arithmetic_observation(self, &endpoint, parameter));
92 accepted_signal = true;
93 }
94 }
95 }
96
97 ctx.ensure_legacy_verification_commit(SSTI_ACTION_ID)?;
98 let reports = build_template_review_reports(ctx, accepted_signal)?;
99 ctx.record_legacy_verification_reports(reports)?;
100
101 ctx.log(format!(
102 "Phase 7: bounded template-arithmetic probes completed with {} review observation(s) across {} request(s).",
103 findings.len(), requests
104 ));
105 Ok(findings)
106 }
107}
108
109fn build_template_review_reports(
110 ctx: &ScanContext,
111 accepted_signal: bool,
112) -> Result<Vec<VerificationReport>, ScannerError> {
113 if !accepted_signal {
114 return Ok(Vec::new());
115 }
116 let subject = ctx.legacy_verification_subject()?;
117 let anchor_predicate =
118 KnowledgePredicate::new("legacy.verification", "manual-review-audit-anchor")
119 .map_err(|_| invalid_verification_report())?;
120 let prior = Probability::from_percent(50).map_err(|_| invalid_verification_report())?;
121 let hypothesis = Hypothesis::with_id(
122 TEMPLATE_HYPOTHESIS_ID,
123 subject.clone(),
124 anchor_predicate,
125 EvidenceValue::Text("template-arithmetic-observation-review".to_owned()),
126 prior,
127 )
128 .map_err(|_| invalid_verification_report())?;
129 ctx.knowledge()
130 .upsert_hypothesis(hypothesis)
131 .map_err(|_| invalid_verification_report())?;
132 let baseline = ctx.knowledge().snapshot_for_subject(&subject);
133
134 let evidence = template_review_evidence(&subject)?;
135 ctx.knowledge()
136 .insert_evidence(evidence)
137 .map_err(|_| invalid_verification_report())?;
138 let after_probe = ctx.knowledge().snapshot_for_subject(&subject);
139
140 let case = VerificationCase::new(
141 TEMPLATE_CASE_ID,
142 subject,
143 SSTI_ACTION_ID,
144 TEMPLATE_HYPOTHESIS_ID,
145 )
146 .map_err(|_| invalid_verification_report())?
147 .without_hypothesis_transition();
148 let predicate = KnowledgePredicate::new("legacy.template-arithmetic", "exact-nonce-result")
149 .map_err(|_| invalid_verification_report())?;
150 let rule = VerificationRule::new(
151 "verify:legacy.template-arithmetic.needs-review",
152 VerificationStage::Active,
153 100,
154 Expression::equals(
155 KnowledgeLayer::Evidence,
156 predicate,
157 EvidenceValue::Boolean(true),
158 ),
159 OutcomeStatus::NeedsReview,
160 Probability::from_percent(70).map_err(|_| invalid_verification_report())?,
161 "A replayed exact template-arithmetic result requires manual review",
162 )
163 .map_err(|_| invalid_verification_report())?
164 .scoped_to_action(SSTI_ACTION_ID)
165 .map_err(|_| invalid_verification_report())?
166 .with_case_correlated_evidence()
167 .map_err(|_| invalid_verification_report())?;
168 let mut verifier = ActiveVerifier::new();
169 verifier
170 .register(rule)
171 .map_err(|_| invalid_verification_report())?;
172 let report = verifier
173 .verify_snapshots(&case, &baseline, &after_probe)
174 .map_err(|_| invalid_verification_report())?;
175 Ok(vec![report])
176}
177
178fn template_review_evidence(subject: &termivar_core::EntityId) -> Result<Evidence, ScannerError> {
179 let predicate = KnowledgePredicate::new("legacy.template-arithmetic", "exact-nonce-result")
180 .map_err(|_| invalid_verification_report())?;
181 let source = EvidenceSource::new(SSTI_ACTION_ID, "exact-arithmetic-review")
182 .and_then(|source| source.with_correlation_id(TEMPLATE_CASE_ID))
183 .map_err(|_| invalid_verification_report())?;
184 let evidence_id = EvidenceId::parse("evidence:legacy.template-arithmetic")
185 .map_err(|_| invalid_verification_report())?;
186 Ok(Evidence::with_id_at(
187 evidence_id,
188 subject.clone(),
189 EvidenceKind::Content,
190 predicate,
191 EvidenceValue::Boolean(true),
192 source,
193 ConfidenceScore::from_percent(70).map_err(|_| invalid_verification_report())?,
194 0,
195 ))
196}
197
198const fn invalid_verification_report() -> ScannerError {
199 ScannerError::InvalidLegacyVerificationReport
200}
201
202impl ArithmeticProbe {
203 fn randomized() -> Self {
204 let random = Uuid::new_v4().as_u128();
205 let left = 101 + u32::try_from(random & 0x7f).unwrap_or_default();
206 let right = 211 + u32::try_from((random >> 16) & 0x7f).unwrap_or_default();
207 let nonce = Uuid::new_v4().simple().to_string();
208 Self::new(left, right, &nonce)
209 }
210
211 fn new(left: u32, right: u32, nonce: &str) -> Self {
212 let prefix = format!("venom-ssti-{nonce}-begin-");
213 let suffix = format!("-end-{nonce}");
214 let expression = format!("{left}*{right}");
215 let payload = [prefix.as_str(), "{{", expression.as_str(), "}}", &suffix].concat();
216 let control = [prefix.as_str(), "{{", expression.as_str(), "}", &suffix].concat();
217 let product = left.saturating_mul(right).to_string();
218 let expected = [prefix.as_str(), product.as_str(), suffix.as_str()].concat();
219 Self {
220 payload,
221 control,
222 expected,
223 }
224 }
225}
226
227async fn request(
228 ctx: &ScanContext,
229 url: Url,
230 requests: &mut usize,
231) -> Result<BoundedHttpResponse, ScannerError> {
232 debug_assert!(*requests < MAX_SSTI_REQUESTS);
233 *requests += 1;
234 ctx.verification_request(SSTI_ACTION_ID, HttpProbeMethod::Get, url)
235 .await
236}
237
238fn replace_parameter(endpoint: &Url, parameter: &str, value: &str) -> Url {
239 let retained = endpoint
240 .query_pairs()
241 .filter(|(name, _)| name != parameter)
242 .map(|(name, value)| (name.into_owned(), value.into_owned()))
243 .collect::<Vec<_>>();
244 let mut url = endpoint.clone();
245 url.query_pairs_mut()
246 .clear()
247 .extend_pairs(retained)
248 .append_pair(parameter, value);
249 url
250}
251
252fn endpoint_subject(endpoint: &Url) -> String {
253 let mut subject = endpoint.clone();
254 subject.set_query(None);
255 subject.set_fragment(None);
256 subject.to_string()
257}
258
259fn usable_text_response(response: &BoundedHttpResponse) -> bool {
260 (200..300).contains(&response.status())
261 && !response.body_truncated()
262 && response.content_type().is_some_and(is_textual_content_type)
263 && std::str::from_utf8(response.body()).is_ok()
264}
265
266fn is_textual_content_type(content_type: &str) -> bool {
267 let media_type = content_type
268 .split(';')
269 .next()
270 .unwrap_or_default()
271 .trim()
272 .to_ascii_lowercase();
273 media_type.starts_with("text/")
274 || matches!(
275 media_type.as_str(),
276 "application/json"
277 | "application/problem+json"
278 | "application/xml"
279 | "application/xhtml+xml"
280 )
281 || media_type.ends_with("+json")
282 || media_type.ends_with("+xml")
283}
284
285fn supports_template_arithmetic(
286 baseline: &BoundedHttpResponse,
287 control: &BoundedHttpResponse,
288 candidate: &BoundedHttpResponse,
289 replay: &BoundedHttpResponse,
290 probe: &ArithmeticProbe,
291) -> bool {
292 if ![baseline, control, candidate, replay]
293 .into_iter()
294 .all(usable_text_response)
295 || ![control, candidate, replay].into_iter().all(|response| {
296 response.status() == baseline.status()
297 && response.content_type() == baseline.content_type()
298 })
299 || candidate.body() != replay.body()
300 {
301 return false;
302 }
303
304 let expected = probe.expected.as_bytes();
305 !contains_bytes(baseline.body(), expected)
306 && !contains_bytes(control.body(), expected)
307 && contains_exactly_once(candidate.body(), expected)
308 && contains_exactly_once(replay.body(), expected)
309 && !contains_bytes(candidate.body(), probe.payload.as_bytes())
310 && !contains_bytes(replay.body(), probe.payload.as_bytes())
311}
312
313fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
314 !needle.is_empty()
315 && haystack
316 .windows(needle.len())
317 .any(|window| window == needle)
318}
319
320fn contains_exactly_once(haystack: &[u8], needle: &[u8]) -> bool {
321 !needle.is_empty()
322 && haystack
323 .windows(needle.len())
324 .filter(|window| *window == needle)
325 .take(2)
326 .count()
327 == 1
328}
329
330fn arithmetic_observation(scanner: &SstiScanner, endpoint: &Url, parameter: &str) -> ScanFinding {
331 ScanFinding {
332 phase: scanner.phase_number(),
333 module_name: scanner.name().to_owned(),
334 severity: "INFO".to_owned(),
335 description: "A nonce-scoped arithmetic expression produced its exact expected result while the baseline and malformed-expression control did not; manual review is required.".to_owned(),
336 evidence: format!(
337 "endpoint={} parameter={} candidate replay matched; no template-engine attribution or code-execution claim",
338 endpoint_subject(endpoint), parameter
339 ),
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use std::sync::{
346 atomic::{AtomicUsize, Ordering},
347 Arc,
348 };
349
350 use tokio::{
351 io::{AsyncReadExt, AsyncWriteExt},
352 net::TcpListener,
353 task::JoinHandle,
354 };
355
356 use super::*;
357 use crate::VerificationLimits;
358 use termivar_core::HypothesisState;
359
360 struct LocalFixture {
361 target: Url,
362 requests: Arc<AtomicUsize>,
363 task: JoinHandle<()>,
364 }
365
366 impl Drop for LocalFixture {
367 fn drop(&mut self) {
368 self.task.abort();
369 }
370 }
371
372 async fn serve_fixture(
373 max_requests: usize,
374 handler: impl Fn(&str) -> String + Send + Sync + 'static,
375 ) -> LocalFixture {
376 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
377 let address = listener.local_addr().unwrap();
378 let requests = Arc::new(AtomicUsize::new(0));
379 let observed_requests = Arc::clone(&requests);
380 let handler = Arc::new(handler);
381 let task = tokio::spawn(async move {
382 for _ in 0..max_requests {
383 let (mut stream, _) = listener.accept().await.unwrap();
384 let mut request = Vec::new();
385 loop {
386 let mut chunk = [0_u8; 1_024];
387 let read = stream.read(&mut chunk).await.unwrap();
388 if read == 0 {
389 break;
390 }
391 request.extend_from_slice(&chunk[..read]);
392 if request.windows(4).any(|window| window == b"\r\n\r\n") {
393 break;
394 }
395 assert!(request.len() <= 16 * 1_024);
396 }
397 observed_requests.fetch_add(1, Ordering::SeqCst);
398 let target = String::from_utf8_lossy(&request)
399 .lines()
400 .next()
401 .and_then(|line| line.split_ascii_whitespace().nth(1))
402 .unwrap()
403 .to_owned();
404 let body = handler(&target);
405 let wire = format!(
406 "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
407 body.len(), body
408 );
409 stream.write_all(wire.as_bytes()).await.unwrap();
410 stream.shutdown().await.unwrap();
411 }
412 });
413 let mut target = Url::parse(&format!("http://{address}/render")).unwrap();
414 target.query_pairs_mut().append_pair("name", "known");
415 LocalFixture {
416 target,
417 requests,
418 task,
419 }
420 }
421
422 fn scan_context(target: Url) -> ScanContext {
423 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
424 ScanContext::new_with_verification_limits(
425 target,
426 reqwest::Client::new(),
427 telemetry,
428 VerificationLimits::new().with_max_requests(4).unwrap(),
429 )
430 }
431
432 fn query_value(target: &str, parameter: &str) -> String {
433 Url::parse(&format!("http://fixture.test{target}"))
434 .unwrap()
435 .query_pairs()
436 .find_map(|(name, value)| (name == parameter).then(|| value.into_owned()))
437 .unwrap_or_default()
438 }
439
440 fn evaluate_arithmetic_payload(value: &str) -> String {
441 let Some(open) = value.find("{{") else {
442 return value.to_owned();
443 };
444 let Some(relative_close) = value[open + 2..].find("}}") else {
445 return value.to_owned();
446 };
447 let close = open + 2 + relative_close;
448 let expression = &value[open + 2..close];
449 let Some((left, right)) = expression.split_once('*') else {
450 return value.to_owned();
451 };
452 let (Ok(left), Ok(right)) = (left.parse::<u32>(), right.parse::<u32>()) else {
453 return value.to_owned();
454 };
455 format!("{}{}{}", &value[..open], left * right, &value[close + 2..])
456 }
457
458 #[test]
459 fn phase_identity_is_stable() {
460 let scanner = SstiScanner;
461 assert_eq!(scanner.phase_number(), 7);
462 assert_eq!(scanner.name(), "Template Arithmetic Observer");
463 }
464
465 #[test]
466 fn arithmetic_probe_uses_variable_operands_and_exact_nonce_marker() {
467 let first = ArithmeticProbe::new(113, 227, "first");
468 let second = ArithmeticProbe::new(127, 229, "second");
469 assert_eq!(first.expected, "venom-ssti-first-begin-25651-end-first");
470 assert_eq!(
471 first.payload,
472 "venom-ssti-first-begin-{{113*227}}-end-first"
473 );
474 assert_eq!(first.control, "venom-ssti-first-begin-{{113*227}-end-first");
475 assert_ne!(first.expected, second.expected);
476 assert!(!first.expected.contains("49"));
477 }
478
479 #[test]
480 fn common_number_and_raw_reflection_do_not_match_exact_result() {
481 let probe = ArithmeticProbe::new(113, 227, "nonce");
482 assert!(!contains_bytes(
483 b"ordinary page 49",
484 probe.expected.as_bytes()
485 ));
486 assert!(!contains_bytes(
487 probe.payload.as_bytes(),
488 probe.expected.as_bytes()
489 ));
490 }
491
492 #[tokio::test]
493 async fn common_49_page_produces_no_observation() {
494 let fixture = serve_fixture(4, |_| "documentation example: 7 * 7 = 49".to_owned()).await;
495 let context = scan_context(fixture.target.clone());
496
497 let findings = SstiScanner.execute(&context).await.unwrap();
498
499 assert!(findings.is_empty());
500 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
501 }
502
503 #[tokio::test]
504 async fn inert_payload_reflection_produces_no_observation() {
505 let fixture = serve_fixture(4, |target| query_value(target, "name")).await;
506 let context = scan_context(fixture.target.clone());
507
508 let findings = SstiScanner.execute(&context).await.unwrap();
509
510 assert!(findings.is_empty());
511 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
512 }
513
514 #[tokio::test]
515 async fn exact_replayed_arithmetic_result_is_info_review_observation_only() {
516 let fixture = serve_fixture(4, |target| {
517 evaluate_arithmetic_payload(&query_value(target, "name"))
518 })
519 .await;
520 let context = scan_context(fixture.target.clone());
521
522 let findings = SstiScanner.execute(&context).await.unwrap();
523
524 assert_eq!(findings.len(), 1);
525 assert_eq!(findings[0].severity, "INFO");
526 let public_text = format!("{} {}", findings[0].description, findings[0].evidence);
527 assert!(public_text.contains("manual review"));
528 assert!(!public_text.to_ascii_lowercase().contains("confirmed"));
529 assert!(!public_text.to_ascii_lowercase().contains("vulnerab"));
530 assert!(!public_text.contains("{{"));
531 assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
532 }
533
534 #[tokio::test]
535 async fn randomized_operands_keep_typed_projection_stable_and_private() {
536 let fixture = serve_fixture(8, |target| {
537 evaluate_arithmetic_payload(&query_value(target, "name"))
538 })
539 .await;
540 let first_context = scan_context(fixture.target.clone());
541 let second_context = scan_context(fixture.target.clone());
542
543 SstiScanner.execute(&first_context).await.unwrap();
544 SstiScanner.execute(&second_context).await.unwrap();
545
546 let first = first_context.legacy_verification_outcomes_since(0);
547 let second = second_context.legacy_verification_outcomes_since(0);
548 assert_eq!(first.len(), 1);
549 assert_eq!(second.len(), 1);
550 assert_eq!(first[0].fingerprint(), second[0].fingerprint());
551 assert_eq!(first[0].disposition(), OutcomeStatus::NeedsReview);
552 assert_eq!(first[0].evidence_ids(), second[0].evidence_ids());
553 let verified = first[0].verification_outcome().unwrap();
554 assert_eq!(verified.case_id(), TEMPLATE_CASE_ID);
555 assert_eq!(verified.stage(), VerificationStage::Active);
556 assert_eq!(
557 first_context
558 .knowledge()
559 .hypothesis(TEMPLATE_HYPOTHESIS_ID)
560 .unwrap()
561 .state(),
562 HypothesisState::Proposed
563 );
564 let encoded = serde_json::to_string(&first[0]).unwrap();
565 for secret in ["/render", "name=", "known", "{{", "venom-ssti-"] {
566 assert!(!encoded.contains(secret));
567 }
568 assert_eq!(fixture.requests.load(Ordering::SeqCst), 8);
569 }
570
571 #[test]
572 fn url_mutation_replaces_only_the_named_parameter() {
573 let endpoint = Url::parse("https://example.test/render?name=old&mode=safe").unwrap();
574 let mutated = replace_parameter(&endpoint, "name", "a&b");
575 assert_eq!(
576 mutated.as_str(),
577 "https://example.test/render?mode=safe&name=a%26b"
578 );
579 assert_eq!(endpoint.query(), Some("name=old&mode=safe"));
580 }
581}