Skip to main content

assay_sim/attacks/
integrity.rs

1use super::test_bundle::create_single_event_bundle;
2use crate::mutators::inject::InjectFile;
3use crate::mutators::Mutator;
4use crate::report::SimReport;
5use crate::suite::TimeBudget;
6use anyhow::Result as AnyhowResult;
7use assay_evidence::types::EvidenceEvent;
8use assay_evidence::{verify_bundle_with_limits, VerifyError, VerifyLimits};
9use chrono::{TimeZone, Utc};
10use flate2::read::GzEncoder;
11use flate2::Compression;
12use rand::Rng;
13use rand::SeedableRng;
14use std::io::{self, Cursor, Read};
15
16pub fn check_integrity_attacks(
17    report: &mut SimReport,
18    seed: u64,
19    limits: VerifyLimits,
20    budget: &TimeBudget,
21) -> Result<(), IntegrityError> {
22    let valid_bundle = create_single_event_bundle().map_err(IntegrityError::from)?;
23
24    // 1. BitFlip (Harder)
25    run_attack(report, "integrity.bitflip", limits, budget, || {
26        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
27        let mut corrupted = valid_bundle.clone();
28        for _ in 0..10 {
29            let idx = rng.gen_range(0..corrupted.len());
30            corrupted[idx] ^= 1 << rng.gen_range(0..8);
31        }
32        Ok(corrupted)
33    })?;
34
35    // 2. Truncate
36    run_attack(report, "integrity.truncate", limits, budget, || {
37        Ok(valid_bundle[..valid_bundle.len() / 2].to_vec())
38    })?;
39
40    // 3. Inject File
41    run_attack(report, "integrity.inject_file", limits, budget, || {
42        let injector = InjectFile {
43            name: "malicious.sh".into(),
44            content: b"echo 'bad'".to_vec(),
45        };
46        injector.mutate(&valid_bundle)
47    })?;
48
49    // 4. Zip Bomb
50    run_attack(report, "security.zip_bomb", limits, budget, || {
51        create_zip_bomb(1100 * 1024 * 1024)
52    })?;
53
54    // 5. [SOTA 2026] Tar Duplicate Entry
55    run_attack(report, "integrity.tar_duplicate", limits, budget, || {
56        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
57        {
58            let mut builder = tar::Builder::new(&mut encoder);
59            let manifest = serde_json::json!({
60                "schema_version": 1, "run_id": "test", "event_count": 1, "run_root": "sha256:...",
61                "files": { "events.ndjson": { "sha256": "..." } }
62            });
63            let manifest_bytes = serde_json::to_vec(&manifest)?;
64            let mut header = tar::Header::new_gnu();
65            header.set_path("manifest.json")?;
66            header.set_size(manifest_bytes.len() as u64);
67            header.set_cksum();
68            builder.append(&header, manifest_bytes.as_slice())?;
69
70            let event = create_event(0);
71            let event_bytes = serde_json::to_vec(&event)?;
72            for _ in 0..2 {
73                let mut header = tar::Header::new_gnu();
74                header.set_path("events.ndjson")?;
75                header.set_size(event_bytes.len() as u64);
76                header.set_cksum();
77                builder.append(&header, event_bytes.as_slice())?;
78            }
79            builder.finish()?;
80        }
81        Ok(encoder.finish()?)
82    })?;
83
84    // 6. [SOTA 2026] NDJSON Nasties: BOM
85    run_attack(report, "integrity.ndjson_bom", limits, budget, || {
86        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
87        {
88            let mut builder = tar::Builder::new(&mut encoder);
89            let manifest = serde_json::json!({
90                "schema_version": 1, "run_id": "test", "event_count": 1, "run_root": "sha256:...",
91                "files": { "events.ndjson": { "sha256": "..." } }
92            });
93            let manifest_bytes = serde_json::to_vec(&manifest)?;
94            let mut header = tar::Header::new_gnu();
95            header.set_path("manifest.json")?;
96            header.set_size(manifest_bytes.len() as u64);
97            header.set_cksum();
98            builder.append(&header, manifest_bytes.as_slice())?;
99
100            let mut content = vec![0xEF, 0xBB, 0xBF];
101            content.extend_from_slice(&serde_json::to_vec(&create_event(0))?);
102            let mut header = tar::Header::new_gnu();
103            header.set_path("events.ndjson")?;
104            header.set_size(content.len() as u64);
105            header.set_cksum();
106            builder.append(&header, content.as_slice())?;
107            builder.finish()?;
108        }
109        Ok(encoder.finish()?)
110    })?;
111
112    // 7. [SOTA 2026] NDJSON Nasties: CRLF
113    run_attack(report, "integrity.ndjson_crlf", limits, budget, || {
114        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
115        {
116            let mut builder = tar::Builder::new(&mut encoder);
117            // Append with \r\n
118            let mut content = serde_json::to_vec(&create_event(0))?;
119            content.extend_from_slice(b"\r\n");
120
121            let mut header = tar::Header::new_gnu();
122            header.set_path("events.ndjson")?;
123            header.set_size(content.len() as u64);
124            header.set_cksum();
125            builder.append(&header, content.as_slice())?;
126            builder.finish()?;
127        }
128        Ok(encoder.finish()?)
129    })?;
130
131    // 8. limit_bundle_bytes (ADR-024): compressed size = limit + 1, streaming (no alloc)
132    run_attack_reader(
133        report,
134        "integrity.limit_bundle_bytes",
135        limits,
136        budget,
137        || {
138            let n = limits.max_bundle_bytes.saturating_add(1);
139            let src = io::repeat(0u8).take(n);
140            Ok(GzEncoder::new(src, Compression::none()))
141        },
142    )?;
143
144    Ok(())
145}
146
147fn create_event(seq: u64) -> EvidenceEvent {
148    let mut event = EvidenceEvent::new("assay.test", "urn:test", "run", seq, serde_json::json!({}));
149    event.time = Utc.timestamp_opt(1700000000, 0).unwrap();
150    event
151}
152
153#[derive(Debug)]
154#[non_exhaustive]
155pub enum IntegrityError {
156    BudgetExceeded,
157    Other(anyhow::Error),
158}
159impl From<anyhow::Error> for IntegrityError {
160    fn from(e: anyhow::Error) -> Self {
161        Self::Other(e)
162    }
163}
164
165fn run_attack_reader<F, R>(
166    report: &mut SimReport,
167    name: &str,
168    limits: VerifyLimits,
169    budget: &TimeBudget,
170    make_reader: F,
171) -> Result<(), IntegrityError>
172where
173    F: FnOnce() -> AnyhowResult<R>,
174    R: Read,
175{
176    if budget.exceeded() {
177        return Err(IntegrityError::BudgetExceeded);
178    }
179    let reader = make_reader()?;
180    let start = std::time::Instant::now();
181    let res = verify_bundle_with_limits(reader, limits);
182    let duration = start.elapsed().as_millis() as u64;
183
184    match res {
185        Ok(_) => report.add_attack(name, Err(anyhow::anyhow!("Attack Bypassed")), duration),
186        Err(e) => {
187            if let Some(ve) = e.downcast_ref::<VerifyError>() {
188                report.add_attack(name, Ok((ve.class(), ve.code)), duration);
189            } else {
190                report.add_attack(
191                    name,
192                    Err(anyhow::anyhow!("Unexpected error: {}", e)),
193                    duration,
194                );
195            }
196        }
197    }
198
199    if budget.exceeded() {
200        return Err(IntegrityError::BudgetExceeded);
201    }
202    Ok(())
203}
204
205fn run_attack<F>(
206    report: &mut SimReport,
207    name: &str,
208    limits: VerifyLimits,
209    budget: &TimeBudget,
210    mutator: F,
211) -> Result<(), IntegrityError>
212where
213    F: FnOnce() -> AnyhowResult<Vec<u8>>,
214{
215    run_attack_reader(report, name, limits, budget, || {
216        let data = mutator()?;
217        Ok(Cursor::new(data))
218    })
219}
220
221fn create_zip_bomb(target_uncompressed: u64) -> AnyhowResult<Vec<u8>> {
222    use flate2::write::GzEncoder;
223    use flate2::Compression;
224    use std::io::Write;
225
226    let mut buf = Vec::new();
227    let mut encoder = GzEncoder::new(&mut buf, Compression::best());
228    let chunk = vec![0u8; 1024 * 1024];
229    let mut remaining = target_uncompressed;
230    while remaining > 0 {
231        let to_write = remaining.min(chunk.len() as u64);
232        encoder.write_all(&chunk[..to_write as usize])?;
233        remaining -= to_write;
234    }
235    encoder.finish()?;
236    Ok(buf)
237}
238
239/// Run only the limit_bundle_bytes attack. Used by tests to avoid slow zip_bomb.
240#[cfg(test)]
241fn run_limit_bundle_bytes_only(
242    report: &mut SimReport,
243    limits: VerifyLimits,
244    budget: &TimeBudget,
245) -> Result<(), IntegrityError> {
246    run_attack_reader(
247        report,
248        "integrity.limit_bundle_bytes",
249        limits,
250        budget,
251        || {
252            let n = limits.max_bundle_bytes.saturating_add(1);
253            let src = io::repeat(0u8).take(n);
254            Ok(GzEncoder::new(src, Compression::none()))
255        },
256    )
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::report::AttackStatus;
263    use crate::suite::TimeBudget;
264    use assay_evidence::VerifyLimits;
265
266    #[test]
267    fn test_limit_bundle_bytes_blocked_with_limit_bundle_bytes() {
268        // Use limit 100: gzip from 101 zeros is ~1024 bytes compressed, so LimitReader must trigger.
269        // Runs in isolation to avoid slow zip_bomb (1.1GB) in check_integrity_attacks.
270        let limits = VerifyLimits {
271            max_bundle_bytes: 100,
272            ..Default::default()
273        };
274
275        let mut report = SimReport::new("test", 0);
276        let budget = TimeBudget::new(std::time::Duration::from_secs(60));
277
278        run_limit_bundle_bytes_only(&mut report, limits, &budget).unwrap();
279
280        let r = report
281            .results
282            .iter()
283            .find(|r| r.name == "integrity.limit_bundle_bytes")
284            .expect("limit_bundle_bytes result");
285        assert_eq!(r.status, AttackStatus::Blocked);
286        assert_eq!(
287            r.error_code.as_deref(),
288            Some("LimitBundleBytes"),
289            "expected LimitBundleBytes, got {:?}",
290            r.error_code
291        );
292    }
293}