Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/sbomber-manifest.yaml
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" }
79 changes: 79 additions & 0 deletions .github/scripts/secscan_gate.py
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())
Comment thread
cbartz marked this conversation as resolved.
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,
Comment thread
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())
134 changes: 134 additions & 0 deletions .github/scripts/test_secscan_gate.py
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
3 changes: 3 additions & 0 deletions .github/secscan-exclusions/garm-app-image.txt
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 .github/secscan-exclusions/garm-configurator.txt
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 .github/secscan-exclusions/garm.txt
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).
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 .github/secscan-exclusions/github-runner-planner.txt
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).
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).
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).
5 changes: 5 additions & 0 deletions .github/workflows/publish_charms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ jobs:
secrets:
CHARMHUB_TOKEN: ${{ secrets.CHARMHUB_TOKEN }}

secscan:
needs: publish
permissions:
contents: read
uses: ./.github/workflows/secscan.yaml # zizmor: ignore[self-repository] "$/" syntax is not supported by actionlint
70 changes: 70 additions & 0 deletions .github/workflows/secscan.yaml
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The Go layout follows the [community Go project layout](https://github.com/golan
- **GARM end-to-end test** (root `tox.ini`) — `tox -e garm-e2e`. Runs against a real OpenStack tenant from `garm_e2e.yaml`, is manually dispatched, one run at a time repository-wide, and is **not** a merge gate. See `CONTRIBUTING.md` §"GARM E2E".
- **Shared test-support unit tests** (root `tox.ini`) — `tox -e charms-tests-unit`. Model-free tests for the helpers under `charms/tests/` (dispatch/credential helpers, the diagnostic redactor). A merge gate, unlike the suites that consume those helpers.
- **`actions/` Python** — `tox -e actions-lint`, `tox -e actions-static`, `tox -e actions-unit`.
- **secscan gate** — `tox -e secscan-gate-unit`. Unit tests for `.github/scripts/secscan_gate.py` using a fake `secscan-client`.
- **Go** — `go test ./...`.
- `charmcraft pack` — build a charm (run from the charm dir; not wired into tox).
- **Docs spellcheck** — CI runs Vale over `docs/` with `Canonical.000-US-spellcheck` at **error** level, so an unknown technical term (e.g. `deserialize`) fails the build. Add project-specific terms — regex forms like `[Dd]eserializ(e|es|ed|ing|ation)` are supported — to `docs/.custom_wordlist.txt` (the docs `Makefile` appends it to the Canonical accept vocabulary); verify with `make -C docs spellcheck` before pushing a `docs/` change.
Expand Down
18 changes: 18 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,21 @@ assigned and coordinating the release of the fix.
The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/disclosure-policy)
contains more information about what you can expect when you contact us, and what we
expect from you.

## Vulnerability scanning

The `Secscan` workflow scans the charms on `latest/edge` (`garm`, `garm-configurator`,
`github-runner-planner`, `github-runner-webhook-gateway`) and their `app-image` OCI
resources with Canonical's secscan service (Trivy), using
[sbomber](https://github.com/canonical/sbomber) and `.github/sbomber-manifest.yaml`. It runs
after every publish to edge, weekly, and on demand, and fails when it finds a CVE that is
not excluded.

When it fails, maintainers download the `secscan-reports` artifact. For each artifact it
contains `<artifact>.result.txt`, the authoritative verdict and CVE IDs, alongside the
detailed report downloaded by sbomber. They either fix the issue or open a draft
[GitHub security advisory](../../security/advisories/new) recording the CVE, its severity
and the remediation plan. A CVE accepted as a false positive or acceptable risk is added to `.github/secscan-exclusions/<artifact>.txt` with a
justification comment and a link to its advisory. High or critical exclusions need
security team approval. The SSDLC cycle is set in the manifest's `ssdlc_params` and must
be bumped each cycle.
Loading
Loading