Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions internal/danger/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -7118,6 +7118,21 @@ func isInstall(first string, tokens []string) bool {
}
}

// pip wheel / pip download build the requested project from source,
// running its setup.py or PEP 517 build backend — arbitrary code, the
// same exposure as `pip install`. They are absent from the verb switch
// above, so without this they fell through to Safe (auto-allow). Match
// only the subcommand (the first non-flag token) so `pip show wheel`,
// where `wheel` is a package-name argument, stays an ordinary query.
if first == "pip" || first == "pip3" {
for _, tok := range tokens[1:] {
if strings.HasPrefix(tok, "-") {
continue
}
return tok == "wheel" || tok == "download"
}
}

// cargo install
if first == "cargo" {
return hasArgAfter(tokens, "cargo", "install")
Expand Down
94 changes: 94 additions & 0 deletions internal/danger/command_effects.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ func adapterRunsCode(name string, tokens []string, repo *gitRepoCtx) bool {
return gitVerbRunsRepoCode(sub, args, tokens, repo)
}
}
if name == "pandoc" && pandocRunsProgram(tokens) {
return true
}
if name == "ssh-keygen" {
// -D <lib> loads a PKCS#11 shared library to enumerate its keys and
// -w <lib> loads a FIDO middleware library; either runs attacker-chosen
// native code at load time (a classic GTFOBins escape). ssh-keygen is a
// write prefix, so without this gate `ssh-keygen -D ./evil.so` read as
// local_write and auto-allowed.
if optionPresent(tokens, "-D", "-w") {
return true
}
}
if name == "openssl" && opensslLoadsModule(tokens) {
return true
}
if name == "curl" && (optionPresent(tokens, "-K", "--config") || curlSyntax.parse(tokens[1:]).has("--config")) {
return true
}
Expand All @@ -98,6 +114,84 @@ func adapterRunsCode(name string, tokens []string, repo *gitRepoCtx) bool {
return false
}

// valueIsPathLike reports whether an option value names a filesystem path the
// caller controls (an absolute path, a ./ or ../ relative path, or any value
// containing a slash) rather than a bare name resolved from a trusted search
// directory. It is used to tell an attacker-planted module/program path
// (`-engine ./evil.so`, `--pdf-engine=/tmp/x`) from a trusted bare name
// (`-provider legacy`, `--pdf-engine xelatex`) that loads from a system
// location the agent cannot write.
func valueIsPathLike(v string) bool {
if v == "" {
return false
}
return strings.ContainsRune(v, '/') || strings.HasPrefix(v, ".")
}

// optionValue returns the value of option opt at tokens[i]: a fused
// `--opt=value`, or the following token for the separate `--opt value` form.
func optionValue(tokens []string, i int, opt string) (string, bool) {
tok := tokens[i]
if tok == opt {
if i+1 < len(tokens) {
return tokens[i+1], true
}
return "", true
}
if strings.HasPrefix(tok, opt+"=") {
return tok[len(opt)+1:], true
}
return "", false
}

// pandocRunsProgram reports whether a pandoc invocation shells out to an
// attacker-chosen program or script. --filter/-F names an external JSON
// filter and --lua-filter names a Lua script (pandoc's Lua exposes
// os.execute); both run whatever they name, so any value escalates.
// --pdf-engine names the PDF backend pandoc exec's — a bare engine name
// (`xelatex`) is a trusted PATH lookup, but a path value runs that binary
// directly, so only a path-like value escalates.
func pandocRunsProgram(tokens []string) bool {
if optionPresent(tokens, "--filter", "-F", "--lua-filter") {
return true
}
for i := 1; i < len(tokens); i++ {
if v, ok := optionValue(tokens, i, "--pdf-engine"); ok && valueIsPathLike(v) {
return true
}
}
return false
}

