-
Notifications
You must be signed in to change notification settings - Fork 2
feat(cve-scan): add reusable action to scan container images for cves #213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
31227f8
edbf292
ebde485
d64d626
3cef3c9
25e8163
0e9d46a
181391d
e8c76ff
045e770
ad4d238
0d0419b
0cfd9cb
a2c0a2a
fa97ec7
29650b5
fe2f785
bf18fd8
001f3db
3ab9de7
6c54623
d15c1e1
18dd42f
a6e705f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| name: 'cve-scan' | ||
| description: 'Scan a container image for CVEs with a swappable scanner backend, gate optionally on severity, and report findings.' | ||
| branding: | ||
| icon: "shield" | ||
| color: "red" | ||
|
|
||
| inputs: | ||
| image-ref: | ||
| description: "Full registry reference to scan, e.g. `ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`." | ||
| required: true | ||
| dockerfile-path: | ||
| description: "Optional path to the Dockerfile that built the image, passed to the scanner as `--file`. This buys base-image remediation advice only; it does not change which vulnerabilities are found, since application dependencies are scanned by default. Leave empty when scanning a published image with no checkout. If set, it must exist." | ||
| required: false | ||
| default: "" | ||
| scanner: | ||
| description: "Which scanner adapter to run. Selects `src/scanners/<scanner>.sh`. The action's inputs/outputs never change when this does." | ||
| required: false | ||
| default: "snyk" | ||
| scanner-token: | ||
| description: "Credential the selected adapter needs, if any. Named generically on purpose — its meaning depends on `scanner`, so swapping tools repoints the secret a caller maps in here without renaming this input." | ||
| required: false | ||
| default: "" | ||
| scanner-version: | ||
| description: "Version of the scanner CLI to install when it isn't already on the runner. Pinned so results are reproducible; the adapter installs and checksum-verifies it. Renovate keeps the default current." | ||
| required: false | ||
| # renovate: datasource=github-releases depName=snyk/cli | ||
| default: v1.1306.3 | ||
| registry: | ||
| description: "Registry host to authenticate against, e.g. `ghcr.io`. Only used when `registry-password` is set." | ||
| required: false | ||
| default: "ghcr.io" | ||
| registry-username: | ||
| description: "Username for the registry holding `image-ref`. Defaults to the triggering actor, which is what GHCR expects. GHCR only checks the token, but docker/login-action requires a username value to be present — same constraint promote-release documents." | ||
| required: false | ||
| default: ${{ github.actor }} | ||
| registry-password: | ||
| description: "Password or token for the registry holding `image-ref`. For GHCR pass the caller's `secrets.GITHUB_TOKEN` with `packages: read`. Masked in the log before use." | ||
|
vcauesantos marked this conversation as resolved.
|
||
| required: false | ||
| default: "" | ||
| severity-threshold: | ||
| description: "Minimum severity that counts toward gating: `critical`, `high`, `medium`, or `low`." | ||
| required: false | ||
| default: "high" | ||
| enabled: | ||
| description: "Kill switch. Only an explicit falsey value (`false`/`no`/`0`/`off`, any case) skips the scan, and the skip is annotated rather than silent. An unrecognised value scans anyway and warns — resolving toward scanning so a typo in a repo variable can't quietly disable a security control." | ||
| required: false | ||
| default: "true" | ||
| block-on-findings: | ||
| description: "When `true` (or `yes`/`1`/`on`), findings at or above `severity-threshold` fail the job. When `false` (default) or unrecognised, findings are always reported but never fail the job. A scanner error never fails the job either way; a config error always does — see README." | ||
| required: false | ||
| default: "false" | ||
| notify: | ||
| description: "Send a Slack notification on findings, a scanner error, or a config error. Only fires on `schedule` and `release` events — a `workflow_dispatch` or PR run never notifies, so testing the action can't spam the channel. The Job Summary is written either way." | ||
| required: false | ||
| default: "true" | ||
| slack-webhook-url: | ||
| description: "Slack incoming webhook URL for the ci-test-notify action. Required when `notify: true`." | ||
| required: false | ||
| default: "" | ||
|
|
||
| outputs: | ||
| has-vulnerabilities: | ||
| description: "`true` if any finding at or above `severity-threshold` was reported. Findings below the threshold are still counted in the per-severity outputs and the report; they just don't set this to `true` or drive the Slack notification." | ||
| value: ${{ steps.scan.outputs.has-vulnerabilities }} | ||
| critical-count: | ||
| description: "Critical-severity finding count." | ||
| value: ${{ steps.scan.outputs.critical-count }} | ||
| high-count: | ||
| description: "High-severity finding count." | ||
| value: ${{ steps.scan.outputs.high-count }} | ||
| medium-count: | ||
| description: "Medium-severity finding count." | ||
| value: ${{ steps.scan.outputs.medium-count }} | ||
| low-count: | ||
| description: "Low-severity finding count." | ||
| value: ${{ steps.scan.outputs.low-count }} | ||
| scanner-error: | ||
| description: "`true` if the scan could not complete (timeout, registry failure, unparseable output). Distinct from finding CVEs — never fails the job regardless of `block-on-findings`. Also distinct from a *setup* error (missing CLI or credential), which fails the job and leaves this unset — config errors write no result outputs at all." | ||
| value: ${{ steps.scan.outputs.scanner-error }} | ||
| report-path: | ||
| description: "Path to a short markdown summary — the per-severity counts and what was scanned. Per-finding detail is in `sarif-path`, not here." | ||
| value: ${{ steps.scan.outputs.report-path }} | ||
| summary: | ||
| description: "Slack-ready text summary, distinct from `report-path` (the full file). Always set: a completed scan reports the per-severity counts even when they are all zero, and a skipped or inconclusive run says which it was." | ||
| value: ${{ steps.scan.outputs.summary }} | ||
| sarif-path: | ||
| description: "Path to the SARIF file the scanner emitted, or empty if it produced none. Upload it with `github/codeql-action/upload-sarif` from the caller workflow to get per-finding detail in the Security tab — this action does not upload it itself, to keep `security-events: write` out of its own permission footprint." | ||
| value: ${{ steps.scan.outputs.sarif-path }} | ||
|
|
||
| runs: | ||
| using: "composite" | ||
| steps: | ||
| # docker/login-action only calls core.setSecret on its multi-registry | ||
| # `registry-auth` input, not the single-registry form used below, so | ||
| # masking here is what backs this input's documented guarantee. GitHub's | ||
| # automatic masking covers only an exact literal from the `secrets.` | ||
| # context, which misses a caller who composes or re-encodes the credential. | ||
| - name: Mask the registry password | ||
| if: inputs.registry-password != '' | ||
| shell: bash | ||
| env: | ||
| REGISTRY_PASSWORD: ${{ inputs.registry-password }} | ||
| run: echo "::add-mask::${REGISTRY_PASSWORD}" | ||
|
|
||
| - name: Log in to the image registry | ||
| if: inputs.registry-password != '' && !contains(fromJSON('["false","no","0","off"]'), inputs.enabled) | ||
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 | ||
| # A registry that can't be reached is inconclusive, not a finding, so this | ||
| # must not fail the caller's job — run.sh classifies the pull failure that | ||
| # follows. See README, "Three failure modes". | ||
| continue-on-error: true | ||
| with: | ||
| registry: ${{ inputs.registry }} | ||
| username: ${{ inputs.registry-username }} | ||
| password: ${{ inputs.registry-password }} | ||
|
|
||
| - name: Scan image for vulnerabilities | ||
| id: scan | ||
| shell: bash | ||
| env: | ||
| ACTION_PATH: ${{ github.action_path }} | ||
| IMAGE_REF: ${{ inputs.image-ref }} | ||
| DOCKERFILE_PATH: ${{ inputs.dockerfile-path }} | ||
| SCANNER: ${{ inputs.scanner }} | ||
| SCANNER_TOKEN: ${{ inputs.scanner-token }} | ||
| SCANNER_VERSION: ${{ inputs.scanner-version }} | ||
| SEVERITY_THRESHOLD: ${{ inputs.severity-threshold }} | ||
| ENABLED: ${{ inputs.enabled }} | ||
| BLOCK_ON_FINDINGS: ${{ inputs.block-on-findings }} | ||
| run: ${{ github.action_path }}/run.sh | ||
|
|
||
| - name: Send Slack notification | ||
| # Two gates, for two different reasons. | ||
| # | ||
| # Event allowlist: only the two triggers this action is built for can | ||
| # post. A `workflow_dispatch` run is someone testing, and a channel that | ||
| # gets a message every time an engineer pokes the action is a channel | ||
| # people learn to ignore — which costs more than the alert is worth. | ||
| # `govulncheck` restricts to `schedule` for the same reason; the release | ||
| # event is added here because the prerelease scan is the case this action | ||
| # exists for. Deliberately an allowlist, not a denylist: a new trigger | ||
| # should have to opt in rather than start posting by surprise. | ||
| # | ||
| # Outcome gate: `outcome == 'failure'` is the third case. A config error | ||
| # (bad severity-threshold, a scanner that couldn't | ||
| # be provisioned) fails the job but sets neither `scanner-error` nor | ||
| # `has-vulnerabilities`, so without it that outcome reddened the run and | ||
| # told nobody — despite being the most actionable of the three. | ||
| if: >- | ||
| always() && | ||
| inputs.notify == 'true' && | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. consider —
A caller who silences one repo by wiring GitHub expressions cannot case-fold, so there are two honest options. The cheap one is to make the manifest's contract match its behaviour — state in the |
||
| (github.event_name == 'schedule' || github.event_name == 'release') && | ||
| (steps.scan.outputs.has-vulnerabilities == 'true' || | ||
| steps.scan.outputs.scanner-error == 'true' || | ||
| steps.scan.outcome == 'failure') | ||
|
Comment on lines
+150
to
+155
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. consider — The Slack gate has no arm for a cancelled scan step, so the most likely unattended-cron failure produces no page and no Job Summary entry.
When that job timeout fires, The result is a scheduled security scan that silently stops scanning, which is the outcome this action's whole error taxonomy exists to prevent. It is a fifth state the README does not name. if: >-
always() &&
inputs.notify == 'true' &&
(github.event_name == 'schedule' || github.event_name == 'release') &&
(steps.scan.outputs.has-vulnerabilities == 'true' ||
steps.scan.outputs.scanner-error == 'true' ||
steps.scan.outcome == 'failure' ||
steps.scan.outcome == 'cancelled')…with a matching arm in the This becomes blocking once the Phase 3 callers land, because at that point a hung daily scan across three repos is invisible to everyone. |
||
| uses: loft-sh/github-actions/.github/actions/ci-test-notify@85d7023c5749421d369f59430c7849f2d00ad694 # ci-test-notify/v1 | ||
| with: | ||
| test-name: "cve-scan: ${{ inputs.image-ref }}" | ||
| # Every branch of the `if:` above is a failure, so the status is a | ||
| # literal rather than a ternary that could only ever pick one value. | ||
| status: failure | ||
| # All three outcomes share that status, so the distinction has to live | ||
| # in the text: "the scanner broke", "we shipped critical CVEs" and "the | ||
| # config is wrong" are very different calls to action for whoever is on | ||
| # call, and someone skimming Slack by colour can't tell them apart. | ||
| # Ordered so a blocked scan with findings reads as findings, not as a | ||
| # config error — it fails the job too. | ||
| details: | | ||
| ${{ steps.scan.outputs.scanner-error == 'true' && 'SCANNER ERROR — no scan was performed, nothing was verified' || (steps.scan.outputs.has-vulnerabilities == 'true' && 'FINDINGS at or above the severity threshold' || 'CONFIGURATION ERROR — cve-scan could not run. An authoring or provisioning mistake, not a scan result; see the job log.') }} | ||
| ``` | ||
| ${{ steps.scan.outputs.summary }} | ||
| ``` | ||
| webhook-url: ${{ inputs.slack-webhook-url }} | ||
Uh oh!
There was an error while loading. Please reload this page.