Repository navigation
ci: add secscan vulnerability scanning #396
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
Draft
cbartz
wants to merge
7
commits into
main
Choose a base branch
from
ci/secscan
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+380
−1
Draft
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
997ac0c
ci: add secscan vulnerability scanning
cbartz aa3a752
ci(secscan): fail gate when statefile lacks manifest artifacts
cbartz e0c4173
ci(secscan): bound sbomber poll within job timeout and name result files
cbartz edeed3d
test(secscan): add unit tests for the CVE gate
cbartz 0f2ae21
Merge branch 'main' into ci/secscan
cbartz 045fd23
ci(secscan): drop fork-PR guard covered by external contributor check
cbartz 9382d61
Merge remote-tracking branch 'origin/main' into ci/secscan
cbartz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # Copyright 2026 Canonical Ltd. | ||
| # See LICENSE file for licensing details. | ||
|
|
||
| # Artifacts scanned by .github/workflows/secscan.yaml (SEC0025). Names must match the | ||
| # files in .github/secscan-exclusions/. | ||
| clients: | ||
| secscan: {} | ||
|
|
||
| artifacts: | ||
| - name: garm | ||
| type: charm | ||
| channel: latest/edge | ||
| ssdlc_params: { name: github-runner, version: "", channel: edge, cycle: "26.10" } | ||
| - name: garm-configurator | ||
| type: charm | ||
| channel: latest/edge | ||
| ssdlc_params: { name: github-runner, version: "", channel: edge, cycle: "26.10" } | ||
| - name: github-runner-planner | ||
| type: charm | ||
| channel: latest/edge | ||
| ssdlc_params: { name: github-runner, version: "", channel: edge, cycle: "26.10" } | ||
| - name: github-runner-webhook-gateway | ||
| type: charm | ||
| channel: latest/edge | ||
| ssdlc_params: { name: github-runner, version: "", channel: edge, cycle: "26.10" } | ||
| - name: garm-app-image | ||
| type: rock | ||
| charm: garm | ||
| channel: latest/edge | ||
| charm_resource: app-image | ||
| ssdlc_params: { name: github-runner, version: "", channel: edge, cycle: "26.10" } | ||
| - name: github-runner-planner-app-image | ||
| type: rock | ||
| charm: github-runner-planner | ||
| channel: latest/edge | ||
| charm_resource: app-image | ||
| ssdlc_params: { name: github-runner, version: "", channel: edge, cycle: "26.10" } | ||
| - name: github-runner-webhook-gateway-app-image | ||
| type: rock | ||
| charm: github-runner-webhook-gateway | ||
| channel: latest/edge | ||
| charm_resource: app-image | ||
| ssdlc_params: { name: github-runner, version: "", channel: edge, cycle: "26.10" } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # Copyright 2026 Canonical Ltd. | ||
| # See LICENSE file for licensing details. | ||
|
|
||
| """Fail CI when any sbomber-submitted secscan result has unexcluded CVEs. | ||
|
|
||
| sbomber only reports whether a scan ran, not what it found, so this re-reads each | ||
| artifact's result through `secscan-client --batch`, whose exit code encodes findings. | ||
| """ | ||
|
|
||
| import os | ||
| import pathlib | ||
| import subprocess | ||
| import sys | ||
|
|
||
| import yaml | ||
|
|
||
| MANIFEST = pathlib.Path(".github/sbomber-manifest.yaml") | ||
| STATEFILE = pathlib.Path("scanner/.statefile.yaml") | ||
| REPORTS = pathlib.Path("scanner/reports") | ||
| EXCLUSIONS = pathlib.Path(".github/secscan-exclusions") | ||
|
|
||
|
|
||
| def main() -> int: | ||
| state = yaml.safe_load(STATEFILE.read_text()) | ||
| REPORTS.mkdir(parents=True, exist_ok=True) | ||
| artifacts = state.get("artifacts") or [] | ||
| failed = False | ||
| if not artifacts: | ||
| print("::error::sbomber statefile has no artifacts; prepare or submit failed, re-run the job.") | ||
| failed = True | ||
| # A dropped artifact would otherwise go unscanned and pass silently. | ||
| expected = [a["name"] for a in yaml.safe_load(MANIFEST.read_text())["artifacts"]] | ||
| present = {a["name"] for a in artifacts} | ||
| for name in expected: | ||
| if name not in present: | ||
| print(f"::error::{name}: missing from the sbomber statefile; it was not scanned, re-run the job.") | ||
| failed = True | ||
| for artifact in artifacts: | ||
| name = artifact["name"] | ||
| token = ((artifact.get("processing") or {}).get("secscan") or {}).get("token") | ||
| if not token: | ||
| print(f"::error::{name}: no secscan token; submission failed, re-run the job.") | ||
| failed = True | ||
| continue | ||
| proc = subprocess.run( | ||
| ["secscan-client", "--batch", "result", *_exclusion_args(name)], | ||
| input=token, text=True, capture_output=True, check=False, | ||
|
cbartz marked this conversation as resolved.
|
||
| ) | ||
| (REPORTS / f"{name}.result.txt").write_text(proc.stdout + proc.stderr) | ||
| print(_classify(name, proc.returncode)) | ||
| failed = failed or proc.returncode != 0 | ||
| return 1 if failed else 0 | ||
|
|
||
|
|
||
| def _exclusion_args(name: str) -> list[str]: | ||
| src = EXCLUSIONS / f"{name}.txt" | ||
| if not src.is_file(): | ||
| return [] | ||
| active = [l for l in src.read_text().splitlines() if l.strip() and not l.strip().startswith("#")] | ||
| if not active: | ||
| return [] | ||
| # The snap can only read files under its own common directory. | ||
| staged = pathlib.Path(os.environ["SECSCAN_DIR"]) / f"exclusions-{name}.txt" | ||
| staged.write_text(src.read_text()) | ||
| return ["--exclusions-filename", str(staged)] | ||
|
|
||
|
|
||
| def _classify(name: str, rc: int) -> str: | ||
| if rc == 0: | ||
| return f"{name}: No CVEs found." | ||
| if rc == 101: | ||
| return f"::error::{name}: Excluded CVEs are no longer reported; remove them from .github/secscan-exclusions/." | ||
| if 100 <= rc <= 199: | ||
| return f"::error::{name}: CVEs found; download the secscan report artifact and triage per SECURITY.md." | ||
| return f"::error::{name}: secscan-client failed (exit {rc}); re-run the job." | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| # Copyright 2026 Canonical Ltd. | ||
| # See LICENSE file for licensing details. | ||
|
|
||
| """Unit tests for secscan_gate, driven through a fake ``secscan-client`` on PATH.""" | ||
|
|
||
| import importlib.util | ||
| import pathlib | ||
| import stat | ||
|
|
||
| import pytest | ||
| import yaml | ||
|
|
||
| _SPEC = importlib.util.spec_from_file_location( | ||
| "secscan_gate", pathlib.Path(__file__).with_name("secscan_gate.py") | ||
| ) | ||
| gate = importlib.util.module_from_spec(_SPEC) | ||
| _SPEC.loader.exec_module(gate) | ||
|
|
||
| # The token is the fake client's stdin, so it selects the exit code. | ||
| FAKE_CLIENT = """#!/usr/bin/env bash | ||
| tok=$(cat); echo "args=$* token=$tok" | ||
| case "$tok" in clean) exit 0;; stale) exit 101;; cves) exit 150;; *) exit 3;; esac | ||
| """ | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def workspace(tmp_path, monkeypatch): | ||
| """A repo-like cwd with a fake client on PATH; returns a helper to lay out gate inputs.""" | ||
| bin_dir = tmp_path / "bin" | ||
| bin_dir.mkdir() | ||
| client = bin_dir / "secscan-client" | ||
| client.write_text(FAKE_CLIENT) | ||
| client.chmod(client.stat().st_mode | stat.S_IEXEC) | ||
| monkeypatch.setenv("PATH", f"{bin_dir}:{pathlib.os.environ['PATH']}") | ||
| monkeypatch.setenv("SECSCAN_DIR", str(tmp_path)) | ||
| monkeypatch.chdir(tmp_path) | ||
| (tmp_path / ".github" / "secscan-exclusions").mkdir(parents=True) | ||
| (tmp_path / "scanner").mkdir() | ||
|
|
||
| def setup(manifest: list[str], state: dict) -> pathlib.Path: | ||
| manifest_doc = {"artifacts": [{"name": n} for n in manifest]} | ||
| (tmp_path / ".github" / "sbomber-manifest.yaml").write_text(yaml.safe_dump(manifest_doc)) | ||
| (tmp_path / "scanner" / ".statefile.yaml").write_text(yaml.safe_dump(state)) | ||
| return tmp_path | ||
|
|
||
| return setup | ||
|
|
||
|
|
||
| def _artifact(name: str, token: str | None) -> dict: | ||
| entry = {"name": name} | ||
| if token: | ||
| entry["processing"] = {"secscan": {"token": token}} | ||
| return entry | ||
|
|
||
|
|
||
| def test_gate_passes_when_every_artifact_is_clean(workspace, capsys): | ||
| """ | ||
| arrange: a statefile with two clean artifacts that match the manifest. | ||
| act: run the gate. | ||
| assert: exit 0, each artifact reported clean and its result file written. | ||
| """ | ||
| root = workspace(["a", "b"], {"artifacts": [_artifact("a", "clean"), _artifact("b", "clean")]}) | ||
|
|
||
| assert gate.main() == 0 | ||
|
|
||
| out = capsys.readouterr().out | ||
| assert "a: No CVEs found." in out and "b: No CVEs found." in out | ||
| assert (root / "scanner" / "reports" / "a.result.txt").is_file() | ||
|
|
||
|
|
||
| def test_gate_stages_only_active_exclusions(workspace): | ||
| """ | ||
| arrange: artifact a has an active exclusion, b only comments, c has no file. | ||
| act: run the gate. | ||
| assert: only a is passed --exclusions-filename, pointing at a staged copy. | ||
| """ | ||
| root = workspace( | ||
| ["a", "b", "c"], | ||
| {"artifacts": [_artifact(n, "clean") for n in "abc"]}, | ||
| ) | ||
| exclusions = root / ".github" / "secscan-exclusions" | ||
| (exclusions / "a.txt").write_text("# why\nCVE-1\n") | ||
| (exclusions / "b.txt").write_text("# only a comment\n\n") | ||
|
|
||
| assert gate.main() == 0 | ||
|
|
||
| reports = root / "scanner" / "reports" | ||
| assert f"--exclusions-filename {root}/exclusions-a.txt" in (reports / "a.result.txt").read_text() | ||
| assert "--exclusions-filename" not in (reports / "b.result.txt").read_text() | ||
| assert "--exclusions-filename" not in (reports / "c.result.txt").read_text() | ||
| assert (root / "exclusions-a.txt").read_text() == "# why\nCVE-1\n" | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "token, expected", | ||
| [ | ||
| ("stale", "::error::a: Excluded CVEs are no longer reported"), | ||
| ("cves", "::error::a: CVEs found"), | ||
| ("broken", "::error::a: secscan-client failed (exit 3)"), | ||
| (None, "::error::a: no secscan token"), | ||
| ], | ||
| ) | ||
| def test_gate_fails_on_non_clean_results(workspace, capsys, token, expected): | ||
| """ | ||
| arrange: one artifact whose client exit code is 101, 1xx, another error, or no token. | ||
| act: run the gate. | ||
| assert: exit 1 with the matching classification. | ||
| """ | ||
| workspace(["a"], {"artifacts": [_artifact("a", token)]}) | ||
|
|
||
| assert gate.main() == 1 | ||
|
|
||
| assert expected in capsys.readouterr().out | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "state, expected", | ||
| [ | ||
| ({}, "sbomber statefile has no artifacts"), | ||
| ({"artifacts": []}, "sbomber statefile has no artifacts"), | ||
| ({"artifacts": [_artifact("a", "clean")]}, "::error::b: missing from the sbomber statefile"), | ||
| ], | ||
| ) | ||
| def test_gate_fails_when_manifest_artifacts_were_not_scanned(workspace, capsys, state, expected): | ||
| """ | ||
| arrange: a statefile that is empty or lacks manifest artifact b. | ||
| act: run the gate. | ||
| assert: exit 1 naming the problem, so an unscanned artifact can never pass silently. | ||
| """ | ||
| workspace(["a", "b"], state) | ||
|
|
||
| assert gate.main() == 1 | ||
|
|
||
| assert expected in capsys.readouterr().out |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # CVEs excluded from the secscan gate for this artifact, one per line. | ||
| # Every entry needs a justification comment and a link to its draft GHSA. | ||
| # High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # CVEs excluded from the secscan gate for this artifact, one per line. | ||
| # Every entry needs a justification comment and a link to its draft GHSA. | ||
| # High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # CVEs excluded from the secscan gate for this artifact, one per line. | ||
| # Every entry needs a justification comment and a link to its draft GHSA. | ||
| # High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). |
3 changes: 3 additions & 0 deletions
3
.github/secscan-exclusions/github-runner-planner-app-image.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # CVEs excluded from the secscan gate for this artifact, one per line. | ||
| # Every entry needs a justification comment and a link to its draft GHSA. | ||
| # High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # CVEs excluded from the secscan gate for this artifact, one per line. | ||
| # Every entry needs a justification comment and a link to its draft GHSA. | ||
| # High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). |
3 changes: 3 additions & 0 deletions
3
.github/secscan-exclusions/github-runner-webhook-gateway-app-image.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # CVEs excluded from the secscan gate for this artifact, one per line. | ||
| # Every entry needs a justification comment and a link to its draft GHSA. | ||
| # High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # CVEs excluded from the secscan gate for this artifact, one per line. | ||
| # Every entry needs a justification comment and a link to its draft GHSA. | ||
| # High/critical exclusions need GRC/SecOps approval recorded in the GHSA (SEC0025). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| # Copyright 2026 Canonical Ltd. | ||
| # See LICENSE file for licensing details. | ||
|
|
||
| # Scans the published GARM-stack charms and their OCI resources with Canonical secscan | ||
| # (via sbomber) to meet SEC0025. Called after each publish to edge, and run weekly. | ||
| name: Secscan | ||
|
|
||
| on: | ||
| schedule: | ||
| - cron: "41 4 * * 1" | ||
| workflow_dispatch: | ||
| workflow_call: | ||
| pull_request: | ||
| paths: | ||
| - .github/workflows/secscan.yaml | ||
| - .github/sbomber-manifest.yaml | ||
| - .github/scripts/secscan_gate.py | ||
| - .github/secscan-exclusions/** | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
| secscan: | ||
| name: Secscan charms and images | ||
| runs-on: [self-hosted-linux-amd64-noble-private-endpoint-medium] | ||
| permissions: | ||
| contents: read | ||
| timeout-minutes: 120 | ||
| steps: | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| repository: canonical/sbomber | ||
| ref: 2b11503a3d840abadc50a9c14a7f0cc56e8b8096 | ||
| path: scanner | ||
| persist-credentials: false | ||
| - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | ||
| - name: Install tools | ||
| run: | | ||
| sudo apt-get update -q | ||
| sudo apt-get install -y -q python3-apt skopeo | ||
| sudo snap install juju --channel=3.6/stable | ||
| sudo snap install canonical-secscan-client | ||
| sudo snap connect canonical-secscan-client:home system:home | ||
| mkdir -p "$HOME/snap/canonical-secscan-client/common" | ||
| echo "SECSCAN_DIR=$HOME/snap/canonical-secscan-client/common" >> "$GITHUB_ENV" | ||
| echo "NO_PROXY=${NO_PROXY:+$NO_PROXY,}.internal" >> "$GITHUB_ENV" | ||
| echo "no_proxy=${no_proxy:+$no_proxy,}.internal" >> "$GITHUB_ENV" | ||
| - name: Prepare, submit and wait | ||
| working-directory: scanner | ||
| run: | | ||
| ./sbomber prepare ../.github/sbomber-manifest.yaml | ||
| ./sbomber submit | ||
| # --timeout is per artifact and the 7 are awaited sequentially; keep 7 x timeout under the job timeout. | ||
| ./sbomber poll --wait --timeout 15 | ||
| - name: Download reports | ||
| if: always() | ||
| working-directory: scanner | ||
| run: ./sbomber download | ||
| - name: Gate on findings | ||
| run: scanner/.venv/bin/python .github/scripts/secscan_gate.py | ||
| - name: Upload reports | ||
| if: always() | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: secscan-reports | ||
| path: scanner/reports/ | ||
| if-no-files-found: warn |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.