// opensslLoadsModule reports whether an openssl invocation loads native code
// of the caller's choosing. -engine / -provider run a shared module's
// initializer on load; a bare module name (`-provider legacy`) resolves from
// the trusted system module directory, but a path value loads an
// attacker-planted .so, so only a path-like value escalates. -provider-path
// redirects that search directory itself, so any use escalates (a later bare
// name then loads from the attacker location). -config names a configuration
// file whose openssl_conf section can activate an arbitrary engine/provider
// module; the file is uninspectable here, so it fails closed like curl -K.
func opensslLoadsModule(tokens []string) bool {
for i := 1; i < len(tokens); i++ {
tok := tokens[i]
switch {
case tok == "-provider-path" || strings.HasPrefix(tok, "-provider-path="):
return true
case tok == "-config" || tok == "--config" ||
strings.HasPrefix(tok, "-config=") || strings.HasPrefix(tok, "--config="):
return true
}
if v, ok := optionValue(tokens, i, "-engine"); ok && valueIsPathLike(v) {
return true
}
if v, ok := optionValue(tokens, i, "-provider"); ok && valueIsPathLike(v) {
return true
}
}
return false
}

// sqliteWritefileCall and sqliteVacuumInto locate the SQL forms of the sqlite3
// shell that write a file at a path the statement names: writefile(PATH, …)
// and VACUUM [schema] INTO PATH, the schema name bare or quoted in any SQL
Expand Down
157 changes: 157 additions & 0 deletions internal/danger/redhunt_2026_10_21_danger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package danger

import "testing"

// hunt21Action evaluates cmd with the stock policy.
func hunt21Action(cmd string) Action {
return (&DangerousConfig{}).ActionForCommand(cmd)
}

// hunt21CodeExec asserts every command classifies as at least code_execution
// and is not auto-allowed under the default policy.
func hunt21CodeExec(t *testing.T, cmds ...string) {
t.Helper()
for _, cmd := range cmds {
if got := Classify(cmd); Rank(got) < Rank(CodeExecution) {
t.Errorf("Classify(%q) = %s, want at least code_execution", cmd, got)
}
if act := hunt21Action(cmd); act == Allow {
t.Errorf("ActionForCommand(%q) = allow under the default policy", cmd)
}
}
}

// hunt21Allowed asserts a benign form stays auto-allowed, so the escalations
// below do not over-fire on ordinary use.
func hunt21Allowed(t *testing.T, cmds ...string) {
t.Helper()
for _, cmd := range cmds {
if act := hunt21Action(cmd); act != Allow {
t.Errorf("ActionForCommand(%q) = %s, want allow", cmd, act)
}
}
}

// ssh-keygen loads attacker-chosen native code through two option values:
// -D <lib> enumerates keys in a PKCS#11 shared library, and -w <lib> selects
// a FIDO middleware library for sk-* key generation. Both run the module's
// initializer on load, a classic GTFOBins escape. ssh-keygen is a write
// prefix, so the forms otherwise read as local_write and auto-allowed.
func TestRED_SSHKeygenSharedLibraryLoadIsCodeExecution(t *testing.T) {
t.Setenv("HOME", "/home/user")
t.Chdir(t.TempDir())
hunt21CodeExec(t,
`ssh-keygen -D /tmp/evil.so`,
`ssh-keygen -D/tmp/evil.so`,
`ssh-keygen -D pkcs11.so -e`,
`ssh-keygen -w /tmp/evil.so -t ed25519-sk`,
`ssh-keygen -w/tmp/evil.so`,
)
// Ordinary key generation and inspection stay allowed.
hunt21Allowed(t,
`ssh-keygen -t ed25519 -f key -N ''`,
`ssh-keygen -l -f key.pub`,
`ssh-keygen -y -f key`,
)
}

