diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 184c2d1..7af9bd3 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -42,6 +42,36 @@ jobs: - run: pip install ruff - run: ruff check tests/ + osv-scanner: + # maestro#14 / data-boar-site#91: dependency vulns (osv-scanner or trivy) on every repo. + # Binary pin + sha256 copied from keen-platypus security.yml job osv-scanner (v2.6.0). + # Default branch confirmed: main. + # Checkout SHA matches the other jobs in this file (and KP production), not the + # drifted actions/checkout@v6 pin that landed in the 2026-09-16 issue comment. + # Site has no lockfiles today: `scan source -r .` exits 128 ("No package sources + # found"). --allow-no-lockfiles makes that OK; when a manifest appears, scan runs. + name: OSV dependency scan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Download and verify osv-scanner + run: | + set -euo pipefail + VER="2.6.0" + BIN="osv-scanner_linux_amd64" + EXPECTED_SHA256="ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108" + BASE="https://github.com/google/osv-scanner/releases/download/v${VER}" + curl -sSfL "${BASE}/${BIN}" -o osv-scanner + echo "${EXPECTED_SHA256} osv-scanner" | sha256sum -c - + chmod +x ./osv-scanner + + - name: Scan dependency manifests + run: ./osv-scanner scan source -r . --allow-no-lockfiles + # CodeQL (JavaScript/TypeScript) is enabled via GitHub's managed **default setup** # (Settings → Code security), NOT a committed workflow — no Action SHA to verify/maintain. # Free on this public repo. See docs/adr/ADR-0001. diff --git a/.gitignore b/.gitignore index e45af8a..185ee0b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .DS_Store .ruff_cache/ .semgrep/ +scripts/.cache/ diff --git a/scripts/check-all.sh b/scripts/check-all.sh index 203b4fe..3309614 100644 --- a/scripts/check-all.sh +++ b/scripts/check-all.sh @@ -2,9 +2,25 @@ # check-all — FULL local gate for data-boar-site (run before opening a PR / before merge). # Mirrors the data-boar `check-all` discipline: green locally BEFORE push (ADR-0080 spirit). # NOT a toy project — this gate is inviolable (docs/adr/ADR-0001). Add checks, never remove. +# --skip-osv-scanner skip OSV dependency scan (same flag as keen-platypus check-all) set -uo pipefail cd "$(dirname "$0")/.." || exit 2 fail=0 +SKIP_OSV=0 +for arg in "$@"; do + case "$arg" in + --skip-osv-scanner) SKIP_OSV=1 ;; + -h|--help) + echo "uso: $0 [--skip-osv-scanner]" + exit 0 + ;; + *) + echo "check-all: argumento desconhecido: $arg" >&2 + echo "uso: $0 [--skip-osv-scanner]" >&2 + exit 2 + ;; + esac +done run() { echo; echo "── $1 ──"; shift; if "$@"; then echo " ✅ ok"; else echo " ❌ FALHOU"; fail=1; fi; } @@ -55,6 +71,55 @@ sys.exit(bad) PY fi +# 4) OSV dependency scan — mirrors CI job osv-scanner (#91). Pin = keen-platypus 2.6.0. +OSV_VER="2.6.0" +OSV_SHA256="ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108" +OSV_CACHE="$(pwd)/scripts/.cache" +ensure_osv_scanner() { + mkdir -p "$OSV_CACHE" + if command -v osv-scanner >/dev/null 2>&1 && osv-scanner --version 2>/dev/null | grep -qF "$OSV_VER"; then + return 0 + fi + if [ -x "$OSV_CACHE/osv-scanner" ] && "$OSV_CACHE/osv-scanner" --version 2>/dev/null | grep -qF "$OSV_VER"; then + PATH="$OSV_CACHE:$PATH" + export PATH + return 0 + fi + echo "check-all: baixando osv-scanner v${OSV_VER} para ${OSV_CACHE}..." >&2 + curl -sSfL "https://github.com/google/osv-scanner/releases/download/v${OSV_VER}/osv-scanner_linux_amd64" \ + -o "$OSV_CACHE/osv-scanner.download" || { + echo "check-all: download osv-scanner falhou" >&2 + rm -f "$OSV_CACHE/osv-scanner.download" + return 1 + } + # set -uo pipefail without -e: sha256sum -c must be checked or we mv a bad blob. + if ! echo "${OSV_SHA256} $OSV_CACHE/osv-scanner.download" | sha256sum -c -; then + echo "check-all: sha256 osv-scanner nao bate — recusando o download (nao vai pro cache)" >&2 + rm -f "$OSV_CACHE/osv-scanner.download" + return 1 + fi + mv "$OSV_CACHE/osv-scanner.download" "$OSV_CACHE/osv-scanner" + chmod +x "$OSV_CACHE/osv-scanner" + PATH="$OSV_CACHE:$PATH" + export PATH +} + +if [ "$SKIP_OSV" = "1" ]; then + echo + echo "── osv-scanner ──" + echo " ⏭️ skip (--skip-osv-scanner)" +else + if ensure_osv_scanner && command -v osv-scanner >/dev/null 2>&1; then + # Static site: no lockfiles today → --allow-no-lockfiles (CI same). Exit 128 without it. + run "osv-scanner scan source -r ." osv-scanner scan source -r . --allow-no-lockfiles + else + echo + echo "── osv-scanner ──" + echo " ❌ FALHOU — osv-scanner v${OSV_VER} ausente (CI security.yml baixa e verifica sha256)" + fail=1 + fi +fi + echo if [ "$fail" = "0" ]; then echo "✅ check-all VERDE — pode abrir PR."; else echo "❌ check-all VERMELHO — NÃO abra PR."; fi exit "$fail" diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index c25fe3a..ee3facf 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -20,7 +20,9 @@ import re import subprocess import sys +import tempfile import unittest +from pathlib import Path from typing import ClassVar ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -74,6 +76,96 @@ def test_pages_deploy_queues_and_avoids_unquoted_secret_heredoc(self): ) self.assertIn("json.dumps", wf) self.assertIn("refusing deploy", wf) + + def test_security_yml_osv_scanner_job_is_sha256_pinned(self): + """#91: osv-scanner v2.6.0 checksum pin (keen-platypus / issue comment).""" + wf = _read(os.path.join(ROOT, ".github", "workflows", "security.yml")) + self.assertIn("osv-scanner:", wf) + self.assertIn("2.6.0", wf) + self.assertIn( + "ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108", + wf, + ) + self.assertIn("--allow-no-lockfiles", wf) + chk = _read(os.path.join(ROOT, "scripts", "check-all.sh")) + self.assertIn("--skip-osv-scanner", chk) + self.assertIn("--allow-no-lockfiles", chk) + + def test_ensure_osv_scanner_does_not_promote_binary_on_bad_sha256(self): + """PR #94: checksum fail must return 1 before mv into scripts/.cache/osv-scanner.""" + src = _read(os.path.join(ROOT, "scripts", "check-all.sh")) + ver = re.search(r'^OSV_VER="([^"]+)"', src, flags=re.MULTILINE) + sha = re.search(r'^OSV_SHA256="([^"]+)"', src, flags=re.MULTILINE) + self.assertTrue(ver and sha) + start = src.index("ensure_osv_scanner() {") + end = src.index('\nif [ "$SKIP_OSV"', start) + fn = src[start:end] + body = fn[fn.index("{") + 1 :].rstrip().removesuffix("}") + idx_if = body.rfind("if ! echo") + idx_sum = body.rfind("sha256sum -c -") + idx_mv = body.find('mv "$OSV_CACHE/osv-scanner.download"') + self.assertGreater(idx_if, -1) + self.assertGreater(idx_sum, idx_if) + self.assertGreater(idx_mv, idx_sum) + between = body[idx_if:idx_mv] + self.assertIn("return 1", between) + self.assertRegex(between, r"if !") + + with tempfile.TemporaryDirectory() as td: + cache = Path(td) / "cache" + bindir = Path(td) / "bin" + cache.mkdir() + bindir.mkdir() + curl = bindir / "curl" + curl.write_text( + "#!/bin/sh\n" + "out=\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = "-o" ]; then shift; out=$1; fi\n' + " shift\n" + "done\n" + 'printf "tampered-not-osv\\n" > "$out"\n', + encoding="utf-8", + ) + curl.chmod(0o755) + # Must shadow a real /usr/bin/osv-scanner so we actually hit curl+sha256. + fake_osv = bindir / "osv-scanner" + fake_osv.write_text( + '#!/bin/sh\necho "osv-scanner version: 0.0.0-not-pinned"\n', + encoding="utf-8", + ) + fake_osv.chmod(0o755) + wrapper = Path(td) / "run.sh" + wrapper.write_text( + "#!/usr/bin/env bash\n" + "set -uo pipefail\n" + f"OSV_VER={ver.group(1)!r}\n" + f"OSV_SHA256={sha.group(1)!r}\n" + f"OSV_CACHE={str(cache)!r}\n" + "ensure_osv_scanner() {\n" + f"{body}\n" + "}\n" + "ensure_osv_scanner\n" + "exit $?\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["PATH"] = f"{bindir}{os.pathsep}/usr/bin{os.pathsep}/bin" + proc = subprocess.run( + ["bash", str(wrapper)], + cwd=td, + env=env, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(proc.returncode, 0, proc.stderr) + self.assertFalse((cache / "osv-scanner").exists()) + self.assertFalse((cache / "osv-scanner").is_file()) + + def test_pages_deploy_rejects_unpinned_upload_pages_artifact_v3(self): + """Keep Pages SHA-pin regression next to the concurrency test above.""" + wf = _read(os.path.join(ROOT, ".github", "workflows", "pages-deploy.yml")) # Transitive pin: v3.0.1 composite used upload-artifact@v4 (org policy fail). self.assertNotIn( "56afc609e74202658d3ffba0e8f6dda462b719fa",