Skip to main content

termivar_scanner/payload_strategies/
ssti_arithmetic_expression_pair.rs

1//! Bounded structural SSTI arithmetic probes.
2//!
3//! The initial closed family uses only small integer multiplication inside
4//! brace-expression delimiters. It performs no calls, traversal, I/O, timing,
5//! or access to runtime objects. Additional families can be added as catalog
6//! entries without changing the differential runtime.
7
8use crate::payload_strategy::{
9    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
10    PayloadStrategyRef, PayloadVariantRole,
11};
12
13pub const SSTI_ARITHMETIC_EXPRESSION_PAIR_ID: &str =
14    "web.review.ssti.brace-arithmetic-expression-pair";
15pub const SSTI_ARITHMETIC_EXPRESSION_PAIR_REVISION: u32 = 1;
16
17/// Closed SSTI syntax-family catalog supported by this build.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub(crate) enum SstiProbeFamily {
20    BraceArithmeticV1,
21}
22
23impl SstiProbeFamily {
24    pub(crate) const fn stable_id(self) -> &'static str {
25        match self {
26            Self::BraceArithmeticV1 => "web.review.ssti.family.brace-arithmetic@1",
27        }
28    }
29}
30
31/// One deterministic, scanner-owned arithmetic probe and its exact outcomes.
32#[derive(Clone, PartialEq, Eq)]
33pub(crate) struct SstiArithmeticProbe {
34    family: SstiProbeFamily,
35    nonce: String,
36    left: u8,
37    right: u8,
38}
39
40impl std::fmt::Debug for SstiArithmeticProbe {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        formatter
43            .debug_struct("SstiArithmeticProbe")
44            .field("family", &self.family.stable_id())
45            .field("encoded_bytes", &self.seed().len())
46            .finish()
47    }
48}
49
50impl SstiArithmeticProbe {
51    pub(crate) fn new(nonce: String, left: u8, right: u8) -> Option<Self> {
52        if !(8..=32).contains(&nonce.len())
53            || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit())
54            || !(2..=12).contains(&left)
55            || !(2..=12).contains(&right)
56        {
57            return None;
58        }
59        Some(Self {
60            family: SstiProbeFamily::BraceArithmeticV1,
61            nonce,
62            left,
63            right,
64        })
65    }
66
67    pub(crate) fn seed(&self) -> String {
68        format!("v1-{}-{}-{}", self.nonce, self.left, self.right)
69    }
70
71    pub(crate) fn control_value(&self) -> String {
72        format!("venom-ssti-{}-control-end", self.nonce)
73    }
74
75    pub(crate) fn candidate_value(&self) -> String {
76        format!(
77            "venom-ssti-{}-{{{{{}*{}}}}}-end",
78            self.nonce, self.left, self.right
79        )
80    }
81
82    pub(crate) fn expected_value(&self) -> String {
83        format!(
84            "venom-ssti-{}-{}-end",
85            self.nonce,
86            u16::from(self.left) * u16::from(self.right)
87        )
88    }
89
90    fn parse(seed: &[u8]) -> Option<Self> {
91        let text = std::str::from_utf8(seed).ok()?;
92        let mut parts = text.split('-');
93        if parts.next()? != "v1" {
94            return None;
95        }
96        let nonce = parts.next()?.to_owned();
97        let left = parts.next()?.parse().ok()?;
98        let right = parts.next()?.parse().ok()?;
99        if parts.next().is_some() {
100            return None;
101        }
102        Self::new(nonce, left, right)
103    }
104}
105
106#[derive(Debug, Clone)]
107pub struct SstiArithmeticExpressionPairStrategy {
108    reference: PayloadStrategyRef,
109}
110
111impl SstiArithmeticExpressionPairStrategy {
112    pub fn new() -> Self {
113        Self {
114            reference: PayloadStrategyRef::new(
115                SSTI_ARITHMETIC_EXPRESSION_PAIR_ID,
116                SSTI_ARITHMETIC_EXPRESSION_PAIR_REVISION,
117            )
118            .expect("the SSTI arithmetic strategy identity is static and valid"),
119        }
120    }
121}
122
123impl Default for SstiArithmeticExpressionPairStrategy {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl PayloadStrategy for SstiArithmeticExpressionPairStrategy {
130    fn strategy_ref(&self) -> &PayloadStrategyRef {
131        &self.reference
132    }
133
134    fn derive_one(
135        &self,
136        role: PayloadVariantRole,
137        seed: &PayloadSeed,
138        limits: PayloadStrategyLimits,
139    ) -> Result<PayloadArtifact, PayloadStrategyError> {
140        let probe = SstiArithmeticProbe::parse(seed.as_bytes())
141            .ok_or(PayloadStrategyError::DerivationFailed)?;
142        let value = match role {
143            PayloadVariantRole::Control => probe.control_value(),
144            PayloadVariantRole::Candidate => probe.candidate_value(),
145        };
146        PayloadArtifact::new(self.reference.clone(), role, value.into_bytes(), limits)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn limits() -> PayloadStrategyLimits {
155        PayloadStrategyLimits::new(128, 128).unwrap()
156    }
157
158    #[test]
159    fn family_is_safe_bounded_and_exactly_correlated() {
160        let probe = SstiArithmeticProbe::new("a1b2c3d4e5f60708".into(), 3, 11).unwrap();
161        let seed = PayloadSeed::new(probe.seed().into_bytes(), limits()).unwrap();
162        let strategy = SstiArithmeticExpressionPairStrategy::new();
163        let control = strategy
164            .derive_one(PayloadVariantRole::Control, &seed, limits())
165            .unwrap();
166        let candidate = strategy
167            .derive_one(PayloadVariantRole::Candidate, &seed, limits())
168            .unwrap();
169        assert_eq!(control.as_bytes(), probe.control_value().as_bytes());
170        assert_eq!(candidate.as_bytes(), probe.candidate_value().as_bytes());
171        assert_eq!(probe.expected_value(), "venom-ssti-a1b2c3d4e5f60708-33-end");
172        for forbidden in [".", "[", "]", "(", ")", "'", "\"", ";", "/"] {
173            assert!(!probe.candidate_value().contains(forbidden));
174        }
175    }
176
177    #[test]
178    fn invalid_or_unbounded_catalog_seeds_fail_closed() {
179        let strategy = SstiArithmeticExpressionPairStrategy::new();
180        for seed in [
181            "v2-a1b2c3d4-3-4",
182            "v1-short-3-4",
183            "v1-a1b2c3d4-1-4",
184            "v1-a1b2c3d4-3-99",
185            "v1-a1b2c3d4-3-4-extra",
186        ] {
187            let seed = PayloadSeed::new(seed.as_bytes().to_vec(), limits()).unwrap();
188            assert!(matches!(
189                strategy.derive_one(PayloadVariantRole::Candidate, &seed, limits()),
190                Err(PayloadStrategyError::DerivationFailed)
191            ));
192        }
193    }
194}