diff --git a/.github/workflows/_local-charm-scan.yaml b/.github/workflows/_local-charm-scan.yaml new file mode 100644 index 00000000..f1fe19d8 --- /dev/null +++ b/.github/workflows/_local-charm-scan.yaml @@ -0,0 +1,25 @@ +name: Charm Security Scan + +on: + workflow_dispatch: + +jobs: + scan: + name: Scan fleet + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install dependencies + run: | + sudo snap install just --classic + sudo snap install yq + sudo snap install astral-uv --classic + + - name: Scan every charm in manifest.yaml + run: just security::scan-charms + + - name: Build report + if: always() + run: just security::build-report "$RUNNER_TEMP/results" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 2780d237..94c82e69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ **/dist/** venv -.idea/ \ No newline at end of file +.idea/ + +# Scratch dirs left behind by `just security::scan-repo` / `build-report` +# when run locally outside CI (where $RUNNER_TEMP isn't set). +/charm-checkout/ +/results/ \ No newline at end of file diff --git a/justfile b/justfile index 9eee7ee3..d3442a5c 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,8 @@ set quiet # Recipes are silent by default set export # Just variables are exported to the environment +mod security + [private] default: just --list @@ -28,21 +30,21 @@ list-repos: } | { grep -vxF -f <(printf '%s\n' "${ignore[@]}") || true; } | sort -u # List all charms from the manifest -[group("manifest")] +[group("info")] list-charms: #!/usr/bin/env bash set -euo pipefail yq -r '.artifacts.charms[].name' manifest.yaml | sort -u # List all rocks from the manifest -[group("manifest")] +[group("info")] list-rocks: #!/usr/bin/env bash set -euo pipefail yq -r '.artifacts.rocks[].name' manifest.yaml | sort -u # List all snaps from the manifest -[group("manifest")] +[group("info")] list-snaps: #!/usr/bin/env bash set -euo pipefail diff --git a/security.just b/security.just new file mode 100644 index 00000000..89792962 --- /dev/null +++ b/security.just @@ -0,0 +1,215 @@ +set quiet # Recipes are silent by default +set export # Just variables are exported to the environment + +[private] +default: + just -f security.just --list + +# List every charm release as JSON, grouped by unique repo +[private] +[group("security")] +scan-matrix: + #!/usr/bin/env bash + set -euo pipefail + # One entry per repo, with the charms/paths/branches it hosts - used by + # `scan-charms` to enumerate repos, and by `scan-charm-repo` to look up the + # charms hosted in one of them. + yq -o=json manifest.yaml | jq -c ' + [.artifacts.charms[] + | .name as $charm | .repo as $repo | .path as $path + | .releases[]? + | { + repo: $repo, + branch: .branch, + charm: $charm, + path: $path, + release: .name, + cycle: (.cycle | tostring), + lts: (.support.lts // false) + } + ] + | group_by(.repo) + | map({ + repo: .[0].repo, + charms: map({charm, path, release, branch, cycle, lts}) + }) + ' + +# Run `just scan` on charms in a single repository +[group("charms")] +[arg("repo", help="Repository in 'org/repo' form, as it appears in manifest.yaml")] +scan-charm-repo repo: + #!/usr/bin/env python3 + # Clones `repo` once and checks out each branch it hosts charms on in turn - + # only branches that are actually listed in manifest.yaml, nothing else - + # running `just scan` per charm and writing one result JSON per charm under + # $RUNNER_TEMP/results (or ./results outside CI). Exits non-zero if any branch + # failed to check out; vulnerabilities found by `just scan` are recorded but + # don't fail the recipe. Used by the _local-charm-scan.yaml workflow, but also + # runs standalone, e.g.: just security::scan-charm-repo canonical/litmus-operators + import json + import os + import subprocess + import sys + from pathlib import Path + + repo = "{{ repo }}" + results_dir = Path(os.environ.get("RUNNER_TEMP", ".")) / "results" + results_dir.mkdir(parents=True, exist_ok=True) + + def run(*args, cwd=None): + # Merge stdout/stderr (like a shell's `2>&1`) so captured logs read in order. + return subprocess.run(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + + def record(charm, release, cycle, lts, branch, status, log): + result = { + "charm": charm, "release": release, "cycle": cycle, "lts": lts, + "repo": repo, "branch": branch, "status": status, + "log": log or "(no output captured)", + } + (results_dir / f"{charm}-{release}.json").write_text(json.dumps(result)) + + # A fresh `just` subprocess re-resolves modules from scratch, so sibling + # recipes in this same module still need their fully-qualified name here. + matrix = json.loads(subprocess.run( + ["just", "security::scan-matrix"], capture_output=True, text=True, check=True + ).stdout) + entry = next((e for e in matrix if e["repo"] == repo), None) + charms = entry["charms"] if entry else [] + if not charms: + print(f"No charms found for repo '{repo}' in manifest.yaml", file=sys.stderr) + sys.exit(1) + + checkout_dir = Path("charm-checkout") + clone = run( + "git", "clone", "--quiet", "--no-checkout", "--depth=1", "--no-single-branch", + f"https://github.com/{repo}.git", str(checkout_dir), + ) + + any_failed = False + if clone.returncode != 0: + any_failed = True + for c in charms: + record(c["charm"], c["release"], c["cycle"], c["lts"], c["branch"], "checkout-failed", clone.stdout) + else: + for branch in sorted({c["branch"] for c in charms}): + checkout = run("git", "checkout", "--quiet", "-B", branch, f"origin/{branch}", cwd=checkout_dir) + branch_ok = checkout.returncode == 0 + any_failed = any_failed or not branch_ok + + for c in (c for c in charms if c["branch"] == branch): + if not branch_ok: + status, log = "checkout-failed", checkout.stdout + else: + scan = run("just", "scan", cwd=checkout_dir / c["path"]) + status = "pass" if scan.returncode == 0 else "vulnerabilities-found" + log = scan.stdout + record(c["charm"], c["release"], c["cycle"], c["lts"], branch, status, log) + + if any_failed: + print(f"::error::One or more branches of {repo} failed to check out", file=sys.stderr) + sys.exit(1) + +# Run `just scan` on every charm in manifest.yaml, one repo at a time +[group("charms")] +scan-charms: + #!/usr/bin/env bash + set -euo pipefail + # Sequential on purpose: each `scan-charm-repo` call is a shallow clone plus + # a handful of lightweight `uv audit` runs, so parallelizing this across one + # runner per repo bought little beyond N-times the runner/artifact overhead. + # Continues past a repo that fails (matching scan-charm-repo's own + # per-branch tolerance) but still exits non-zero at the end if any did. + matrix="$(just security::scan-matrix)" + releases="$(echo "$matrix" | jq '[.[].charms[]] | length')" + repos="$(echo "$matrix" | jq -r '.[].repo')" + repo_count="$(echo "$repos" | wc -l)" + echo "Scanning $releases charm releases across $repo_count repos" + + any_failed=0 + while IFS= read -r repo; do + just security::scan-charm-repo "$repo" || any_failed=1 + done <<< "$repos" + + exit "$any_failed" + +# Build the scan-charm markdown report from a directory of result JSON files +[group("report")] +build-report results_dir="results": + #!/usr/bin/env python3 + # Searches results_dir recursively, so this works with both a flat directory + # and the one-artifact-per-repo-subdirectory layout actions/download-artifact + # produces. Prints the report to stdout - redirect it into $GITHUB_STEP_SUMMARY + # in CI, or just read it directly when running locally, e.g.: + # just security::build-report results + # Exits non-zero if any result was checkout-failed (does not affect stdout). + import glob + import html + import json + import sys + + results = [] + for path in glob.glob("{{ results_dir }}/**/*.json", recursive=True): + with open(path) as f: + results.append(json.load(f)) + + if not results: + print("# :mag: Charm Security Scan Report\n") + print("No charm releases found in `manifest.yaml`.") + sys.exit(0) + + results.sort(key=lambda r: (r["cycle"], r["charm"], r["release"])) + + badges = { + "pass": "✅", + "vulnerabilities-found": "⚠️", + "checkout-failed": "❌", + } + status_text = { + "pass": "pass", + "vulnerabilities-found": "vulnerabilities found", + "checkout-failed": "checkout failed", + } + + counts = {"pass": 0, "vulnerabilities-found": 0, "checkout-failed": 0} + for r in results: + counts[r["status"]] += 1 + + lines = [] + lines.append("# :mag: Charm Security Scan Report") + lines.append("") + lines.append( + f"**{len(results)} releases scanned** · " + f"{badges['pass']} {counts['pass']} pass · " + f"{badges['vulnerabilities-found']} {counts['vulnerabilities-found']} flagged · " + f"{badges['checkout-failed']} {counts['checkout-failed']} checkout failed" + ) + lines.append("") + + current_cycle = None + for r in results: + if r["cycle"] != current_cycle: + current_cycle = r["cycle"] + lines.append(f"## Cycle `{current_cycle}`") + lines.append("") + + badge = badges[r["status"]] + lts_label = "LTS" if r["lts"] else "non-LTS" + summary = ( + f"{badge} {r['charm']} {r['release']} ({lts_label}) " + f"— {status_text[r['status']]}" + ) + lines.append("
") + lines.append(f"{summary}") + lines.append("") + lines.append(f"`{r['repo']}@{r['branch']}`") + lines.append("") + lines.append(f"
{html.escape(r['log'])}
") + lines.append("
") + lines.append("") + + print("\n".join(lines)) + + if counts["checkout-failed"] > 0: + print(f"::error::{counts['checkout-failed']} charm(s) failed to check out; see the report above.", file=sys.stderr) + sys.exit(1)