gomodjail confines a specific set of Go modules, so as to mitigate their potential vulnerabilities and supply chain attack vectors.
In other words, gomodjail is a "container" (as in Docker containers) for Go modules.
gomodjail can be applied just in the following two steps:
Step 1: add gomodjail:confined comment to go.mod:
require (
example.com/module v1.0.0 // gomodjail:confined
)Step 2: statically verify the confinement with gomodjail analyze:
gomodjail analyze ./...The build fails (non-zero exit) if a confined module's code can reach a denied capability: filesystem, network, process execution, raw syscalls, OS state modification, or cgo.
The legacy dynamic mode (gomodjail run), which enforces a similar (but
not identical) policy at runtime via syscall interception, is still
available — see Dynamic mode.
- Go (build and analysis)
- Linux (4.8 or later) or macOS, on x86_64 ("amd64") or aarch64 ("arm64")
The static analysis itself is platform-independent, but the gomodjail binary currently builds only on Linux and macOS, as the legacy dynamic mode is compiled in unconditionally (moving it behind a build tag is planned).
make
sudo make installMakefile variables:
PREFIX: installation prefix (default:/usr/local)
An example program is located in ./examples/victim:
cd ./examples/victim
go build
./victimConfirm the "malicious" vi screen:
*** ARBITRARY SHELL CODE EXECUTION ***
This 'vi' command was executed by the 'github.com/AkihiroSuda/gomodjail/examples/poisoned' module.
This example is harmless, of course, but suppose that this was a malicious code.
Type ':q!' to leave this screen.
The poisoned module is marked gomodjail:confined in victim's go.mod,
so gomodjail analyze catches the exec statically, with the witness call
path, before the program ever runs:
$ gomodjail analyze ./...
FAIL github.com/AkihiroSuda/gomodjail/examples/poisoned: reaches EXEC
[EXEC]
github.com/AkihiroSuda/gomodjail/examples/poisoned.Add
os/exec.Command (poisoned.go:19)
gomodjail: 1 confined module(s): 0 ok, 0 warning(s), 1 violation(s)gomodjail analyze prints one verdict per confined module:
- FAIL — the module (or one of its own dependencies) can reach a denied
capability:
FILES/READ,FILES/WRITE,NETWORK,EXEC,SYSTEM_CALLS,OPERATING_SYSTEM,MODIFY_SYSTEM_STATE, orCGO. The exit code is non-zero. - WARN — no denied capability is reachable, but the module uses
constructs the analyzer cannot follow (
reflect,unsafe, assembly, unresolvable dynamic calls), so the verdict is best-effort. Warnings do not fail the gate unless--strictis set. Nearly every real-world module that marshals, hashes, or logs earns at least one warning; this is expected. - ok — no denied capability is reachable from the module's code.
A confined module that contributes no packages to the build is reported
ok (unused)so stale annotations stay visible.
Reading or writing an io.Writer/io.Reader/*os.File handed to the
module by your program is not a violation: the capability was granted when
your code handed over the handle. Opening a new file, dialing, or execing is.
File descriptors are not isolated across modules: reading or writing an
already-open file descriptor (including one wrapped with os.NewFile) is
not a violation either.
Useful flags:
--strict: treat warnings as violations--explain: print witness call paths for warnings too--format=json: machine-readable full report--format=sarif: SARIF 2.1.0 for code-scanning integrations (findings are anchored to the module'srequireline ingo.mod)--goos,--goarch: analyze a different target platform--parallel=N: analyze up to N modules concurrently (default: the number of CPUs; memory use grows with N)
gomodjail fix rewrites go.mod so that gomodjail analyze passes:
every module with a FAIL verdict has its annotation downgraded to
// gomodjail:unconfined (a per-line annotation overrides any file- or
block-level gomodjail:confined default, and the rest of the line is
preserved; for // indirect requires the annotation is written on its own
line above the require, keeping the // indirect comment byte-for-byte
intact for other toolchains):
$ gomodjail fix ./...
fix github.com/containerd/go-runc: unconfined (reaches EXEC, FILES/READ, NETWORK, SYSTEM_CALLS)
gomodjail: unconfined 1 of 101 confined module(s) in go.mod
gomodjail: 35 module(s) with warnings kept confined (use --strict to unconfine them too)Unconfining is the only safe automated fix: a FAIL means the module's
own code (or its own dependency cone) reaches the capability, which no edit
to your program can prevent. The explicit gomodjail:unconfined annotation
keeps the decision visible and reviewable in go.mod.
Unconfining every confined module at once is refused, as gomodjail analyze would then have nothing left to verify.
Useful flags:
--dry-run: print the edits without writinggo.mod--strict: also unconfine warning-only modules (mirrorsanalyze --strict)--from-report=FILE: reuse a savedgomodjail analyze --format=jsonreport (-for stdin) instead of re-running the analysis--parallel=N: analyze up to N modules concurrently (default: the number of CPUs; memory use grows with N)
examples/profiles has several example profiles:
docker.mod: fordocker(notdockerd)- ...
- A WARN verdict is a real soundness concession: code hidden behind
reflect,unsafe, assembly, etc. cannot be statically verified. Use--strictto reject it. - Capabilities injected by the host program (e.g. your code hands a confined module a live network connection) are attributed to the host, not the module. The dynamic mode is stronger for this specific threat.
- No isolation of file descriptors across modules. A confined module can still read/write an existing file descriptor, although it cannot open a new file descriptor.
- Modules that probe the environment at init time (e.g. CPU-feature
detection reading
/proc) fail onFILES/READby design; they were never confinable at runtime either. - The
gomodjail:confinedpolicy is not well defined and still subject to change. - This is not a panacea; there can be other loopholes too.
The original runtime enforcement is still available:
gomodjail run --go-mod=go.mod -- ./victim
level=WARN msg=***Blocked*** syscall=pidfd_open module=github.com/AkihiroSuda/gomodjail/examples/poisonedIt imposes syscall restrictions on the confined modules by intercepting syscalls and attributing them to modules via stack unwinding. It needs no source code (only symbols), and it catches host-injected capability abuse, but it is slower and considerably more fragile than the static gate.
How it works:
- Linux:
SECCOMP_RET_TRACEis used for conditionally allowing trusted Go modules to execute the syscall.SECCOMP_RET_USER_NOTIFis not used because it cannot access all the CPU registers, due to the lack ofstruct pt_regsinstruct seccomp_data. Stack unwinding is used for analyzing the call stack to determine the Go module. - macOS:
DYLD_INSERT_LIBRARIESis used to hooklibSystem(libc) calls. In addition to the frame pointer (AArch64 register X29),struct gin the TLS andg->m.libcallspare parsed to analyze the CGO call stack.
Dynamic-mode caveats:
- Not applicable to a Go binary built by a non-trustworthy third party, as the symbol information might be faked.
- Not applicable to a Go module that imports
unsafe,reflect,plugin, etc. (gomodjail analyzereports these as warnings.) - No isolation of file descriptors across modules. A confined module can still read/write an existing file descriptor, although it cannot open a new file descriptor.
- The target binary file must not be replaced during execution.
macOS:
- The protection can be arbitrarily disabled by unsetting an environment
variable
DYLD_INSERT_LIBRARIES. - Only works with the following versions of Go:
- 1.22
- 1.23
- 1.24 (excluding 1.24.0-1.24.5)
- 1.25
- 1.26
- 1.27
- Not applicable to a Go module that use:
macOS on Intel:
- Not applicable to a Go binary built with
-ldflags="-s"(disable symbol table)
To create a self-extract archive of gomodjail with a target program, run
gomodjail pack --go-mod=go.mod PROGRAM.
The self-extract archive is created as <PROGRAM>.gomodjail.
The packed program runs under the dynamic mode.
gomodjail analyze reuses Capslock
(pinned) as its capability-analysis engine. Each confined module is analyzed
in its own dependency slice — the module's packages plus their transitive
imports, with the query rooted at the module's own functions — so a module
is blamed only for what its own code and its own dependency cone can do,
not for values the host program hands it. Capability classes map to three
severity tiers (deny / caveat / allow) calibrated against what the dynamic
mode's seccomp filter actually blocked; unknown classes are denied (fail
closed).
- Per-module capability scoping (e.g. allowing
FILES/READfor a config-loading module). - GitHub Action and golangci-lint integrations.
- Apply landlock in addition to seccomp (dynamic mode). Depends on
SECCOMP_IOCTL_NOTIF_ADDFD.
docs/syntax.md: syntaxexamples/profiles/README.md: profiles