skip to content

Why did a CI step running govulncheck stop failing on known vulnerabilities after it switched to -format sarif, and how do you restore the gate?

level: middleimportance: should knowfreq 28%

answer

  1. machine formats feed another program
  2. the scan itself reports success
  3. text mode exits 3 on findings
  4. json, sarif, openvex always exit 0

basics

~20 s

govulncheck exits 0 with -json, -format sarif or -format openvex regardless of findings; only the default text output exits 3 when vulnerabilities are found. Gate on a text-mode run, or parse the report and fail on findings yourself.

solid answer

~50 s

govulncheck's exit status depends on the format. In the default text output it exits `0` when nothing is found, `3` when vulnerabilities are found, and `2` on a usage error such as `-show traces` combined with `-format sarif`. With `-json`, `-format sarif` or `-format openvex` it is documented to exit `0` regardless of the number of vulnerabilities, because those formats are meant for another program to judge. So the switch to SARIF kept the report correct and silently removed the failure. To restore the gate, either run `govulncheck ./...` a second time in text mode and let status 3 fail the job, or add a step that reads the SARIF or JSON and fails when it contains findings. Also watch pipes: `govulncheck ./... | tee out.txt` needs `set -o pipefail`, or status 3 is lost.

code

bash · 3 lines
bash
set -euo pipefail
govulncheck -format sarif ./... > govulncheck.sarif
govulncheck ./... | tee govulncheck.txt

go deeper

for a junior

Recall the rule: text output exits 3 when vulnerabilities are found, while json, sarif and openvex output exit 0 regardless of findings.

for a middle

Explain why a format change removed the failure, what statuses 0, 2 and 3 mean in text mode, and the two ways to restore the gate.

for a senior

Show you would audit a security gate by forcing a known finding through it, separate usage errors from findings, and choose source or binary mode for each pipeline stage.

for a principal

Weigh one machine-readable run with your own pass/fail logic against two runs with the tool's own verdict, and who owns that logic as the formats evolve.

