Skip to main content

termivar_scanner/phases/
phase7_ssti.rs

1//! # Phase 7: bounded template-arithmetic observations
2//!
3//! This legacy-only phase uses nonce-scoped, non-destructive arithmetic
4//! expressions. It compares an exact expected result with a baseline, a
5//! syntactically similar non-evaluating control, and an exact replay. It does
6//! not identify a template engine, attempt a sandbox escape, or claim code
7//! execution.
8
9use 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/// Legacy template-arithmetic scanner.
34#[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}