Container Scan Guardrails
Enforces container image vulnerability scanning standards. Ensures container scanners run and validates vulnerability thresholds for container security.
container-scan to your lunar-config.yml:uses: github://earthly/lunar-lib/policies/container-scan@v1.0.5
Included Guardrails
This policy includes 4 guardrails that enforce standards for your security and compliance.
executed
Verifies that container scanning was executed on the component. Fails if no scanner has written to .container_scan.
max-severity
Ensures no findings at or above the configured severity threshold. Configure min_severity to set the threshold (critical, high, medium, low).
max-total
Ensures total container vulnerabilities are under a configurable threshold.
unfixable-reviewed
Fails on each finding at or above min_severity that has no fix available and no approved exemption.
How Guardrails Fit into Lunar
Lunar guardrails define your engineering standards as code. They evaluate data collected by integrations and produce pass/fail checks with actionable feedback.
Policies support gradual enforcement—from silent scoring to blocking PRs or deployments—letting you roll out standards at your own pace without disrupting existing workflows.
Learn How Lunar Works →Required Integrations
This policy evaluates data gathered by one or more of the following integration(s).
Make sure to enable them in your lunar-config.yml.
Configuration
Configure this policy in your lunar-config.yml.
Inputs
| Input | Required | Default | Description |
|---|---|---|---|
min_severity
|
Optional |
high
|
Minimum severity to fail on (critical, high, medium, low) |
ignore_unfixable
|
Optional |
false
|
Only fail max-severity on findings that have a fix available. Unfixable findings are still reported in the component JSON. |
exempt_findings
|
Required | — | Vulnerabilities `max-severity` ignores because the risk was reviewed and accepted. Entries are `<id> <package>@<version>`, optionally followed by image globs, separated by newlines or commas: ```yaml with: exempt_findings: | # bundled inside a vendor tool that hasn't shipped urllib3 2.8.0 CVE-2026-97687 urllib3@2.7.0 registry.example.com/tools:* ``` Id, package and version must match the finding exactly (the id ignores case), so an entry stops matching once the package is upgraded. With globs, only findings in a matching image are exempt, and a finding with no recorded image never is. `#` comments are ignored. Exempt findings stay in the component JSON. The check still fails on the rest, and resolves to `skip`, naming the entries that matched, only when every finding that would have failed was exempt. An entry that cannot be parsed fails the check without exempting anything. `max-total` ignores this input. Approved records in `.compliance.vulnerability_exemptions.records[]` (written by the `vulnerability-exemptions` collector) also exempt findings that have no fix, read at run time, alongside this input. |
max_total_threshold
|
Required | — | Maximum total findings allowed (must be configured) |
Documentation
View on GitHubContainer Scan Guardrails
Enforces container image vulnerability scanning standards for container security.
Overview
This policy validates that container scanning is configured and enforces vulnerability thresholds for container images. It works with any container scanner that writes to the normalized .container_scan path in the Component JSON (Trivy, Grype, Snyk Container, etc.).
Policies
This plugin provides the following policies (use include to select a subset):
| Policy | Description | Failure Meaning |
|---|---|---|
executed |
Verifies container scanning ran | No scanner has written to .container_scan |
max-severity |
No findings at or above severity threshold | Findings found at configured severity or higher |
max-total |
Total vulnerabilities under threshold | Total count exceeds configured limit |
unfixable-reviewed |
Every finding with no fix at or above the threshold has an approved exemption | A finding with no fix has no approved exemption |
Required Data
This policy reads from the following Component JSON paths:
| Path | Type | Provided By |
|---|---|---|
.container_scan |
object | Any container scanner collector (Trivy, Grype, etc.) |
.container_scan.vulnerabilities.critical |
number | Container scanner collector |
.container_scan.vulnerabilities.high |
number | Container scanner collector |
.container_scan.vulnerabilities.medium |
number | Container scanner collector |
.container_scan.vulnerabilities.low |
number | Container scanner collector |
.container_scan.vulnerabilities.total |
number | Container scanner collector |
.container_scan.summary.has_critical |
boolean | Container scanner collector (preferred) |
.container_scan.summary.has_high |
boolean | Container scanner collector (preferred) |
.container_scan.summary.has_medium |
boolean | Container scanner collector (preferred) |
.container_scan.summary.has_low |
boolean | Container scanner collector (preferred) |
.containers.native.docker.cicd.cmds[] |
array | docker collector, plus any CI step that records a pushed image — the pushed refs decide whether max-severity / max-total apply |
.container_scan.findings[] |
array | Container scanner collector — names the offending packages/CVEs in the max-severity failure message and drives ignore_unfixable and exempt_findings (cve, severity, package, version, fix_version, fixable, image) |
.compliance.vulnerability_exemptions.records[] |
array | Optional: vulnerability-exemptions collector — exemption records for findings with no fix (cve, component, until) |
Note: If collectors don't yet write vulnerability counts, the max-severity and max-total checks will fail. Use include: [executed] to only verify the scanner ran until collectors are enhanced.
Applicability. max-severity and max-total skip a component with no .containers, and also skip a commit whose .containers record carries no pushed image ref — a CI tracer records docker info / docker ps there, so the object alone does not mean an image shipped. With a pushed ref and no .container_scan they fail: something shipped that nothing scanned. The pushed-ref resolution matches the one the Trivy and Grype container sub-collectors use to choose images, so these checks are applicable exactly when a scanner had a target. executed does not consult it — include it to require a scan whatever the CI record shows.
Installation
Add to your lunar-config.yml:
policies:
- uses: github://earthly/lunar-lib/policies/container-scan@main
on: ["domain:your-domain"]
enforcement: report-pr
include: [executed, max-severity, max-total, unfixable-reviewed]
with:
min_severity: "high" # Fail on critical and high findings
max_total_threshold: "10" # Fail if more than 10 total findings
# ignore_unfixable: "true" # Optional: only fail on findings that have a fix
# exempt_findings: "" # Optional: accepted risks, see below
Only failing on fixable findings
By default max-severity fails on every finding at or above min_severity,
whether or not a fix exists. That is the right default, but base images routinely
carry criticals that the upstream distro has not fixed — so a release gate on
critical can end up permanently closed on findings no change to your own image
can clear. Set ignore_unfixable: "true" to narrow the failure to findings that
carry an upgrade target, which is what makes a block-pr-and-release gate
practical:
enforcement: block-pr-and-release
with:
min_severity: "critical"
ignore_unfixable: "true"
Unfixable findings are still collected and still visible in the Component JSON and the dashboard — the option changes what the check gates on, never what is recorded. It can only turn a failure into a pass: it is applied after the threshold has already been crossed, so it never creates a failure the default would not have raised.
Two behaviours worth knowing:
- A finding counts as fixable when the scanner reported a fix version. Trivy and Grype can both scan the same image; where they disagree, the one that found a fix wins — a fix that exists is actionable.
- If the scanner reported only summary counts and no
.container_scan.findings[], fixability cannot be evaluated per finding, so the check fails as it would with the option off and says so in the failure message..container_scan.summary.all_fixableis not a substitute: it is a single boolean across all severities, so it cannot answer whether the in-scope findings are fixable.
Exempting accepted findings
A scanner marks a finding fixable when the library has a fixed release, even if
you can't take it yet. The usual case is a library bundled inside a third-party
tool: the library has the fix, but the tool hasn't shipped it. List those in
exempt_findings, one <id> <package>@<version> per line, optionally limited
to some images:
with:
min_severity: "high"
ignore_unfixable: "true"
exempt_findings: |
# bundled inside a vendor tool that hasn't shipped urllib3 2.8.0
CVE-2026-97687 urllib3@2.7.0 registry.example.com/tools:*
The version must match exactly, so the entry stops applying once the package is
upgraded. An exemption never turns a finding into a pass: other findings still
fail, and the check resolves to skip, naming what it exempted, only when
nothing else would have failed.
Reviewing findings with no fix
ignore_unfixable keeps a gate practical, but it also hides findings no one has
looked at. unfixable-reviewed closes that gap: it fails once per image finding
at or above min_severity that has no fix available and no approved exemption.
Run it as a non-blocking report next to the gate:
- uses: github://earthly/lunar-lib/policies/container-scan@main
name: image-cve-unfixable
include: [unfixable-reviewed]
enforcement: report-pr
with:
min_severity: "high"
It skips when no scan data is present, and it never counts fixable findings,
which max-severity already gates.
Exemption records
Approved exemptions can also arrive at run time, in
.compliance.vulnerability_exemptions.records[], written by the
vulnerability-exemptions
collector from a register kept in one compliance repository. max-severity and
unfixable-reviewed honor them alongside exempt_findings, so a new or expired
exemption needs no config change. A record applies to a finding when all of these hold:
- the finding's
cvematches the record'scve(case-insensitive); - the record's
componentis the component being checked; - today is not past
until; - the finding has no fix. Once a fix ships, the record stops applying and
max-severitytakes over;exempt_findingscovers a fix you can't take yet.
Examples
Passing Example
{
"container_scan": {
"source": { "tool": "trivy", "integration": "cicd" },
"vulnerabilities": { "critical": 0, "high": 0, "medium": 5, "total": 12 },
"summary": { "has_critical": false, "has_high": false }
}
}
Failing Example
{
"container_scan": {
"source": { "tool": "trivy", "integration": "cicd" },
"vulnerabilities": { "critical": 3, "high": 8, "medium": 15, "total": 40 },
"findings": [
{ "severity": "critical", "package": "openssl", "cve": "CVE-2026-1234", "fix_version": "3.0.14" },
{ "severity": "high", "package": "libcurl", "cve": "CVE-2026-5678", "fix_version": null }
],
"summary": { "has_critical": true, "has_high": true }
}
}
Failure messages:
executed: "No container scan results at this commit, though it pushed 2 image(s) — either no scanner is configured for this component, or a configured one recorded nothing here.", followed by onenot scanned: <image>assertion per pushed image.max-severityandmax-totalsay the same when an image shipped and nothing scanned it; with no pushed image they skip instead.max-severity: fails with a headline assertion plus one assertion per offending package/CVE (most severe first), the same format thescapolicy uses — no policy-side cap; the hub truncates the display. When the scanner emits per-finding detail it renders as a nested list under the check:
A summary-only scanner (no❌ max-severity * Critical container vulnerabilities detected * critical: openssl — CVE-2026-1234 (fix: 3.0.14) * high: libcurl — CVE-2026-5678 (no fix available).container_scan.findings[]) falls back to the headline alone (e.g.Critical container vulnerabilities detected).max-total: "Total container vulnerability findings (40) exceeds threshold (10)"
Remediation
When this policy fails, you can resolve it by:
executedfailure: Configure a container scanner (Trivy, Grype, Snyk Container) in your CI pipeline. If one is already configured, check its run for this commit — a scanner that ran but recorded nothing leaves the same empty.container_scan.max-severityfailure: Review and remediate flagged vulnerabilities by updating base images, or list a reviewed, accepted risk inexempt_findings.max-totalfailure: Reduce total vulnerability count by updating base images and dependencies.
Open Source
This policy is open source and available on GitHub. Contribute improvements, report issues, or fork it for your own use.
Common Use Cases
Explore how individual guardrails work with specific integrations.
Ready to Automate Your Standards?
See how Lunar can turn your AGENTS.md, engineering wiki, compliance docs, or postmortem action items into automated guardrails with our 300+ built-in guardrails.