#nosec suppression is nondeterministic (identical source, same binary)
#nosec suppression is nondeterministic: the same finding is suppressed on some runs and reported on others (identical source, same binary)
Summary
Running gosec repeatedly over unchanged source produces different results.
A G101 finding that is covered by a #nosec G101 directive is correctly
suppressed on most runs, but leaks through as a reported issue on a minority of
runs — no source change, no flag change, same gosec binary.
This makes gosec unusable as a hard CI gate: a job that runs gosec ./... and
fails on any HIGH finding flakes red at random.
Environment
- gosec: v2.29.0 (also reproduces on
@latestbuilt from source) - Go:
go1.26.8(also seen on 1.22/1.23) - OS: reproduced on macOS (arm64) and Ubuntu (GitHub-hosted
ubuntu-latest)
Minimal reproduction
Self-contained package (stdlib only, compiles, go vet-clean):
package repro
import (
"fmt"
"net/url"
"os"
)
// #nosec G101 -- example connection strings in help text are not real credentials
var serveCmd = &struct {
Use, Long string
RunE func(args []string) error
}{
Use: "serve",
Long: `Config file format:
databases:
db1:
url: "postgres://user:pass@host:5432/db1"
db2:
url: "postgres://user:pass@host:5432/db2"
`,
RunE: func(args []string) error {
cfgPath := os.Getenv("CFG")
raw, err := os.ReadFile(cfgPath) // #nosec G304 -- operator-supplied path
if err != nil {
return fmt.Errorf("read: %w", err)
}
_ = raw
u, _ := url.Parse(os.Getenv("DB_URL"))
if u != nil && u.User != nil {
u.User = url.UserPassword(u.User.Username(), "x")
}
return nil
},
}
func main() { _ = serveCmd }Run gosec ~50–60 times and count the G101 findings each run:
for i in $(seq 1 60); do
gosec -fmt json -out out.json ./... >/dev/null 2>&1 || true
python3 -c "import json;print(len([x for x in json.load(open('out.json'))['Issues'] if x['rule_id']=='G101']),end=' ')"
done; echoA ready-made script (reproduce.sh) that generates the package, runs gosec N
times, and exits non-zero when the outcome varies is attached.
Expected
Every run yields the identical result. The #nosec G101 directive covers the
declaration, so the G101 finding should be suppressed on every run (count 0).
Actual
The G101 count varies run to run over identical source, e.g.:
1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 00 = directive honored (correct); 1 = same directive ignored, finding leaks.
Leak rate is roughly 5–15% per run and rises with more concurrent CPU load.
On the real-world codebase where we first hit this, the same file flapped between 9 and 10 total findings across 50 CI runs — one suppressed G101 appearing/disappearing — which is what led us here.
What appears to matter (from bisecting)
The bug only surfaces with a specific combination; simplifying the file hides it:
- The finding is anchored to an outer node, not the trigger line. The
#nosec G101is on the outervardeclaration, but the credential-looking strings are several lines deeper inside theLongraw string. gosec reports the G101 issue at the declaration's line range (e.g.10-37), not at the string's own line. Suppression then depends on range-matching, not an exact line hit. - There is more than one
#nosecdirective in the file (here G101 + G304). With a single directive the result is stable; adding the second directive is what makes the G101 suppression start flapping. - It is line-position sensitive. Inserting or removing lines (even comments) shifts the outcome — sometimes to stable-suppressed, sometimes to stable-leaked, sometimes to flapping. This points at nondeterministic ordering/iteration when matching findings against suppression ranges, rather than a fixed off-by-one.
Taken together this looks like the suppression pass matching issues against
#nosec ranges using a data structure whose iteration order (or concurrent
population) is not deterministic, so which range "wins" for a given
outer-node-anchored finding varies between runs.
Impact
- gosec cannot be used as a blocking CI check: builds fail at random.
- Suppressions that developers added and verified locally silently stop working on some runs, so the reverse (a real finding randomly suppressed) is also plausible.
Repro assets attached
reproduce.sh— generates the package and runs the N-times comparison; exits1when nondeterministic.
Few runs
./reproduce.sh
gosec: Version: dev Git tag: Build date:
go: go version go1.26.8 darwin/arm64
runs: 50
Per-run count of the G101 finding that #nosec should suppress (expect all 0):
0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 0 0 1 0 0 1 1
NONDETERMINISTIC: outcomes across identical source = { 0 1 }
The suppressed G101 leaked on 7/50 runs. ./reproduce.sh
gosec: Version: dev Git tag: Build date:
go: go version go1.26.8 darwin/arm64
runs: 50
Per-run count of the G101 finding that #nosec should suppress (expect all 0):
0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
NONDETERMINISTIC: outcomes across identical source = { 0 1 }
The suppressed G101 leaked on 3/50 runs. ./reproduce.sh
gosec: Version: dev Git tag: Build date:
go: go version go1.26.8 darwin/arm64
runs: 50
Per-run count of the G101 finding that #nosec should suppress (expect all 0):
0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 1 0 0 1 0 1 1 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0
NONDETERMINISTIC: outcomes across identical source = { 0 1 }
The suppressed G101 leaked on 8/50 runs.Source: securego/gosec