|
| 1 | +//! Independent command excerpts, not a prompt-injection recall corpus. |
| 2 | +//! See docs/PI079-EVIDENCE.md for provenance and sampling limitations. |
| 3 | +use injection_scanner::allowlist::Suppressions; |
| 4 | +use injection_scanner::pattern::Severity; |
| 5 | +use injection_scanner::patterns::load_embedded_patterns; |
| 6 | +use injection_scanner::scanner::Scanner; |
| 7 | +use serde::Deserialize; |
| 8 | + |
| 9 | +mod common; |
| 10 | + |
| 11 | +#[derive(Deserialize)] |
| 12 | +struct Specimen { |
| 13 | + name: String, |
| 14 | + text: String, |
| 15 | + expected_match: bool, |
| 16 | +} |
| 17 | + |
| 18 | +fn specimens() -> Vec<Specimen> { |
| 19 | + let cases: Vec<Specimen> = |
| 20 | + serde_json::from_str(include_str!("fixtures/pi079-evidence.json")).unwrap(); |
| 21 | + assert_eq!( |
| 22 | + cases.len(), |
| 23 | + 3, |
| 24 | + "update the measured table when specimens change" |
| 25 | + ); |
| 26 | + cases |
| 27 | +} |
| 28 | + |
| 29 | +fn fires(scanner: &Scanner, text: &str) -> bool { |
| 30 | + let report = scanner.scan("specimen.txt", text, &Suppressions::default()); |
| 31 | + report |
| 32 | + .matches |
| 33 | + .iter() |
| 34 | + .chain(&report.low_confidence) |
| 35 | + .any(|m| m.pattern_id == "PI079") |
| 36 | +} |
| 37 | + |
| 38 | +#[test] |
| 39 | +fn independent_commands_record_the_benign_overlap() { |
| 40 | + for specimen in specimens() { |
| 41 | + assert_eq!( |
| 42 | + fires(common::scanner(), &specimen.text), |
| 43 | + specimen.expected_match, |
| 44 | + "{}", |
| 45 | + specimen.name |
| 46 | + ); |
| 47 | + } |
| 48 | + let categories = load_embedded_patterns().unwrap(); |
| 49 | + let pattern = categories |
| 50 | + .iter() |
| 51 | + .flat_map(|c| &c.patterns) |
| 52 | + .find(|p| p.id == "PI079") |
| 53 | + .unwrap(); |
| 54 | + assert_eq!(pattern.severity, Some(Severity::Medium)); |
| 55 | +} |
| 56 | + |
| 57 | +#[test] |
| 58 | +fn threshold_sensitivity_on_independent_commands() { |
| 59 | + // Counts refer to base64 characters AFTER the literal AAAA prefix. |
| 60 | + // Raising the cutoff above 64 suppresses the legitimate Ed25519 example, |
| 61 | + // but also excludes real keys of that size regardless of provenance. |
| 62 | + let cases = specimens(); |
| 63 | + for (minimum, expected) in [ |
| 64 | + (8, [true, true, true]), |
| 65 | + (40, [true, true, false]), |
| 66 | + (64, [true, true, false]), |
| 67 | + (65, [true, false, false]), |
| 68 | + (128, [true, false, false]), |
| 69 | + (512, [false, false, false]), |
| 70 | + ] { |
| 71 | + let mut categories = load_embedded_patterns().unwrap(); |
| 72 | + for category in &mut categories { |
| 73 | + category.patterns.retain(|p| p.id == "PI079"); |
| 74 | + for pattern in &mut category.patterns { |
| 75 | + assert_eq!(pattern.pattern.matches("{40,}").count(), 1); |
| 76 | + pattern.pattern = pattern.pattern.replace("{40,}", &format!("{{{minimum},}}")); |
| 77 | + } |
| 78 | + } |
| 79 | + let scanner = Scanner::new(&categories).unwrap(); |
| 80 | + for (specimen, expected) in cases.iter().zip(expected) { |
| 81 | + assert_eq!( |
| 82 | + fires(&scanner, &specimen.text), |
| 83 | + expected, |
| 84 | + "minimum={minimum}: {}", |
| 85 | + specimen.name |
| 86 | + ); |
| 87 | + } |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +#[test] |
| 92 | +fn threshold_boundary_is_44_total_base64_characters() { |
| 93 | + // Synthetic probes pin mechanics only; they are not independent evidence. |
| 94 | + for (after_prefix, expected) in [(39, false), (40, true), (41, true)] { |
| 95 | + let text = format!( |
| 96 | + "echo 'ssh-ed25519 AAAA{}' >> ~/.ssh/authorized_keys", |
| 97 | + "B".repeat(after_prefix) |
| 98 | + ); |
| 99 | + assert_eq!(fires(common::scanner(), &text), expected, "{after_prefix}"); |
| 100 | + } |
| 101 | +} |
0 commit comments