Triage scanner findings with signed VEX
A vulnerability gate that blocks on every finding gets switched off the first time it blocks a release for code you do not run. A gate that lets people wave findings through by hand gets no better. VEX (Vulnerability Exploitability eXchange) is the middle path: a statement, per finding, that says why the finding does not apply, signed by whoever is accountable for that judgment.
cilock attest vex writes an OpenVEX document and signs it as evidence. It is
fail-closed at authoring: not_affected needs a justification, affected
needs an action statement, and unknown statuses are rejected. In this session
the prompt asks Claude to record a govulncheck scan for a Go module that pins
an old golang.org/x/text, check whether any finding reaches the code, write
a signed VEX statement for each one that does not, and build a policy that
passes only when every finding is covered.
TestifySec wrote the prompt and sent it into the session, and approved one
permission prompt (Claude deleting evidence files it had generated, before
re-recording them). Claude Code and cilock ran unedited, and the recording
was not changed afterwards. The session used Go 1.26.9 (GOTOOLCHAIN), so the
scan reports the module's findings and not the standard library's.
What happens, step by step
-
Scan. govulncheck reports three findings in
golang.org/x/text v0.3.7, none reachable, all matched at module level with no package or function in the trace:Finding Alias Vulnerable package GO-2022-1059 CVE-2022-32149 x/text/languageGO-2026-5970 CVE-2026-56852 x/text/unicode/normGO-2026-6629 CVE-2026-56851 x/text/secure/precis -
Check the claim before signing it. Claude does not take "unreachable" on the scanner's word:
go list -deps -test ./...shows the module pulls in onlyx/text/widthandx/text/transform. So each statement isnot_affectedwith the justificationvulnerable_code_not_present. -
Record the scan and the statements. Claude writes the steps into a script,
vex-gate/record.sh, so they can be re-run. The scan is recorded with thegovulncheckattestor; each statement is onecilock attest vexcall:cilock run --offline --step vulns -k "$KEY" --workload manual -a govulncheck \ --subjects "$anchor" -o "$out/vulns.att.json" \ -- sh -c 'govulncheck -json ./... > govulncheck.json' cilock attest vex --offline -k "$KEY" --workload manual -a environment -s vex \ --subjects "$anchor" \ --product pkg:golang/example.com/labels --vuln CVE-2022-32149 --status not_affected \ --justification vulnerable_code_not_present --author vex-triage \ --impact-statement "golang.org/x/text/language is not in the build graph of example.com/labels ..." \ --vex-out vex-gate/vex/go-2022-1059.openvex.json -o "$out/vex-go-2022-1059.att.json"$anchorisgo.sum=sha256:<digest of go.sum>. Every envelope carries it as a subject, and the release is verified againstgo.sum, so the statements apply to this exact dependency set. Whenx/textis bumped,go.sumchanges and the old statements stop counting. -
Write the policy.
cilock policy template --goal vulns --with-vexneeds an enrolled Pushgate agent, so Claude writes the Rego by hand fromcilock policy guide. The scan command is pinned and must exit0. Every finding needs a signed statement that matches its GO id or a CVE/GHSA alias and names the product; onlyfixed, ornot_affectedwith a justification, counts. Claude adds one more rule: a "not present" or "not in execute path" statement is refused if the signed scan shows the package imported or the code reachable, so a later import ofx/text/languagecannot hide behind today's statement. -
Verify. With all three statements,
cilock verify go.sumexits0. With GO-2026-6629's statement left out it exits1:GO-2026-6629 (aliases {"CVE-2026-56851"}) has no signed VEX statement from step "vex"…. A statement signed with a key the policy does not trust is refused, anunder_investigationstatement in place of the real one is refused, and anunder_investigationstatement next to the valid one is refused too, because a conflicting statement blocks the finding.
A VEX statement is a judgment, not a fix
Signing not_affected moves the decision to someone accountable and puts it
in the evidence. It does not remove the vulnerable code from go.sum. The
statements in this session say so in their impact statement ("Pending
golang.org/x/text bump next sprint"), and binding them to the go.sum digest
keeps them from outliving the dependency set they were written about.
The policy in the recording is hand-written; Claude says so and recommends a
review before relying on it. See the vex attestor
for the fields a rule reads, govulncheck for the
scanner, and Policy verification for how
attestationsFrom lets one step's rule read another step's evidence.
Run it yourself
The recording used cilock built from main at commit 5b614726 and
govulncheck on Go 1.26.9. Signing ran offline with local keys, so the
signatures carry no timestamp; add --timestamp-servers in CI if you need
one.
Reference generated from the product documentation. Match commands and support details to your installed release.