From 997ac0ca3b902b1660eb19ca935134a83adbdb67 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 2 Oct 2026 11:54:26 +0000 Subject: [PATCH 1/5] ci: add secscan vulnerability scanning Scan the published charms and their OCI resources with Canonical secscan via sbomber after publish and weekly (SEC0025, ISD-6336). --- .github/sbomber-manifest.yaml | 43 +++++++++++ .github/scripts/secscan_gate.py | 67 +++++++++++++++++ .github/secscan-exclusions/garm-app-image.txt | 3 + .../secscan-exclusions/garm-configurator.txt | 3 + .github/secscan-exclusions/garm.txt | 3 + .../github-runner-planner-app-image.txt | 3 + .../github-runner-planner.txt | 3 + ...ithub-runner-webhook-gateway-app-image.txt | 3 + .../github-runner-webhook-gateway.txt | 3 + .github/workflows/publish_charms.yml | 6 ++ .github/workflows/secscan.yaml | 71 +++++++++++++++++++ SECURITY.md | 17 +++++ 12 files changed, 225 insertions(+) create mode 100644 .github/sbomber-manifest.yaml create mode 100644 .github/scripts/secscan_gate.py create mode 100644 .github/secscan-exclusions/garm-app-image.txt create mode 100644 .github/secscan-exclusions/garm-configurator.txt create mode 100644 .github/secscan-exclusions/garm.txt create mode 100644 .github/secscan-exclusions/github-runner-planner-app-image.txt create mode 100644 .github/secscan-exclusions/github-runner-planner.txt create mode 100644 .github/secscan-exclusions/github-runner-webhook-gateway-app-image.txt create mode 100644 .github/secscan-exclusions/github-runner-webhook-gateway.txt create mode 100644 .github/workflows/secscan.yaml diff --git a/.github/sbomber-manifest.yaml b/.github/sbomber-manifest.yaml new file mode 100644 index 00000000..9b6d00be --- /dev/null +++ b/.github/sbomber-manifest.yaml @@ -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" } diff --git a/.github/scripts/secscan_gate.py b/.github/scripts/secscan_gate.py new file mode 100644 index 00000000..29fa4255 --- /dev/null +++ b/.github/scripts/secscan_gate.py @@ -0,0 +1,67 @@ +# 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 + +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) + failed = False + for artifact in state.get("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, + ) + (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()) diff --git a/.github/secscan-exclusions/garm-app-image.txt b/.github/secscan-exclusions/garm-app-image.txt new file mode 100644 index 00000000..4386d831 --- /dev/null +++ b/.github/secscan-exclusions/garm-app-image.txt @@ -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). diff --git a/.github/secscan-exclusions/garm-configurator.txt b/.github/secscan-exclusions/garm-configurator.txt new file mode 100644 index 00000000..4386d831 --- /dev/null +++ b/.github/secscan-exclusions/garm-configurator.txt @@ -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). diff --git a/.github/secscan-exclusions/garm.txt b/.github/secscan-exclusions/garm.txt new file mode 100644 index 00000000..4386d831 --- /dev/null +++ b/.github/secscan-exclusions/garm.txt @@ -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). diff --git a/.github/secscan-exclusions/github-runner-planner-app-image.txt b/.github/secscan-exclusions/github-runner-planner-app-image.txt new file mode 100644 index 00000000..4386d831 --- /dev/null +++ b/.github/secscan-exclusions/github-runner-planner-app-image.txt @@ -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). diff --git a/.github/secscan-exclusions/github-runner-planner.txt b/.github/secscan-exclusions/github-runner-planner.txt new file mode 100644 index 00000000..4386d831 --- /dev/null +++ b/.github/secscan-exclusions/github-runner-planner.txt @@ -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). diff --git a/.github/secscan-exclusions/github-runner-webhook-gateway-app-image.txt b/.github/secscan-exclusions/github-runner-webhook-gateway-app-image.txt new file mode 100644 index 00000000..4386d831 --- /dev/null +++ b/.github/secscan-exclusions/github-runner-webhook-gateway-app-image.txt @@ -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). diff --git a/.github/secscan-exclusions/github-runner-webhook-gateway.txt b/.github/secscan-exclusions/github-runner-webhook-gateway.txt new file mode 100644 index 00000000..4386d831 --- /dev/null +++ b/.github/secscan-exclusions/github-runner-webhook-gateway.txt @@ -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). diff --git a/.github/workflows/publish_charms.yml b/.github/workflows/publish_charms.yml index c312d786..37691792 100644 --- a/.github/workflows/publish_charms.yml +++ b/.github/workflows/publish_charms.yml @@ -19,3 +19,9 @@ 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 diff --git a/.github/workflows/secscan.yaml b/.github/workflows/secscan.yaml new file mode 100644 index 00000000..2055ae28 --- /dev/null +++ b/.github/workflows/secscan.yaml @@ -0,0 +1,71 @@ +# 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 + # Fork PRs must never reach private-endpoint runners. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + 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 + ./sbomber poll --wait --timeout 60 + - 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 diff --git a/SECURITY.md b/SECURITY.md index fe6468e8..798f7e41 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,3 +21,20 @@ 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 and 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/.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. From aa3a752eefceb692ca0c1c7fe5df9ddcf625c380 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 2 Oct 2026 12:35:02 +0000 Subject: [PATCH 2/5] ci(secscan): fail gate when statefile lacks manifest artifacts --- .github/scripts/secscan_gate.py | 14 +++++++++++++- .github/workflows/publish_charms.yml | 1 - 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/scripts/secscan_gate.py b/.github/scripts/secscan_gate.py index 29fa4255..937f180b 100644 --- a/.github/scripts/secscan_gate.py +++ b/.github/scripts/secscan_gate.py @@ -14,6 +14,7 @@ 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") @@ -22,8 +23,19 @@ def main() -> int: state = yaml.safe_load(STATEFILE.read_text()) REPORTS.mkdir(parents=True, exist_ok=True) + artifacts = state.get("artifacts") or [] failed = False - for artifact in state.get("artifacts", []): + 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: diff --git a/.github/workflows/publish_charms.yml b/.github/workflows/publish_charms.yml index 37691792..9fe44b87 100644 --- a/.github/workflows/publish_charms.yml +++ b/.github/workflows/publish_charms.yml @@ -19,7 +19,6 @@ jobs: secrets: CHARMHUB_TOKEN: ${{ secrets.CHARMHUB_TOKEN }} - secscan: needs: publish permissions: From e0c417372c1c725a658d1cb7a31bb732dab70d55 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 2 Oct 2026 13:34:24 +0000 Subject: [PATCH 3/5] ci(secscan): bound sbomber poll within job timeout and name result files --- .github/workflows/secscan.yaml | 3 ++- SECURITY.md | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/secscan.yaml b/.github/workflows/secscan.yaml index 2055ae28..4201fdcc 100644 --- a/.github/workflows/secscan.yaml +++ b/.github/workflows/secscan.yaml @@ -55,7 +55,8 @@ jobs: run: | ./sbomber prepare ../.github/sbomber-manifest.yaml ./sbomber submit - ./sbomber poll --wait --timeout 60 + # --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 diff --git a/SECURITY.md b/SECURITY.md index 798f7e41..ee71a9e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,10 +31,11 @@ resources with Canonical's secscan service (Trivy), using 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 and 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/.txt` with a +When it fails, maintainers download the `secscan-reports` artifact. For each artifact it +contains `.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/.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. From edeed3d1ead7cd561c6ee45a72df2d07a843c8f5 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 2 Oct 2026 14:28:16 +0000 Subject: [PATCH 4/5] test(secscan): add unit tests for the CVE gate --- .github/scripts/test_secscan_gate.py | 134 +++++++++++++++++++++++++++ AGENTS.md | 1 + tox.ini | 10 +- 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/test_secscan_gate.py diff --git a/.github/scripts/test_secscan_gate.py b/.github/scripts/test_secscan_gate.py new file mode 100644 index 00000000..7c439e3f --- /dev/null +++ b/.github/scripts/test_secscan_gate.py @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 0a5fec10..eed39de2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/tox.ini b/tox.ini index ba735482..bf06c972 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ [tox] no_package = True skip_missing_interpreters = True -env_list = format, lint, static, actions-lint, actions-static, actions-unit +env_list = format, lint, static, actions-lint, actions-static, actions-unit, secscan-gate-unit min_version = 4.0.0 [vars] @@ -170,6 +170,14 @@ commands = pylint {[vars]actions_path} bandit -q -r {[vars]actions_path} -x {[vars]actions_path}/enable-log-forwarding/tests +[testenv:secscan-gate-unit] +description = Run unit tests for the secscan CVE gate script +deps = + pytest + pyyaml +commands = + pytest -v {tox_root}/.github/scripts + [testenv:actions-unit] description = Run unit tests for Python code under actions/ deps = From 045fd23fd95d6da38869f38c4cde7e0765157d6d Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Mon, 5 Oct 2026 06:09:30 +0000 Subject: [PATCH 5/5] ci(secscan): drop fork-PR guard covered by external contributor check --- .github/workflows/secscan.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/secscan.yaml b/.github/workflows/secscan.yaml index 4201fdcc..01a49a1f 100644 --- a/.github/workflows/secscan.yaml +++ b/.github/workflows/secscan.yaml @@ -22,8 +22,6 @@ permissions: {} jobs: secscan: name: Secscan charms and images - # Fork PRs must never reach private-endpoint runners. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: [self-hosted-linux-amd64-noble-private-endpoint-medium] permissions: contents: read