Security scanning¶
bomify security scan <scanners> <tag> (cmd/security.go), where
<scanners> is one scanner or several, comma-separated (e.g.
grype,trivy):
- Resolves
<tag>(build.ResolveTag) and walks its SBOM. - Finds
bomify-plugin-<scanner>for each (findScanners), and scans with each in turn (security.ScanAll), so every component goes to every scanner that supports it. A component no scanner scanned is skipped; see Coverage. - Asks each once for
security supported-components: the purl types it scans, each by purl or from the component's files (pluginlib.ScanMode). It skips any component whose type isn't listed, or that has no purl type at all. A type scanned from files is given the component's pulled layer (security.ScanOptions.ContentDir, the data directory's layers). It returns the skipped ones with why (security.Skipped; see Coverage). Duplicate purls are scanned once, and aren't counted as skipped. - Calls
security scan --purl <purl>per component, adding--input <layer dir>for a type scanned from files, up to--concurrencyat a time (security.ScanAll). A result markedunscanned(the plugin couldn't analyze the component) counts as skipped, with no report. Otherwise the plugin returns CycloneDX vulnerabilities, each withaffectsset by the plugin: to the purl itself, or, for something it had to unpack (an image), to the affected pieces, which it also returns as components. - Writes each result as its own CycloneDX document
(
security.NewReport) tovulnerabilities/<purlHash>/<scanner>.json, replacing that scanner's previous one and leaving other scanners' alone, so scanning with a second scanner adds to the first's findings. Itsmetadata.componentis the scanned component (bom-ref= purl), itscomponentsare the unpacked pieces someaffectsnames, itsvulnerabilitiesare exactly what the plugin reported, andmetadata.timestamp/toolsrecord when and what scanned it.
Nothing in a report depends on the package it was scanned through, which is why packages sharing a purl share its reports.
bomify package vulnerabilities <tag> prints the reports of a package's
components (or --purl ones) as one JSON array on stdout, in SBOM
order and by scanner within a component (security.ReadReports),
reading each report as raw JSON so values are never re-encoded.
Components without a report are skipped; warnings go to stderr.
Vulnerability gating¶
security.Gate (internal/security/gate.go) fails a package when any
report has a vulnerability at or above its threshold (info < low <
medium < high < critical) that isn't ignored or exempted by VEX,
or, with FailOnUnscanned, when the scan skipped any of its components
(see Coverage). A vulnerability's severity is the highest
of its ratings; unrated, none, or unknown never fails.
--fail-on lists the conditions (security.ParseFailOn): at most one
severity, and unscanned, e.g. --fail-on high,unscanned. A rule
stores them as failOn and failOnUnscanned (see
Policy rules).
Every scanner's reports are gated together. A vulnerability two scanners both report on a component is one finding: it fails at the highest severity any non-exempt report gives it, and is exempted only if VEX exempts it in every report of it.
bomify security scan picks the gate (cmd/scanning.go, gateFlags):
--skip-gate: nothing fails (warning if a rule would have).--fail-on, plus--ignore(which needs a severity in it). It replaces all of a matching rule's conditions.- The most specific
conf/scan.jsonrule matching the repository (see Policy rules). - Otherwise nothing fails.
VEX adds up rather than overriding: the rule's stored documents, then
--vex files.
security.Scan returns reports instead of writing them. security scan
writes them all before checking the gate, so a failing package's reports
are there to inspect. A failure prints a table of the offending
vulnerabilities to stderr.
Coverage¶
A component no scanner scanned has no report, so on its own it could
never fail a gate: a package nothing could check would pass as clean.
Every scan (checkGate) prints the components it skipped, and why, to
stderr, e.g. 2 of 9 components not scanned by grype or trivy. A
component any one scanner scanned counts as scanned. With
Gate.FailOnUnscanned (--fail-on unscanned, or a rule's
failOnUnscanned), any skipped component fails the gate, alongside or
instead of a severity, and the GateError carries them as Unscanned.
VEX¶
security.LoadVEX (internal/security/vex.go) holds statements in
go-vex's OpenVEX model. go-vex reads
OpenVEX (JSON/YAML, any version) and CSAF. CycloneDX VEX is converted:
not_affected/false_positive → not_affected,
resolved/resolved_with_pedigree → fixed, exploitable →
affected, in_triage → under_investigation, with affects refs
(bom-refs or BOM-Links) resolved to purls through the document's
components. Only not_affected and fixed exempt.
A finding is exempted only if every target it affects is. A statement
covers a target when its product is that target or the scanned
component, and, if it names subcomponents, the target is one of them.
So "not affected in the image" clears a finding throughout the image,
while "not affected in openssl" doesn't clear it for zlib. Matching is
go-vex's Statement.Matches: a versionless purl matches every version,
qualifiers only matter when the statement gives them, and IDs match
through aliases and report references (GHSA ↔ CVE). Where statements
disagree, the last wins: documents in order, OpenVEX statements by
timestamp, with an analysis already in the report counting first.
Every exemption is logged with its status, justification, and source. VEX never changes a report; it only decides what fails.
Policy rules¶
conf/scan.json holds one rule per match, written by bomify security
policy create; the most specific match applies. It's a JSON array,
covered by the data directory version
and read strictly: an unknown field fails the command rather than being
ignored, so a misspelling can't drop a gate.
| Field | Type | Meaning |
|---|---|---|
match |
string | /-separated prefix of a package's repository, matched at segment boundaries. Omitted or empty matches every package. |
scanners |
array of strings | At least one scanning plugin, bomify-plugin-<scanner>; each scans every component it supports. |
failOn |
string | The severity (info, low, medium, high, critical) at or above which a finding fails the package. Omitted never fails on severity. |
failOnUnscanned |
boolean | Fail the package if any component went unscanned (see Coverage). |
vex |
array of strings | Names of documents in vex/ whose statements exempt findings from failOn (see VEX). A document can't be removed while a rule names it. |
on |
array of strings | Hooks that scan and gate a matching package automatically. Only pull (which covers load) exists, and it needs failOn or failOnUnscanned (see Scanning on pull). Omitted, the rule applies only to security scan. |
There's deliberately no list of vulnerability IDs to ignore: a standing exemption belongs in a VEX document that says which component and why.
Scanning on pull¶
bomify pull/load can scan and gate a package before tagging it, the
one thing a separate security scan after the pull can't do.
They take --scan <scanners>, --fail-on, and --skip-scan
(scanFlags);
--ignore and --vex stay on security scan, and pulls rely on a
rule's stored VEX. For each package, pullScanHook resolves:
--skip-scan: nothing (warning if a rule would have scanned).- The scanners:
--scan, else the matching rule's, but only if the rule listspullinon(security.Rule.AppliesOn). - The gate, as for
security scan, under the same condition.
A scan on pull is only a gate, so scanners and --fail-on must come
together (Gate.CanFail). The package is always scanned fresh, never
judged by the reports its publisher attached. For the same reason, a
rule's --on pull requires --fail-on.
The hook runs as transfer.Options.Scan: pull.PullLayers hands it the
SBOM, along with the target and package manifest, once the package's
component layers are written and before its reports are restored, so a
type scanned from files is scanned from them (ContentDir is the data
directory). A refused package fails the pull before its tags are
recorded; what was written stays, untagged, for bomify package prune
to remove. Its gate also honors VEX the package's publisher attached,
when it verifies (see VEX in a registry). The
fresh reports are written after the package's own, replacing them.
Scanning may need network access, which is why nothing scans on pull
unless asked.
Source: docs/diagrams/scanning.mmd
Reports in a registry¶
Reports travel as an OCI referrer of the package manifest, not
inside it (security.Attach, called by push.Push after packing and
before tagging). Reports go stale and get refreshed; keeping them out of
the manifest means re-scanning never changes the package digest or
orphans its signature.
- Attach: one referrer (artifact type
application/vnd.bomify.vulnerabilities.v1+json, annotatedland.bomify.scan.pluginwith every scanner it carries) with one layer per component and scanner (media typeapplication/vnd.bomify.component.vulnerabilities.v1+json, annotated with its purl and its one scanner,land.bomify.scan.plugin, and titled with its path undervulnerabilities/, sooras pulllays the reports out as bomify keeps them; in SBOM order and by scanner). It's dated by the newest report's scan time, not the push time, so pushing again without re-scanning reproduces it byte for byte. With--signit's signed like the package. - Restore: after restoring the package,
pulltakes the newest report referrer (security.ReportReferrers), checks it with the sametransfer.Verifieras the package, and writes each report to its scanner's place undervulnerabilities/, by the layer's scanner annotation; a layer without one fails the restore. Reports are advisory: any failure only skips them (Result.ReportsSkipped, plus a warning). - Prune: after attaching,
pushdeletes all but the newest--keep-reports(default 1; 0 keeps all) report referrers (security.PruneReferrers), each after whatever refers to it (its signature), so no signature is orphaned. Nothing else is touched. Deletion is best-effort: registries that refuseDELETE(ghcr.io: 405) only cause a warning, sincepullalways takes the newest.bomify security pruneruns the same pruning on its own and fails instead. Asavetarball is built fresh, so it never needs pruning.
Report referrers and VEX referrers are pushed, listed, and read through
shared helpers in internal/oci/transfer's referrer.go:
PushReferrer (an empty config, the given layers, the package as
subject), Referrers (by artifact type, newest first by created
annotation), and ReferrerLayers/FetchAttachment, which cap how much
of a referrer, published by whoever could push, bomify will read.
Signature referrers are the exception: their type and envelope come
from the signing plugin.
VEX in a registry¶
A publisher can attach VEX documents to a package, so their statements
travel with it: push --vex and save --vex (repeatable), each a name
in the VEX store or else a file
(security.ReadVEX). Each becomes a transfer.Attachment in
transfer.Options.Attach, which push.Push attaches as its own
referrer (transfer.Attach): artifact type
application/vnd.bomify.vex.v1+json, one layer holding the document as
written, dated to the nanosecond so documents from one push keep their
order. A document the package already carries isn't attached again,
and with --sign each new referrer is signed like the package; without
it, the push warns that pulls will ignore the VEX (see below). Nothing
is attached implicitly; a rule's VEX is never published unless named.
A scan on pull honors them (security.PublishedVEX) only when:
- the pull verifies signatures (
--verifyor a matching trust rule,signature.Policy.For), since VEX only ever exempts, and anyone who can push could otherwise silence any finding; and - each VEX referrer passes the same
transfer.Verifieritself. Unsigned or untrusted ones are skipped with a warning.
Trusted documents apply oldest first, then the rule's stored VEX
(security.CombineVEX), so the consumer's own statements win.
They're used by that gate only and never stored. Files, stored copies,
and pulled documents all load through security.LoadVEXDocuments.
What scan gates and publisher VEX guarantee, and what they don't, is in Trust model.