## What changed when the output format changed **govulncheck** is the Go team's vulnerability scanner. Run from a module directory as `govulncheck ./...`, it works in **source mode** (the default `-mode source`): it loads the packages with the `go` command found on the `PATH`, asks the Go vulnerability database (by default `https://vuln.go.dev`, changeable with `-db`) which known vulnerabilities touch the module versions in the build, and then uses static analysis to decide which of them your code can actually reach. Its **exit status** depends on the output format, and that is the trap. The tool's documentation states the rule plainly: govulncheck exits 0 when there are no vulnerabilities and unsuccessfully when there are, **but** it also exits 0 when `-json` (the legacy spelling of `-format json`), `-format sarif` or `-format openvex` is given, **regardless of the number of detected vulnerabilities**. The machine-readable formats are meant to feed another program, so the scan itself reports success and leaves the verdict to whoever reads the file. A pipeline that used to run `govulncheck ./...` and fail the job on findings, and was then changed to `govulncheck -format sarif ./... > govulncheck.sarif` so a report could be stored, therefore keeps producing a correct report and **never fails again**. Nothing in the log looks wrong: the step is green and the vulnerabilities sit, unread, in the artifact. ## The exit codes of the default text output The values live in the tool's source (`internal/scan/errors.go`): | Exit status | Meaning in text mode | |---|---| | `0` | no vulnerabilities found, or help requested with `-h` | | `2` | invalid usage: a bad flag or combination, no package patterns given, or patterns that matched no packages | | `3` | **vulnerabilities found** (the comment on the error says: when running without the `-json` flag) | Two consequences matter in CI: - **Status 3 is the gate.** A step that runs the text format and lets a non-zero status fail the job blocks merges on findings with no extra scripting. - **Status 2 is not a finding.** Combinations that `validateConfig` rejects exit 2, for example `-show traces` together with `-format sarif` (the `-show` flag is only supported with text output), `-json` together with `-format`, or `-scan module` given package patterns. A script that treats every non-zero status as "vulnerable" will misreport a broken command line. Other load failures, such as running outside a module (`no go.mod file`) or a mismatch between the Go version that built govulncheck and the Go version on the `PATH`, are separate errors with their own messages, not status 3. ## Restoring the gate There are two honest fixes; both keep the report and bring back the failure: 1. **Run twice.** Produce the report with `-format sarif` (or `json`, or `openvex`), then run the text format as the gate. The second run exits 3 on findings. 2. **Judge the report yourself.** Keep a single machine-readable run and add a step that reads the output and fails when it contains findings. This puts the pass/fail rule in your script, so it has to be maintained and tested like any other code. A shell detail can hide status 3 just as well: `govulncheck ./... | tee out.txt` returns the status of `tee` unless the shell runs with `set -o pipefail`. ## Choosing the scan level and the mode - **`-scan symbol`** (the default) asks whether a vulnerable function is reachable from your code, which is why the text output prints call stacks such as `main.go:12:29: vuln.tutorial.main calls golang.org/x/text/language.Parse`, and lists vulnerabilities you import but never call under an `Informational` heading. - **`-scan package`** stops at whether a vulnerable package is imported, and **`-scan module`** at whether a vulnerable module version is required; `-scan module` accepts no package patterns. Each coarser level reports more and shows no call stacks, because it never asks whether the vulnerable code is used. - **`-mode binary`** scans one built executable instead of the source: `govulncheck -mode binary ./bin/api`. It reads the binary's **symbol information**, so it checks the build configuration that produced the shipped artifact rather than whatever Go toolchain sits on the CI runner's `PATH`. Binary mode has limits worth knowing before choosing it as the gate: - Its output **omits call stacks**, which require source analysis. - It can report code that is linked into the binary but unreachable. - When symbol information cannot be extracted, it reports vulnerabilities for **all modules** the binary depends on. - For binaries built with Go before 1.18 it reports only standard-library vulnerabilities. - `-test` and `-tags` are rejected, and only one binary can be analysed at a time; `-mode extract` turns a binary into a smaller blob that `-mode binary` can read later. A common split is source mode on every change, because its call stacks tell the developer what to fix, and binary mode on the release artifact, because it describes what actually ships.

  • When would you run govulncheck with -mode binary on the release artifact instead of scanning the source?
    When you want the verdict for what actually ships: binary mode uses the build configuration recorded in the executable, not the Go toolchain on the CI runner's PATH. The trade-off is that it reads symbol information only, so it prints no call stacks, can report linked but unreachable code, and reports every dependency module when symbols cannot be extracted. Many teams keep source mode on changes and add binary mode on releases.
  • Your gate script treats any non-zero govulncheck status as 'vulnerabilities found'. What can it misreport?
    A broken command line. In text mode only status 3 means vulnerabilities were found; status 2 means invalid usage, such as a rejected flag combination, no package patterns, or patterns that matched no packages. Other load failures, like running outside a module, are separate errors too. Testing for 3 specifically lets the job say 'vulnerable' versus 'could not scan'.
  • What does switching a govulncheck gate from the default -scan symbol to -scan module change?
    Module level reports every known vulnerability in the module versions the build requires, without checking imports or whether the vulnerable function is called, so it reports more and shows no call stacks; it also accepts no package patterns. The default symbol level is what narrows findings to reachable code, so dropping to module level usually makes the gate noisier.

saying these in an interview costs you the question

  • govulncheck exits 3 on findings in every output format, SARIF included.
  • A green govulncheck step that wrote a SARIF file means no vulnerabilities were found.
  • Exit status 2 from govulncheck means vulnerabilities were found in the standard library.
  • Binary mode prints the same call stacks as source mode if you add -show traces.
  • Piping govulncheck into tee keeps its exit status without pipefail.
  • Switching to -scan module makes the gate stricter about which findings are real.