Skip to content

Commit 2f672c8

Browse files
test: measure PI079 key-blob threshold against independent specimens (#200)
* test: measure PI079 against independent command specimens (#180) * fix: make PI079 remediation account for authorized key setup (#180) * docs: record PI079 threshold measurements and limitations (#180) --------- Co-authored-by: Jiri Hermann <146172182+hermanngeorge15@users.noreply.github.com>
1 parent 6df7a7b commit 2f672c8

8 files changed

Lines changed: 219 additions & 4 deletions

File tree

‎.github/code-scanning-baseline.json‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2592,6 +2592,20 @@
25922592
"count": 1,
25932593
"first_seen_line": 16
25942594
},
2595+
{
2596+
"file": "tests/fixtures/pi079-evidence.json",
2597+
"pattern_id": "PI079",
2598+
"digest": "sha256:839db62f0f04ef4d370b15177a260c579ef59e578a43788ab8d03b08669b22aa",
2599+
"count": 1,
2600+
"first_seen_line": 7
2601+
},
2602+
{
2603+
"file": "tests/fixtures/pi079-evidence.json",
2604+
"pattern_id": "PI079",
2605+
"digest": "sha256:960665f230ba649103bbbd85dc23fdf13755819fd8a78e872171233ed3f3c64f",
2606+
"count": 1,
2607+
"first_seen_line": 15
2608+
},
25952609
{
25962610
"file": "tools/injection-lab/corpus/01-prose-role-override.md",
25972611
"pattern_id": "PI001",

‎docs/DETECTION-BACKLOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ CRITICAL (D-06); `PI073`, `PI074` and `PI079` are MEDIUM and the other six are H
116116
`patterns/core/persistence-lifecycle-hijack.yaml` for the full set and its header comment, which
117117
carries every accepted-cost measurement named below.
118118

119+
PI079 now has [independent command evidence and a threshold sensitivity test](PI079-EVIDENCE.md)
120+
(#180): a captured hostile key append and a legitimate key-installation example both match.
121+
MEDIUM is retained; the length minimum is still a heuristic, not an optimized classifier.
122+
119123
The five bullets, as shipped:
120124

121125
- `write this to CLAUDE.md`, `append to your memory`, `remember this permanently` — **shipped**:

‎docs/PATTERN-CATALOGUE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2218,7 +2218,7 @@ Does **not** catch:
22182218
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
22192219
```
22202220

2221-
**Fix:** Remove the line. Adding a key to authorized_keys grants login access, so it is the user's decision, made outside the document being read.
2221+
**Fix:** Verify that the user explicitly authorized this key installation outside the document being read. Remove the line if they did not. Legitimate SSH setup instructions can match this rule; the key's length does not establish intent.
22222222

22232223
<details><summary>Regex</summary>
22242224

‎docs/PI079-EVIDENCE.md‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# PI079: independent specimens and threshold sensitivity
2+
3+
Issue [#180](https://github.com/UnityInFlow/injection-scanner/issues/180),
4+
measured 2026-10-22 against the pattern on upstream `edec71c`.
5+
6+
The committed excerpts in `tests/fixtures/pi079-evidence.json` supply one
7+
independent hostile command and two independent benign documentation examples.
8+
They were selected by searching for literal key-installation commands, not
9+
sampled randomly. None was written for this scanner. HTML entities were decoded;
10+
the SANS excerpt selects the contiguous echo-and-append command from a longer
11+
command chain. No key was generated, expanded from an ellipsis, or replaced.
12+
The tests treat these commands as text and never execute them.
13+
14+
| Specimen and source | Classification | Shipped PI079 |
15+
|---|---|---|
16+
| [SANS ISC, Alex Rodriguez's DShield honeypot diary, 2023-11-27](https://isc.sans.edu/diary/30428), captured RSA key append | Hostile command | Match |
17+
| [GeckoAdmin, SSH Key Authentication on Linux: Setup From Any Client](https://geckoadmin.com/ssh-key-authentication-setup/), Windows Ed25519 key installation | Benign documentation with a complete 68-character blob | Match (known false positive) |
18+
| [Extract's SFTP connector documentation](https://docs.extract.to/destinations/sftp), manual key installation with an elided blob | Benign documentation | No match |
19+
20+
These are command excerpts, not independently observed prompt injections. The
21+
SANS article itself is defensive documentation, not a hostile document. Its
22+
captured command is evidence for the command shape only. This does not add an
23+
independent prompt-injection true positive or change the existing recall pins.
24+
The benign overlap is recorded outside the zero-finding clean corpus because
25+
the identical command can be authorized or unauthorized. No existing clean
26+
specimen or suppression was changed to accommodate it.
27+
28+
## Measurement
29+
30+
Run `cargo test --locked --test pi079_evidence_test`. The sensitivity test changes
31+
only PI079's minimum in memory, and uses the production `Scanner`, including its
32+
normalization and decoding passes. It does not alter the shipped regex.
33+
34+
The quantifier counts characters **after** the four literal `AAAA` characters;
35+
the current minimum is therefore 44 base64 characters total, excluding padding.
36+
37+
| Minimum after prefix | Hostile command matched (of 1) | Benign documents matched (of 2) |
38+
|---|---|---|
39+
| 8 | 1 | 2 |
40+
| 40 (shipped) | 1 | 1 |
41+
| 64 | 1 | 1 |
42+
| 65 | 1 | 0 |
43+
| 128 | 1 | 0 |
44+
| 512 | 0 | 0 |
45+
46+
The apparent improvement at 65 comes from excluding the complete Ed25519 key
47+
(64 characters after the prefix). That excludes hostile keys of the same size
48+
too. This small, deliberately selected sample cannot establish an optimal
49+
cutoff, population false-positive rate, or recall rate. The large frozen
50+
GATE-03 sweep remains a separate measurement with zero key-blob lines; these
51+
three excerpts do not retroactively change that result.
52+
53+
## Decision
54+
55+
Retain the minimum and MEDIUM grade. The threshold remains a heuristic for short
56+
elisions, now with measured sensitivity rather than an unsupported claim of
57+
calibration. Length alone cannot separate legitimate key installation from
58+
unauthorized persistence. The independent benign match supports keeping the
59+
grade at MEDIUM under PATTERNS.md rule 3. The remediation now asks the reviewer
60+
to verify explicit user authorization, acknowledging legitimate installations.
61+
62+
The existing regex is not an SSH-key parser: it neither validates decoded key
63+
structure nor guarantees that a long elided blob is rejected. The boundary
64+
test uses synthetic 43/44/45-character blobs only to pin those mechanics, not
65+
as evidence of valid keys. Broader sampling across key types and independent
66+
prompt-injection documents is still needed before claiming calibration.

‎patterns/core/persistence-lifecycle-hijack.yaml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,11 +583,15 @@ patterns:
583583
counter_example: "cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys"
584584
name: persistence-command-with-payload
585585
severity: MEDIUM
586+
# Independent hostile and benign matches: docs/PI079-EVIDENCE.md (#180).
587+
# {40,} counts AFTER AAAA: 44 total base64 characters, excluding padding.
588+
# This is an elision heuristic, not key validation or a measured optimum.
589+
# Keep MEDIUM: real key-installation documentation has the same syntax.
586590
# relaxed_pattern drops the literal-blob requirement and keeps only the
587591
# append into an authorized-keys file, so the counter_example (the tutorial
588592
# line appending a key FILE, with no blob on the line) goes red under it.
589593
relaxed_pattern: "(?:>>\\s*|\\btee\\s+(?:-[a-z]+\\s+)*-[a-z]*a[a-z]*\\s+)[\\\"']?[^\\s\\\"'|;&]{0,40}?authorized_keys2?\\b"
590594
pattern: "(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521))\\s+(?-i:AAAA)[A-Za-z0-9+/]{40,}={0,3}[^\\n]{0,250}?(?:>>\\s*|\\btee\\s+(?:-[a-z]+\\s+)*-[a-z]*a[a-z]*\\s+)[\\\"']?[^\\s\\\"'|;&]{0,40}?authorized_keys2?\\b"
591595
description: "A line carries a literal public-key blob of a named key type and, on the same line, appends it to an authorized-keys file with a redirect or an append-mode tee"
592-
remediation: "Remove the line. Adding a key to authorized_keys grants login access, so it is the user's decision, made outside the document being read."
596+
remediation: "Verify that the user explicitly authorized this key installation outside the document being read. Remove the line if they did not. Legitimate SSH setup instructions can match this rule; the key's length does not establish intent."
593597
tags: [persistence, ssh, authorized-keys]

‎tests/fixtures/pi079-evidence.json‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[
2+
{
3+
"name": "sans-outlaw-command",
4+
"source": "https://isc.sans.edu/diary/30428",
5+
"accessed": "2026-10-22",
6+
"classification": "hostile-command",
7+
"text": "echo \"ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEArDp4cun2lhr4KUhBGE7VvAcwdli2a8dbnrTOrbMz1+5O73fcBOx8NVbUT0bUanUV9tJ2/9p7+vD0EpZ3Tz/+0kX34uAx1RV/75GVOmNx+9EuWOnvNoaJe0QXxziIg9eLBHpgLMuakb5+BgTFB+rKJAw9u9FSTDengvS8hX1kNFS4Mjux0hJOK8rvcEmPecjdySYMb66nylAKGwCEE6WEQHmd1mUPgHwGQ0hWCwsQk13yCGPK5w6hYp5zYkFnvlC8hGmd4Ww+u97k6pfTGTUbJk14ujvcD9iUKQTTWYYjIIu5PmUux5bsZ0R4WFwdIe6+i6rBLAsPKgAySVKPRK+oRw== mdrfckr\">>.ssh/authorized_keys",
8+
"expected_match": true
9+
},
10+
{
11+
"name": "geckoadmin-windows-key-installation",
12+
"source": "https://geckoadmin.com/ssh-key-authentication-setup/",
13+
"accessed": "2026-10-22",
14+
"classification": "benign-documentation",
15+
"text": "echo \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC4FFRxI+IkBZZ5bfUhNcvc/2KpiZt3TuZoXXPB80q5D SSH-from-Windows\" >> ~/.ssh/authorized_keys",
16+
"expected_match": true
17+
},
18+
{
19+
"name": "extract-sftp-elided-key",
20+
"source": "https://docs.extract.to/destinations/sftp",
21+
"accessed": "2026-10-22",
22+
"classification": "benign-documentation",
23+
"text": "echo \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... extract\" >> ~/.ssh/authorized_keys",
24+
"expected_match": false
25+
}
26+
]

‎tests/pattern_test.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1655,8 +1655,8 @@ fn test_pi077_lifecycle_hook_sensitive_command() {
16551655

16561656
#[test]
16571657
fn test_pi079_persistence_command_with_payload() {
1658-
// A real-shaped ed25519 public-key blob (68 base64 characters after the
1659-
// `AAAA` prefix, as the format produces).
1658+
// A real-shaped ed25519 public-key blob (68 base64 characters total,
1659+
// including the four-character `AAAA` prefix).
16601660
assert_positives(
16611661
"PI079",
16621662
&[

‎tests/pi079_evidence_test.rs‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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

Comments
 (0)