// openssl loads a shared library into its own process through -engine and
// -provider when the value names a path (an attacker-planted .so), through
// -provider-path (which redirects the module search directory so a later bare
// name loads from the attacker location), and through -config (whose
// openssl_conf section can activate an arbitrary engine/provider module, the
// same uninspectable-config escape as curl -K). openssl is a network prefix,
// so these forms otherwise fell through to network_egress and auto-allow.
func TestRED_OpenSSLModuleLoadIsCodeExecution(t *testing.T) {
t.Setenv("HOME", "/home/user")
t.Chdir(t.TempDir())
hunt21CodeExec(t,
`openssl req -engine /tmp/x.so`,
`openssl req -engine=/tmp/x.so`,
`openssl req -engine ./evil.so`,
`openssl dgst -provider /tmp/x.so -sha256 f`,
`openssl dgst -provider=./x.so -sha256 f`,
`openssl dgst -provider-path /tmp/lib -provider x -sha256 f`,
`openssl req -config /tmp/evil.cnf -new`,
`openssl req --config=/tmp/evil.cnf`,
)
// Local digest/encode and metadata forms stay allowed, and so do the
// built-in modules loaded by bare name from the trusted system directory
// (`-provider legacy`, `-engine gost`) — the common OpenSSL 3 idiom.
hunt21Allowed(t,
`openssl dgst -sha256 file`,
`openssl version`,
`openssl rand -hex 16`,
`openssl dgst -provider legacy -sha256 file`,
`openssl pkcs12 -provider legacy -in x`,
`openssl enc -provider default -aes-256-cbc -in a -out b`,
`openssl dgst -engine gost -sha256 file`,
)
}

// pandoc runs attacker-chosen code through its program-naming options:
// --filter/-F runs an arbitrary external program as a JSON filter,
// --lua-filter runs a Lua script in pandoc's interpreter (which exposes
// os.execute), and --pdf-engine runs the named PDF backend — a path value
// executes an attacker binary, while a bare engine name (`xelatex`) is a
// trusted PATH lookup. pandoc is a write prefix, so the escaping forms
// otherwise read as local_write and auto-allowed.
func TestRED_PandocFilterIsCodeExecution(t *testing.T) {
t.Setenv("HOME", "/home/user")
t.Chdir(t.TempDir())
hunt21CodeExec(t,
`pandoc --lua-filter=/tmp/x.lua a.md`,
`pandoc --lua-filter /tmp/x.lua a.md`,
`pandoc --filter=/tmp/evil a.md -o b.html`,
`pandoc --filter /tmp/evil a.md`,
`pandoc -F /tmp/evil a.md`,
`pandoc a.md --pdf-engine=/tmp/evil -o b.pdf`,
`pandoc a.md --pdf-engine ./evil -o b.pdf`,
)
// Ordinary conversion stays allowed, including a bare --pdf-engine name
// that resolves to a trusted binary on PATH.
hunt21Allowed(t,
`pandoc a.md -o b.pdf`,
`pandoc -s a.md -o b.html`,
`pandoc --version`,
`pandoc a.md --pdf-engine xelatex -o b.pdf`,
)
}

// pip wheel / pip download build the requested project from source, running
// its setup.py or PEP 517 build backend — the same arbitrary-code exposure as
// pip install. They are absent from the install-verb switch, so without a
// dedicated rule they fell through to Safe (auto-allow) while `pip install .`
// prompted.
func TestRED_PipBuildFromSourceRunsCode(t *testing.T) {
t.Setenv("HOME", "/home/user")
t.Chdir(t.TempDir())
// Classified Install (prompt), the same class as `pip install .`; the
// point is that they are no longer auto-allowed.
for _, cmd := range []string{
`pip wheel .`,
`pip wheel evilpkg`,
`pip download .`,
`pip download evilpkg`,
`pip3 wheel .`,
`pip3 download .`,
} {
if got := Classify(cmd); Rank(got) < Rank(Install) {
t.Errorf("Classify(%q) = %s, want at least install", cmd, got)
}
if act := hunt21Action(cmd); act == Allow {
t.Errorf("ActionForCommand(%q) = allow under the default policy", cmd)
}
}
// Read-only pip queries stay allowed — including ones that pass `wheel`
// or `download` as a package-name argument rather than the subcommand.
hunt21Allowed(t,
`pip list`,
`pip show requests`,
`pip --version`,
`pip freeze`,
`pip show wheel`,
`pip index versions wheel`,
`pip3 show download`,
)
}
Loading