| Version | Supported |
|---|---|
| latest | ✅ |
If you discover a security vulnerability in kojuto, please report it responsibly.
Do NOT open a public issue.
Instead, please use GitHub's private vulnerability reporting feature.
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We will acknowledge receipt within 48 hours and aim to provide a fix within 7 days for critical issues.
kojuto is a security tool that intentionally runs untrusted code in an isolated environment. The following are considered valid security concerns:
- Sandbox escape — any way for analyzed packages to affect the host system
- eBPF probe vulnerabilities — issues in the kernel-level monitoring code
- Detection bypass — attack patterns that evade both dynamic and static analysis
- False negatives — attack patterns that bypass detection (please report as feature requests unless they indicate a fundamental design flaw)
- Packages run in Docker containers with
--network=none(zero network connectivity — no bridge interface, no Docker embedded DNS resolver, no attack surface) - Filesystem is read-only with targeted tmpfs mounts for writable paths
--cap-drop=ALLremoves all Linux capabilities;SYS_PTRACE,CHOWN, andFOWNERare re-added only when needed--no-new-privilegesprevents privilege escalation- Custom seccomp profile always applied (regardless of probe method), blocking
mount,unshare,setns,bpf,io_uring_setup/io_uring_enter/io_uring_register,prctl(PR_SET_NAME),ptrace(PTRACE_ATTACH)/ptrace(PTRACE_SEIZE), and 50+ other dangerous syscalls.memfd_createis intentionally allowed — theexecveat(fd, "", ..., AT_EMPTY_PATH)fileless-loader pattern is caught downstream (parser synthesizesComm=/proc/self/fd/<n>which matchessuspiciousExecDirsHIGH), so blocking it silently would remove evidence rather than provide it. Blocking theio_uringfamily closes an evasion path that would otherwise dispatchopenat/connect/send/unlinkatthrough SQEs, entirely bypassing strace'ssys_enter_*tracepoints- The profile is ALLOW-by-default.
--security-opt seccomp=<file>replaces Docker's stock deny-by-default profile rather than layering on it, so the deny list above is the entire set of syscalls blocked — anything not named there is permitted, including syscalls Docker would have blocked. The list covers everything reachable without a capability kojuto does not grant; what remains open would fail withEPERMagainst--cap-drop=ALL. Any newly discovered escape or detection-bypass syscall must be added explicitly. archMapcovers x86_64 (+x86, +x32) and aarch64 (+arm). This is load-bearing, not cosmetic: libseccomp resolves syscall names for one architecture, so a filter without arch coverage matches only the native ABI and every deny above could be bypassed by shipping a static 32-bit binary or issuing the x32 form of a syscall. Hosts on an architecture absent from the map fall back to native-only coverage.
- The profile is ALLOW-by-default.
SYS_PTRACEis required for in-container strace, and is genuinely powerful. The seccomp profile deniesPTRACE_ATTACHandPTRACE_SEIZE, which is what prevents a package from getting a handle on a process it did not fork — including the strace process observing it.PTRACE_TRACEMEstays allowed so anti-debugging self-checks still run and are reported asevasion. Note that blockingprocess_vm_readv/process_vm_writevdoes not by itself containSYS_PTRACE:PTRACE_PEEKDATA/POKEDATAread and write another process's memory without those syscalls. Earlier revisions of this document claimed otherwise.- Hostname sanitization prevents Docker CLI argument injection via hostile hostnames
- Resource limits: memory and CPU mirrored from host (capped at 4GB/4 cores), PID limit of 256
- Host-side resource limits. Each probe stops retaining events past
probe.MaxProbeEvents, and the strace parser's dirfd / created-file correlation maps are bounded. Hitting any of those ceilings counts as lost visibility and forces aninconclusiveverdict. Without them, a package that loops on a traced syscall grew an unbounded slice in the kojuto process and could exhaust host memory — the sandbox's own limits do not constrain the scanner.
- Dynamic analysis: strace monitors
connect,sendto,sendmsg,sendmmsg,bind,listen,accept,accept4,execve,execveat,clone,clone3,openat,rename,renameat,renameat2,sendfile,ptrace,mmap,mprotect,unlink, andunlinkatsyscalls during install and import phases. Captured filenames are C-unescaped, dirfd-resolved (per-PID fd→path map soopenat(<fd>, "shadow", ...)afteropenat(AT_FDCWD, "/etc", ...) = <fd>resolves to/etc/shadow), andpath.Clean-normalized (/etc/./shadow,/etc//shadow,/etc/foo/../shadowall collapse to/etc/shadow) BEFORE the sensitive-path substring check runs - Audit hooks: Python PEP 578 hook (
sitecustomize.py) interceptscompile/exec/importevents; Node.js--requirehook interceptseval/Function/vmcalls. Detects dynamic code execution that generates noexecvesyscall - Import phase reality check: the sandbox resolves each distribution to its actual import-name via
importlib.metadata.top_level.txt(Python) /require()→import()fallback (Node) so packages whose install name differs from their module name (pillow→PIL,pyyaml→yaml,opencv-python→cv2) are actually imported instead of silently no-oping. When every attempted import fails and no HIGH-severity event was independently observed, the verdict isinconclusive— kojuto refuses to claim "clean" on a package whose import-phase code never ran - DNS tunneling detection: extracts query domains from
sendtopayloads and flags high-entropy subdomains (Shannon entropy > 3.5 bits/char) used for data exfiltration - Credential access detection:
openatmonitoring flags access to sensitive paths (~/.ssh/,~/.aws/,/etc/shadow,/proc/self/environ, etc.) - Sandbox-detection probing: reads of
/proc/self/maps,/proc/self/cgroup,/proc/self/status,/proc/self/mountinfo, and/sys/class/netare classified asevasion. Multiple reads of the same path (glibc / V8 / Pythonrunpyread/proc/self/mapson every process launch) are deduplicated by path so the verdict rule (2+ MEDIUM events to flip) measures DISTINCT sandbox-detection paths touched — a single maps read stays clean; maps + cgroup + status flips - Binary hijacking detection:
rename/renameat/renameat2monitoring detects attempts to overwrite trusted binaries (python3,node,sh, etc.) - Multi-OS import probing: packages are imported under simulated Linux, Windows, and macOS identities to trigger platform-gated payloads
- Time-shifted import:
libfaketimeadvances the clock by a random offset between +30 and +180 days during import probes to trigger date-gated payloads; randomization prevents signature-based evasion, upper bound avoids TLS certificate expiry - Honeypot simulation: fake credential files and CI environment variables (randomly generated per scan) provoke credential-harvesting malware into observable behavior
- eBPF mode (opt-in): kprobes for
connect,sendto,sendmsg,bind,listen,accept,openat, andrename, plus tracepoints forexecve/execveat(code execution),ptrace(evasion),mmap/mprotect(memory execution), andunlink/unlinkat(anti-forensics) — full detection parity with strace-container mode.execveuses a tracepoint rather than a kprobe because GCC's interprocedural optimization renamesdo_execveat_commontodo_execveat_common.isra.0, breaking symbol-based kprobe attach - gVisor runtime (
--runtime auto, default): auto-detects gVisor availability; user-space kernel masks/proc/1/cgroupand/proc/self/mountinfo, defeating the remaining container-detection signals - sudo-free eBPF:
scripts/setup-caps.shgrantsCAP_BPF+CAP_PERFMONto the binary viasetcap, eliminating the need for root
- Host hostname, username, CPU count, and memory are mirrored into the container
/.dockerenvis masked at container creation time by bind-mounting an empty regular file from the host over it (--read-onlyrootfs makes post-startrmimpossible, so masking is the only mechanism)- Package mount path mirrors host directory layout
/etc/resolv.confis populated via--dns=198.51.100.1(RFC 5737 TEST-NET-2, guaranteed unreachable) so the file is non-empty even under--network=none— prevents the empty-resolv-conf signal that would reveal isolation.connect()returnsENETUNREACH; combine with--runtime runscto mask remaining/proc/1/cgroupand/proc/self/mountinfosignals
kojuto detects malicious behavior at the syscall level. The following attack vectors are outside its current detection scope:
- Memory-only execution (
mmap+PROT_EXEC): Shellcode executed via JIT-style memory mapping withoutexecve. Simultaneous PROT_WRITE+PROT_EXEC is detected; W^X patterns (mmap(RW)→mprotect(RX)) are indistinguishable from V8 JIT. Network isolation and read-only filesystem limit the practical impact. - Audit hook evasion:
eval/exec/Functionare now detected via audit hooks, but sophisticated malware can disable the Python audit hook viactypes(clobbering the C-level hook list) or detect the hook's presence by inspectingsitecustomize.py. The Node.js hook can be bypassed by overridingglobalThis.evalbefore the hook loads. - Legitimate-host exfiltration: Stolen data sent via connections to benign hosts (e.g.
pypi.org:443) cannot be distinguished from normal traffic at the syscall level, as kojuto does not inspect packet payloads. - Environment variable reads:
os.environ/process.envaccess does not generate syscalls (values are in process memory at startup). Honeypot environment variables are set, but reads are detected only indirectly when the values are exfiltrated via network connections. - Import-time delayed execution: kojuto imports packages but does not call their functions. Payloads that activate only when specific functions are called (e.g.
pkg.connect()) will not trigger during scanning. - Timing beyond +180 days:
libfaketimeshifts the clock by a random offset between +30 and +180 days. Payloads gated on dates further in the future may not activate. The upper bound avoids TLS certificate expiry issues. - Low-entropy DNS tunneling: DNS tunneling detection relies on Shannon entropy (> 3.5 bits/char) and label length heuristics. Dictionary-based encoding schemes that produce low-entropy subdomain labels can evade detection. This is a deliberate design tradeoff: lowering the entropy threshold would cause false positives on legitimate package registry queries. Network isolation prevents DNS queries from reaching external servers, limiting the practical impact.
- No package-manager code runs on the host.
pip downloadandnpm installboth execute inside the network-enabledDownloadSandbox, not on the host — they parse attacker-controlled registry metadata, resolve attacker-controlled dependency graphs, and unpack attacker-controlled archives (extraction is a real path-traversal surface). This includesscan --local, which resolves a local tarball in the same sandbox; that path used to runnpm installon the host even though it is the entry point documented for scanning known-malicious samples. pip downloaduses--only-binary=:all:to prevent source buildsnpm installuses--ignore-scriptsduring resolution; lifecycle scripts are re-executed inside the analysis sandbox under strace, which is where kojuto wants to observe them- npm
package.jsongeneration usesjson.Marshal(not string interpolation) to prevent JSON injection - Staging directories are not world-readable. The download sandbox runs as UID 1000, which is not the host UID, so the bind-mounted staging directory has to be world-writable. It is created one level below a
0700parent so that mode is unreachable to other local accounts — otherwise any local user could drop an extra wheel or tarball into a scan in progress and have it installed and attributed to the scanned package. - Output relayed from the sandbox is escaped before it reaches the terminal. pip/npm stdout, a package's stderr, and failed-install diagnostics all go through
internal/safeout, which renders ANSI escapes inert while preserving newlines and tabs. Both the JSON report and the console path use the same routine. Without this, a package could emit escape sequences that redraw or erase theSUSPICIOUSverdict block kojuto prints to the same stream moments later. - Reports and pinned dependency files are written
0600; they can carry attacker-supplied snippets and internal package names