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)]
154pub enum IntegrityError {
155    BudgetExceeded,
156    Other(anyhow::Error),
157}
158impl From<anyhow::Error> for IntegrityError {
159    fn from(e: anyhow::Error) -> Self {
160        Self::Other(e)
161    }
162}
163
164fn run_attack_reader<F, R>(
165    report: &mut SimReport,
166    name: &str,
167    limits: VerifyLimits,
168    budget: &TimeBudget,
169    make_reader: F,
170) -> Result<(), IntegrityError>
171where
172    F: FnOnce() -> AnyhowResult<R>,
173    R: Read,
174{
175    if budget.exceeded() {
176        return Err(IntegrityError::BudgetExceeded);
177    }
178    let reader = make_reader()?;
179    let start = std::time::Instant::now();
180    let res = verify_bundle_with_limits(reader, limits);
181    let duration = start.elapsed().as_millis() as u64;
182
183    match res {
184        Ok(_) => report.add_attack(name, Err(anyhow::anyhow!("Attack Bypassed")), duration),
185        Err(e) => {
186            if let Some(ve) = e.downcast_ref::<VerifyError>() {
187                report.add_attack(name, Ok((ve.class(), ve.code)), duration);
188            } else {
189                report.add_attack(
190                    name,
191                    Err(anyhow::anyhow!("Unexpected error: {}", e)),
192                    duration,
193                );
194            }
195        }
196    }
197
198    if budget.exceeded() {
199        return Err(IntegrityError::BudgetExceeded);
200    }
201    Ok(())
202}
203
204fn run_attack<F>(
205    report: &mut SimReport,
206    name: &str,
207    limits: VerifyLimits,
208    budget: &TimeBudget,
209    mutator: F,
210) -> Result<(), IntegrityError>
211where
212    F: FnOnce() -> AnyhowResult<Vec<u8>>,
213{
214    run_attack_reader(report, name, limits, budget, || {
215        let data = mutator()?;
216        Ok(Cursor::new(data))
217    })
218}
219
220fn create_zip_bomb(target_uncompressed: u64) -> AnyhowResult<Vec<u8>> {
221    use flate2::write::GzEncoder;
222    use flate2::Compression;
223    use std::io::Write;
224
225    let mut buf = Vec::new();
226    let mut encoder = GzEncoder::new(&mut buf, Compression::best());
227    let chunk = vec![0u8; 1024 * 1024];
228    let mut remaining = target_uncompressed;
229    while remaining > 0 {
230        let to_write = remaining.min(chunk.len() as u64);
231        encoder.write_all(&chunk[..to_write as usize])?;
232        remaining -= to_write;
233    }
234    encoder.finish()?;
235    Ok(buf)
236}
237
238/// Run only the limit_bundle_bytes attack. Used by tests to avoid slow zip_bomb.
239#[cfg(test)]
240fn run_limit_bundle_bytes_only(
241    report: &mut SimReport,
242    limits: VerifyLimits,
243    budget: &TimeBudget,
244) -> Result<(), IntegrityError> {
245    run_attack_reader(
246        report,
247        "integrity.limit_bundle_bytes",
248        limits,
249        budget,
250        || {
251            let n = limits.max_bundle_bytes.saturating_add(1);
252            let src = io::repeat(0u8).take(n);
253            Ok(GzEncoder::new(src, Compression::none()))
254        },
255    )
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::report::AttackStatus;
262    use crate::suite::TimeBudget;
263    use assay_evidence::VerifyLimits;
264
265    #[test]
266    fn test_limit_bundle_bytes_blocked_with_limit_bundle_bytes() {
267        // Use limit 100: gzip from 101 zeros is ~1024 bytes compressed, so LimitReader must trigger.
268        // Runs in isolation to avoid slow zip_bomb (1.1GB) in check_integrity_attacks.
269        let limits = VerifyLimits {
270            max_bundle_bytes: 100,
271            ..Default::default()
272        };
273
274        let mut report = SimReport::new("test", 0);
275        let budget = TimeBudget::new(std::time::Duration::from_secs(60));
276
277        run_limit_bundle_bytes_only(&mut report, limits, &budget).unwrap();
278
279        let r = report
280            .results
281            .iter()
282            .find(|r| r.name == "integrity.limit_bundle_bytes")
283            .expect("limit_bundle_bytes result");
284        assert_eq!(r.status, AttackStatus::Blocked);
285        assert_eq!(
286            r.error_code.as_deref(),
287            Some("LimitBundleBytes"),
288            "expected LimitBundleBytes, got {:?}",
289            r.error_code
290        );
291    }
292}