diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c112ee2..02f7180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,38 @@ env: RUST_BACKTRACE: short jobs: + distribution: + name: distribution + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Validate GitHub Action and release automation + run: python3 scripts/action_validation/validate.py + + - name: Build container + run: docker build --tag keywatch:ci . + + - name: Smoke-test container + shell: bash + run: | + set -euo pipefail + docker run --rm keywatch:ci --version + test "$(docker run --rm --entrypoint id keywatch:ci -u)" != "0" + + set +e + printf '%s\n' 'AWS_KEY=AKIAIOSFODNN7EXAMPLE' | \ + docker run --rm -i keywatch:ci scan --stdin --exit-mode strict + scan_status=$? + set -e + if [ "$scan_status" -ne 1 ]; then + echo "ERROR: container secret scan exited with $scan_status instead of 1" >&2 + exit 1 + fi + formatting: name: cargo-fmt runs-on: ubuntu-latest diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7bac9bb..39ffcc7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -3,20 +3,16 @@ name: Publish Docker Image on: push: tags: ["v*"] - workflow_dispatch: - inputs: - tag: - description: 'Image tag' - required: true - default: 'latest' permissions: contents: read packages: write + attestations: write + id-token: write env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} + IMAGE_NAME: pixincreate/keywatch jobs: build-and-push: @@ -27,6 +23,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + - name: Verify tag and package versions + shell: bash + run: | + set -euo pipefail + tag_version=${GITHUB_REF_NAME#v} + cargo_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1) + action_version=$(sed -n "/^ version:$/,/^ paths:$/ s/^ default: '\([^']*\)'/\1/p" action.yml) + test "$tag_version" = "$cargo_version" + test "$tag_version" = "$action_version" + grep -Fq "## [$tag_version] -" CHANGELOG.md + - name: Log in to GitHub Container Registry uses: docker/login-action@v4.6.0 with: @@ -42,13 +49,22 @@ jobs: tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable=${{ github.event_name == 'push' && !contains(github.ref_name, '-') }} type=sha,format=short - type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} - name: Build and push Docker image + id: push uses: docker/build-push-action@v7.3.0 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + - name: Attest image provenance + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a81a26..f6151cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,62 @@ on: tags: ["v*"] permissions: - contents: write + contents: read jobs: + preflight: + name: release-preflight + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + RUSTFLAGS: "-D warnings" + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2.9.1 + + - name: Verify tag and package versions + shell: bash + run: | + set -euo pipefail + tag_version=${GITHUB_REF_NAME#v} + cargo_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1) + action_version=$(sed -n "/^ version:$/,/^ paths:$/ s/^ default: '\([^']*\)'/\1/p" action.yml) + test "$tag_version" = "$cargo_version" + test "$tag_version" = "$action_version" + grep -Fq "## [$tag_version] -" CHANGELOG.md + + - name: Validate distribution automation + run: python3 scripts/action_validation/validate.py + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run tests + run: cargo test --all-features --all-targets + + - name: Run tests (release) + run: cargo test --release --all-features --all-targets + + - name: Run Clippy + run: cargo clippy --all-features --all-targets -- -D warnings + + - name: Check package + run: cargo package --locked + release: name: release-${{ matrix.platform.asset_name }} + needs: preflight runs-on: ${{ matrix.platform.os }} + permissions: + contents: write strategy: matrix: platform: @@ -71,7 +121,7 @@ jobs: # Build the specific package - name: Build Binary run: | - cargo build --release --target ${{ matrix.platform.target }} + cargo build --locked --release --target ${{ matrix.platform.target }} - name: Prepare Asset shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe6998..22fb088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. - Cloud/monitoring/AI service detectors: Vercel, Netlify, Supabase, Datadog, New Relic, Sentry, PagerDuty, Anthropic, HuggingFace, Groq, Replicate, LangSmith - **GitHub Action** — composite action (`action.yml`) for CI/CD integration - **Docker support** — multi-stage Dockerfile with `--locked` flag, stripped binary, non-root user, and git installed for `--git-history` scanning and hook installation +- **Public distribution verification** — the root GitHub Action verifies release binary and detector checksums, while GHCR images publish semver, major, and latest tags with provenance - `.dockerignore` for optimized Docker builds - **Config file support** — `.keywatch.toml` with custom rules, detector overrides, and exclude patterns - **SARIF 2.1.0 output** — `--format sarif` enables GitHub Code Scanning and SARIF viewer integration @@ -39,6 +40,8 @@ All notable changes to this project will be documented in this file. - Local hook installation now resolves Git's hooks directory directly, improving worktree and submodule compatibility - `exit-mode critical` now fails on both HIGH and CRITICAL findings - Detector descriptions and comments cleaned up for minimal noise +- Release preparation now synchronizes the Action version with Cargo metadata and runs CI before publishing tags +- CI now validates Action shell behavior, checksum failures, release automation, and container smoke behavior ### Fixed diff --git a/README.md b/README.md index 4899d82..a9a0f73 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,60 @@ Requires Rust 1.85+ (edition 2024) when building from source. The canonical command is `key-watch`. `keywatch` and `kw` are optional shell aliases exposed via `key-watch init ...`. +## GitHub Action + +Use the root Action from a public workflow. The major tag follows compatible `2.x` releases; pin an exact release tag or commit SHA when immutable dependencies are required. + +```yaml +name: Secret scan + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + keywatch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - id: keywatch + uses: pixincreate/KeyWatch@v2 + with: + paths: "." + exit-mode: strict +``` + +The Action installs the synchronized KeyWatch release, verifies SHA-256 checksums for the binary and `detectors.toml`, disables repository detector discovery, and writes a JSON report. It supports Linux x64 and macOS x64/arm64 runners; Windows runners are not supported. + +| Input | Default | Purpose | +| ----------- | ---------------------- | ------------------------------------------------------------------------ | +| `version` | Action release version | Exact KeyWatch release to install | +| `paths` | `.` | Space-separated paths or globs to scan | +| `args` | empty | Additional scanner arguments that do not override Action-managed options | +| `exit-mode` | `strict` | `strict`, `critical`, or `always` | +| `output` | temporary report | JSON report path | +| `config` | empty | Explicit trusted `.keywatch.toml` path | +| `verbose` | `false` | Deprecated; enabling it is rejected to prevent secret disclosure in logs | + +The `findings-count` and `exit-code` outputs are available as `${{ steps.keywatch.outputs['findings-count'] }}` and `${{ steps.keywatch.outputs['exit-code'] }}`. + +## Container Image + +The GitHub Container Registry image is a separate distribution channel for Linux x64 environments: + +```sh +docker pull ghcr.io/pixincreate/keywatch:2 +docker run --rm \ + --volume "$PWD:/workspace:ro" \ + --workdir /workspace \ + ghcr.io/pixincreate/keywatch:2 scan . +``` + +Images are published as `x.y.z`, `x.y`, `x`, and `latest`, with build provenance attached. Exact semver tags are the reproducible choice. After the first publication, a repository owner must make the GHCR package public in the package settings to allow anonymous pulls; no separate GHCR account is required. The image runs as a non-root user and uses the image-owned detector configuration at `/etc/keywatch/detectors.toml`. + ## Uninstall ### If installed with `cargo install` diff --git a/.github/actions/keywatch-scan/action.yml b/action.yml similarity index 80% rename from .github/actions/keywatch-scan/action.yml rename to action.yml index 3860188..907cc7d 100644 --- a/.github/actions/keywatch-scan/action.yml +++ b/action.yml @@ -3,6 +3,10 @@ description: 'Scan files and directories for secrets with KeyWatch' author: 'Pa1Nark' inputs: + version: + description: 'Exact KeyWatch release version to install' + required: false + default: '1.1.0' paths: description: 'Paths to scan (space-separated, supports globs)' required: false @@ -43,10 +47,11 @@ runs: shell: bash env: GITHUB_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} run: | set -euo pipefail - for required_tool in curl jq; do + for required_tool in curl; do if ! command -v "$required_tool" >/dev/null 2>&1; then echo "ERROR: $required_tool is required on this runner" >&2 exit 1 @@ -54,23 +59,19 @@ runs: done REPO="pixincreate/KeyWatch" - VERSION="${KEYWATCH_VERSION:-latest}" + VERSION="$INPUT_VERSION" curl_args=(-fsSL) if [ -n "${GITHUB_TOKEN:-}" ]; then curl_args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") fi - if [ "$VERSION" = "latest" ]; then - release_json=$(curl "${curl_args[@]}" \ - "https://api.github.com/repos/$REPO/releases/latest") - VERSION=$(jq -er '.tag_name' <<<"$release_json") - elif [[ "$VERSION" != v* ]]; then + if [[ "$VERSION" != v* ]]; then VERSION="v$VERSION" fi - if [[ ! "$VERSION" =~ ^v[0-9A-Za-z._-]+$ ]]; then - echo "ERROR: invalid KeyWatch version/tag '$VERSION'" >&2 + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "ERROR: invalid KeyWatch release version '$VERSION'" >&2 exit 1 fi @@ -109,16 +110,41 @@ runs: binary_path="$bin_dir/key-watch$exe_suffix" asset_name="keywatch-$asset_os-$asset_arch$exe_suffix" binary_url="https://github.com/$REPO/releases/download/$VERSION/$asset_name" - config_url="https://raw.githubusercontent.com/$REPO/$VERSION/detectors.toml" + binary_checksum_url="$binary_url.sha256" + config_url="https://github.com/$REPO/releases/download/$VERSION/detectors.toml" + config_checksum_url="$config_url.sha256" + binary_checksum_path="$install_dir/$asset_name.sha256" + config_checksum_path="$install_dir/detectors.toml.sha256" mkdir -p "$bin_dir" echo "Downloading KeyWatch $VERSION for $asset_os-$asset_arch..." - curl -fsSL "$binary_url" -o "$binary_path" - chmod +x "$binary_path" + curl "${curl_args[@]}" "$binary_url" -o "$binary_path" + curl "${curl_args[@]}" "$binary_checksum_url" -o "$binary_checksum_path" + + echo "Downloading KeyWatch detectors config from release $VERSION..." + curl "${curl_args[@]}" "$config_url" -o "$config_path" + curl "${curl_args[@]}" "$config_checksum_url" -o "$config_checksum_path" + + expected_binary_checksum=$(awk 'NR == 1 { print $1 }' "$binary_checksum_path") + expected_config_checksum=$(awk 'NR == 1 { print $1 }' "$config_checksum_path") + if [ "$asset_os" = "darwin" ]; then + actual_binary_checksum=$(shasum -a 256 "$binary_path" | awk '{ print $1 }') + actual_config_checksum=$(shasum -a 256 "$config_path" | awk '{ print $1 }') + else + actual_binary_checksum=$(sha256sum "$binary_path" | awk '{ print $1 }') + actual_config_checksum=$(sha256sum "$config_path" | awk '{ print $1 }') + fi + if [ "$actual_binary_checksum" != "$expected_binary_checksum" ]; then + echo "ERROR: KeyWatch binary checksum verification failed" >&2 + exit 1 + fi + if [ "$actual_config_checksum" != "$expected_config_checksum" ]; then + echo "ERROR: detectors.toml checksum verification failed" >&2 + exit 1 + fi - echo "Downloading KeyWatch detectors config from $VERSION..." - curl -fsSL "$config_url" -o "$config_path" + chmod +x "$binary_path" export PATH="$bin_dir:$PATH" export KEYWATCH_CONFIG_PATH="$config_path" diff --git a/scripts/action_validation/keywatch_action_scenarios.py b/scripts/action_validation/keywatch_action_scenarios.py new file mode 100644 index 0000000..ae56705 --- /dev/null +++ b/scripts/action_validation/keywatch_action_scenarios.py @@ -0,0 +1,260 @@ +import hashlib +import os +import subprocess +import tempfile +from pathlib import Path +from typing import NamedTuple + + +class InstallScenario(NamedTuple): + name: str + tampered_asset: str + expected_status: int + expected_stderr: str = "" + + +class ScanScenario(NamedTuple): + name: str + paths: str + args: str + scanner_exit: int + report_mode: str + expected_status: int + expected_output: tuple[str, ...] = () + expected_capture: tuple[str, ...] = () + forbidden_capture: tuple[str, ...] = () + expected_stderr: tuple[str, ...] = () + expected_summary: tuple[str, ...] = () + preseed_report: bool = False + config: str = "" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def checksum(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def write_install_fixtures(root: Path, tampered_asset: str) -> tuple[Path, Path]: + assets = root / "assets" + assets.mkdir() + binary = assets / "keywatch-linux-x86_64" + config = assets / "detectors.toml" + write_executable(binary, "#!/usr/bin/env bash\nprintf '%s\\n' 'key-watch 1.1.0'\n") + config.write_text("[[detectors]]\nname = 'fixture'\n", encoding="utf-8") + for asset in (binary, config): + (assets / f"{asset.name}.sha256").write_text( + f"{checksum(asset)} {asset.name}\n", + encoding="utf-8", + ) + if tampered_asset: + (assets / tampered_asset).write_text("tampered\n", encoding="utf-8") + + tools = root / "tools" + tools.mkdir() + write_executable( + tools / "curl", + "#!/usr/bin/env bash\nset -euo pipefail\nurl=''\nout=''\n" + "while [ \"$#\" -gt 0 ]; do\n" + " case \"$1\" in\n" + " -o) out=\"$2\"; shift 2 ;;\n" + " https://*) url=\"$1\"; shift ;;\n" + " *) shift ;;\n" + " esac\n" + "done\ncp \"$KEYWATCH_ASSET_DIR/${url##*/}\" \"$out\"\n", + ) + write_executable(tools / "sha256sum", "#!/usr/bin/env bash\nshasum -a 256 \"$1\"\n") + return assets, tools + + +def run_install_scenarios(install_block: str) -> None: + scenarios = ( + InstallScenario("verified-assets", "", 0), + InstallScenario( + "tampered-binary", + "keywatch-linux-x86_64", + 1, + "binary checksum verification failed", + ), + InstallScenario( + "tampered-config", + "detectors.toml", + 1, + "detectors.toml checksum verification failed", + ), + ) + with tempfile.TemporaryDirectory(prefix="keywatch-install-") as raw_tmp: + root = Path(raw_tmp) + script = root / "install.sh" + write_executable(script, install_block) + for scenario in scenarios: + scenario_root = root / scenario.name + scenario_root.mkdir() + assets, tools = write_install_fixtures(scenario_root, scenario.tampered_asset) + env = { + "PATH": f"{tools}:{os.environ.get('PATH', '')}", + "KEYWATCH_ASSET_DIR": str(assets), + "INPUT_VERSION": "1.1.0", + "RUNNER_OS": "Linux", + "RUNNER_ARCH": "X64", + "RUNNER_TEMP": str(scenario_root / "runner"), + "GITHUB_PATH": str(scenario_root / "github-path"), + "GITHUB_ENV": str(scenario_root / "github-env"), + "GITHUB_TOKEN": "", + } + result = subprocess.run( + ["bash", str(script)], + check=False, + env=env, + text=True, + capture_output=True, + ) + require( + result.returncode == scenario.expected_status, + f"{scenario.name}: got status {result.returncode}, expected " + f"{scenario.expected_status}; stderr={result.stderr!r}", + ) + require( + scenario.expected_stderr in result.stderr, + f"{scenario.name}: missing stderr {scenario.expected_stderr!r}", + ) + if scenario.expected_status == 0: + config_env = (scenario_root / "github-env").read_text(encoding="utf-8") + require("KEYWATCH_CONFIG_PATH=" in config_env, "verified install must publish detector config") + + +def write_keywatch_stub(bin_dir: Path) -> None: + write_executable( + bin_dir / "key-watch", + "#!/usr/bin/env bash\nset -euo pipefail\n: > \"$KEYWATCH_CAPTURE\"\n" + "for arg in \"$@\"; do printf '%s\\n' \"$arg\" >> \"$KEYWATCH_CAPTURE\"; done\n" + "out=\"\"\nwhile [ \"$#\" -gt 0 ]; do\n" + " if [ \"$1\" = \"--output\" ]; then shift; out=\"$1\"; fi\n" + " shift || true\ndone\ncase \"$KEYWATCH_REPORT_MODE\" in\n" + " valid) printf '%s\\n' '{\"findings\":[{},{}]}' > \"$out\" ;;\n" + " malformed) printf '%s\\n' 'not-json' > \"$out\" ;;\n missing) ;;\n *) exit 99 ;;\nesac\n" + "exit \"$KEYWATCH_STUB_EXIT\"\n", + ) + + +def run_scan_scenarios(scan_block: str) -> None: + scenarios = ( + ScanScenario( + "glob-expands", + "scan/*.txt", + "", + 0, + "valid", + 0, + ("exit_code=0", "findings_count=2"), + ("--no-config-discovery", "scan/match.txt"), + ("scan/*.txt", "--verbose"), + ), + ScanScenario( + "explicit-config", + ".", + "", + 0, + "valid", + 0, + expected_capture=("--no-config-discovery\n", "--config\ntrusted.toml\n"), + config="trusted.toml", + ), + ScanScenario("semicolon-literal", "literal;touch_pwned", "", 0, "valid", 0, expected_capture=("literal;touch_pwned",)), + ScanScenario("path-option-is-literal", "--verbose", "", 0, "valid", 0, expected_capture=("--\n--verbose\n",)), + ScanScenario("format-long-value-rejected", ".", "--format sarif", 0, "valid", 1, expected_stderr=("managed by action inputs",)), + ScanScenario("config-passthrough-rejected", ".", "--config .keywatch.toml", 0, "valid", 1, expected_stderr=("managed by action inputs",)), + ScanScenario("verbose-long-value-rejected", ".", "--verbose=true", 0, "valid", 1, expected_stderr=("managed by action inputs",)), + ScanScenario("verbose-compact-short-rejected", ".", "-vv", 0, "valid", 1, expected_stderr=("managed by action inputs",)), + ScanScenario("verbose-mode-long-allowed", ".", "--verbose-mode", 0, "valid", 0, expected_capture=("--verbose-mode",)), + ScanScenario("scanner-nonzero-propagates", ".", "", 1, "valid", 1, ("exit_code=1", "findings_count=2")), + ScanScenario( + "missing-report-zero-fails", + ".", + "", + 0, + "missing", + 2, + ("exit_code=0", "findings_count=unknown"), + expected_stderr=("JSON report is missing",), + expected_summary=("| Report | missing |",), + ), + ScanScenario( + "stale-report-ignored", + ".", + "", + 0, + "missing", + 2, + ("exit_code=0", "findings_count=unknown"), + expected_stderr=("JSON report is missing",), + expected_summary=("| Report | missing |",), + preseed_report=True, + ), + ) + with tempfile.TemporaryDirectory(prefix="keywatch-action-") as raw_tmp: + tmp = Path(raw_tmp) + scan_script = tmp / "scan.sh" + write_executable(scan_script, scan_block) + bin_dir = tmp / "bin" + bin_dir.mkdir() + write_keywatch_stub(bin_dir) + for scenario in scenarios: + workspace = tmp / scenario.name + workspace.mkdir() + (workspace / "scan").mkdir() + (workspace / "scan" / "match.txt").write_text("match", encoding="utf-8") + (workspace / "scan" / "other.md").write_text("other", encoding="utf-8") + (workspace / "detectors.toml").write_text("", encoding="utf-8") + if scenario.preseed_report: + (workspace / "keywatch-report.json").write_text('{"findings":[{}, {}, {}, {}]}\n', encoding="utf-8") + env = { + "PATH": f"{bin_dir}:{os.environ.get('PATH', '')}", + "KEYWATCH_CONFIG_PATH": str(workspace / "detectors.toml"), + "GITHUB_OUTPUT": str(workspace / "output"), + "GITHUB_STEP_SUMMARY": str(workspace / "summary"), + "RUNNER_TEMP": str(workspace), + "INPUT_PATHS": scenario.paths, + "INPUT_ARGS": scenario.args, + "INPUT_EXIT_MODE": "strict", + "INPUT_OUTPUT": "", + "INPUT_VERBOSE": "false", + "INPUT_CONFIG": scenario.config, + "KEYWATCH_CAPTURE": str(workspace / "capture"), + "KEYWATCH_STUB_EXIT": str(scenario.scanner_exit), + "KEYWATCH_REPORT_MODE": scenario.report_mode, + } + result = subprocess.run( + ["bash", str(scan_script)], + check=False, + cwd=workspace, + env=env, + text=True, + capture_output=True, + ) + require( + result.returncode == scenario.expected_status, + f"{scenario.name}: got status {result.returncode}, expected " + f"{scenario.expected_status}; stderr={result.stderr!r}", + ) + output = (workspace / "output").read_text(encoding="utf-8") if (workspace / "output").exists() else "" + summary = (workspace / "summary").read_text(encoding="utf-8") if (workspace / "summary").exists() else "" + capture = (workspace / "capture").read_text(encoding="utf-8") if (workspace / "capture").exists() else "" + for expected in scenario.expected_output: + require(expected in output, f"{scenario.name}: missing output {expected!r}") + for expected in scenario.expected_capture: + require(expected in capture, f"{scenario.name}: missing argv {expected!r}") + for forbidden in scenario.forbidden_capture: + require(forbidden not in capture, f"{scenario.name}: forbidden argv {forbidden!r}") + for expected in scenario.expected_stderr: + require(expected in result.stderr, f"{scenario.name}: missing stderr {expected!r}") + for expected in scenario.expected_summary: + require(expected in summary, f"{scenario.name}: missing summary {expected!r}") diff --git a/scripts/action_validation/release_script_scenario.py b/scripts/action_validation/release_script_scenario.py new file mode 100644 index 0000000..2b66b97 --- /dev/null +++ b/scripts/action_validation/release_script_scenario.py @@ -0,0 +1,102 @@ +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def run_release_scenario(root: Path) -> None: + with tempfile.TemporaryDirectory(prefix="keywatch-release-") as raw_tmp: + workspace = Path(raw_tmp) + tools = workspace / "tools" + source = workspace / "src" + tools.mkdir() + source.mkdir() + shutil.copy2(root / "scripts" / "release.sh", workspace / "release.sh") + (workspace / "Cargo.toml").write_text( + '[package]\nname = "keywatch-release-fixture"\nversion = "1.1.0"\n' + 'edition = "2024"\n', + encoding="utf-8", + ) + (workspace / "Cargo.lock").write_text( + '# This file is automatically @generated by Cargo.\nversion = 4\n\n' + '[[package]]\nname = "keywatch-release-fixture"\nversion = "1.1.0"\n', + encoding="utf-8", + ) + (source / "lib.rs").write_text("", encoding="utf-8") + (workspace / "CHANGELOG.md").write_text( + "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Fixture\n", + encoding="utf-8", + ) + (workspace / "action.yml").write_text( + "inputs:\n version:\n description: 'Exact version'\n" + " required: false\n default: '1.1.0'\n", + encoding="utf-8", + ) + write_executable( + tools / "git", + "#!/usr/bin/env bash\nset -euo pipefail\n" + "printf '%s\\n' \"$*\" >> \"$GIT_CAPTURE\"\n" + "case \"${1:-}\" in\n" + " rev-parse) printf '%s\\n' master ;;\n" + " diff-index) exit 0 ;;\n" + " show-ref|ls-remote) exit 1 ;;\n" + " *) exit 0 ;;\n" + "esac\n", + ) + write_executable( + tools / "gh", + "#!/usr/bin/env bash\nif [ \"${1:-}\" = auth ]; then exit 1; fi\nexit 0\n", + ) + write_executable( + tools / "cargo", + "#!/usr/bin/env bash\nset -euo pipefail\n" + "printf '%s\\n' \"$*\" >> \"$CARGO_CAPTURE\"\n" + "version=$(sed -n 's/^version = \"\\([^\"]*\\)\"/\\1/p' Cargo.toml)\n" + "sed -i.bak \"s/^version = \\\"[^\\\"]*\\\"/version = \\\"$version\\\"/\" Cargo.lock\n" + "rm Cargo.lock.bak\n", + ) + capture = workspace / "git-capture" + cargo_capture = workspace / "cargo-capture" + env = { + **os.environ, + "PATH": f"{tools}:{os.environ.get('PATH', '')}", + "GIT_CAPTURE": str(capture), + "CARGO_CAPTURE": str(cargo_capture), + } + result = subprocess.run( + ["bash", "release.sh", "create_pr", "2.0.0"], + check=False, + cwd=workspace, + env=env, + text=True, + capture_output=True, + ) + require(result.returncode == 0, f"release scenario failed: {result.stderr!r}") + action = (workspace / "action.yml").read_text(encoding="utf-8") + cargo = (workspace / "Cargo.toml").read_text(encoding="utf-8") + lock = (workspace / "Cargo.lock").read_text(encoding="utf-8") + changelog = (workspace / "CHANGELOG.md").read_text(encoding="utf-8") + git_calls = capture.read_text(encoding="utf-8") + cargo_calls = cargo_capture.read_text(encoding="utf-8") + require("version = \"2.0.0\"" in cargo, "release must update Cargo.toml") + require("version = \"2.0.0\"" in lock, "release must update Cargo.lock") + require("## [2.0.0] -" in changelog, "release must promote the changelog") + require( + re.search(r"(?m)^ default: '2\.0\.0'$", action) is not None, + "release must update the Action version", + ) + require("add Cargo.toml CHANGELOG.md Cargo.lock action.yml" in git_calls, "release must stage action.yml") + require("[skip ci]" not in git_calls, "release PR commits must run CI") + require(cargo_calls == "check --quiet\n", "release must refresh the lockfile without updating dependencies") diff --git a/scripts/action_validation/validate.py b/scripts/action_validation/validate.py new file mode 100644 index 0000000..9a3c545 --- /dev/null +++ b/scripts/action_validation/validate.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Static regression checks for the KeyWatch composite action.""" + +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +from keywatch_action_scenarios import run_install_scenarios, run_scan_scenarios +from release_script_scenario import run_release_scenario +ROOT = Path(__file__).resolve().parents[2] +ACTION = ROOT / "action.yml" + + +def run_blocks(text: str) -> list[str]: + blocks: list[str] = [] + lines = text.splitlines() + index = 0 + while index < len(lines): + line = lines[index] + if re.match(r"^\s{6}run:\s*\|\s*$", line): + block: list[str] = [] + index += 1 + while index < len(lines): + next_line = lines[index] + if next_line and not next_line.startswith(" "): + break + block.append(next_line[8:] if next_line.startswith(" ") else "") + index += 1 + blocks.append("\n".join(block)) + continue + index += 1 + return blocks + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def validate_workflows() -> None: + workflows = ROOT / ".github" / "workflows" + ci = (workflows / "ci.yml").read_text(encoding="utf-8") + release = (workflows / "release.yml").read_text(encoding="utf-8") + docker = (workflows / "docker-publish.yml").read_text(encoding="utf-8") + validator = "python3 scripts/action_validation/validate.py" + + require(validator in ci, "CI must validate the public Action") + require("docker build" in ci, "CI must build the public container") + require("docker run" in ci, "CI must smoke-test the public container") + require(" preflight:" in release, "release workflow must have a preflight job") + require("needs: preflight" in release, "publishing must depend on preflight") + require("cargo test --release" in release, "release preflight must test release mode") + require("cargo clippy" in release, "release preflight must run Clippy") + require(validator in release, "release preflight must validate distribution") + require("attestations: write" in docker, "container publishing must allow attestations") + require("id-token: write" in docker, "container publishing must allow OIDC provenance") + require("IMAGE_NAME: pixincreate/keywatch" in docker, "container image and attestation names must match") + require("type=semver,pattern={{major}}" in docker, "container publishing must create a major tag") + require("type=raw,value=latest" in docker, "container publishing must create a latest tag") + require("workflow_dispatch:" not in docker, "manual runs must not overwrite stable container tags") + require("tag_version=${GITHUB_REF_NAME#v}" in docker, "container publishing must validate the release tag") + require('test "$tag_version" = "$cargo_version"' in docker, "container tag must match Cargo.toml") + require('test "$tag_version" = "$action_version"' in docker, "container tag must match the Action version") + require('grep -Fq "## [$tag_version] -" CHANGELOG.md' in docker, "container tag must exist in the changelog") + require("id: push" in docker, "container build digest must be addressable") + require("actions/attest-build-provenance@" in docker, "container publishing must attest provenance") + require("subject-digest: ${{ steps.push.outputs.digest }}" in docker, "attestation must bind the pushed digest") + require("cargo build --locked --release" in release, "release binaries must use the checked-in lockfile") + + +def main() -> int: + text = ACTION.read_text(encoding="utf-8") + cargo_toml = (ROOT / "Cargo.toml").read_text(encoding="utf-8") + blocks = run_blocks(text) + shell = "\n".join(blocks) + + cargo_version = re.search(r'^version = "([^"]+)"$', cargo_toml, re.MULTILINE) + action_version = re.search( + r"(?m)^ version:\n description:.*\n required: false\n" + r" default: '([^']+)'$", + text, + ) + require(cargo_version is not None, "Cargo.toml package version is missing") + require(action_version is not None, "Action version input/default is missing") + require( + action_version.group(1) == cargo_version.group(1), + "Action default version must match Cargo.toml", + ) + + for block_index, block in enumerate(blocks, start=1): + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".bash") as script: + script.write(block) + script.flush() + result = subprocess.run( + ["bash", "-n", script.name], + check=False, + text=True, + capture_output=True, + ) + require( + result.returncode == 0, + f"run block {block_index} is not valid Bash: {result.stderr.strip()}", + ) + + forbidden_fragments = [ + "sudo ", + "eval ", + "${VERBOSE:+--verbose}", + "${INPUT_VERBOSE:+--verbose}", + "key-watch scan $", + "FINDINGS_COUNT=\"0\"", + "|| echo \"0\"", + ] + for fragment in forbidden_fragments: + require(fragment not in shell, f"forbidden shell fragment remains: {fragment}") + + require("${{ inputs." not in shell, "inputs must be routed through step env, not run blocks") + require("keywatch_args=(scan --no-config-discovery)" in shell, "Action scans must disable untrusted config discovery") + require("keywatch_args+=(--config \"$INPUT_CONFIG\")" in shell, "explicit trusted config input must be supported") + require("read -r -a paths" in shell, "paths input must be parsed without shell evaluation") + require("compgen -G \"$path_token\"" in shell, "path globs must expand without shell evaluation") + require("expanded_paths+=(\"$path_token\")" in shell, "unmatched globs and metachar literals must stay literal") + require("keywatch_args+=(-- \"${expanded_paths[@]}\")" in shell, "path operands must be guarded by --") + require("read -r -a extra_args" in shell, "args input must be parsed without shell evaluation") + require("managed by action inputs" in shell, "args must not override verbose/output/exit-mode inputs") + require("--verbose|--verbose=*|-v*" in shell, "all verbose arg forms must be rejected") + require("--format|--format=*|-f|-f*" in shell, "all format arg forms must be rejected") + require("--config|--config=*|--no-config-discovery|--no-config-discovery=*" in shell, "config discovery controls must be managed by the Action") + require("verbose output is disabled" in shell, "verbose mode must not log matched secrets") + require("false|0|no|off|\"\")" in shell, "verbose=false must be an explicit non-verbose case") + require("asset_arch=\"aarch64\"" in shell, "Darwin ARM64 must map to aarch64 release assets") + require('VERSION="$INPUT_VERSION"' in shell, "version input must select the release") + require("invalid KeyWatch release version" in shell, "release version must be validated before filesystem/URL use") + require("$RUNNER_TEMP/keywatch" in shell, "binary/config install must stay under RUNNER_TEMP") + require("KEYWATCH_CONFIG_PATH=$config_path" in shell, "detectors config path must be persisted") + require("echo \"$bin_dir\" >> \"$GITHUB_PATH\"" in shell, "binary dir must be published via GITHUB_PATH") + require("export PATH=\"$bin_dir:$PATH\"" in shell, "binary dir must be on current-step PATH") + require("releases/download/$VERSION/detectors.toml" in shell, "detectors.toml must come from the exact release") + require("$binary_url.sha256" in shell, "binary checksum must come from the exact release") + require("$config_url.sha256" in shell, "config checksum must come from the exact release") + require("binary checksum verification failed" in shell, "binary checksum mismatch must fail installation") + require("detectors.toml checksum verification failed" in shell, "config checksum mismatch must fail installation") + require("keywatch_args+=(--output \"$report_path\")" in shell, "scan must always request a JSON report") + require("rm -f -- \"$report_path\"" in shell, "stale reports must be removed before scanning") + require("scan_status=$?" in shell, "scanner exit status must be captured") + require("echo \"exit_code=$scan_status\"" in shell, "scanner status must be written to outputs") + require("findings_count=\"unknown\"" in shell, "missing/malformed reports must not default to zero findings") + require("jq -e '.findings | type == \"array\"'" in shell, "findings count must validate JSON report shape") + require("action_status=$scan_status" in shell, "action status must preserve scanner status by default") + require("action_status=2" in shell, "missing/malformed report after scanner success must fail integration") + require("GITHUB_STEP_SUMMARY" in shell, "action must append a Markdown summary") + require("exit \"$action_status\"" in shell, "action must exit with scanner or integration failure status") + require("Windows runners are not supported" in shell, "Windows must not be implied as supported") + + require(len(blocks) >= 2, "scan run block missing") + run_install_scenarios(blocks[0]) + run_scan_scenarios(blocks[1]) + run_release_scenario(ROOT) + validate_workflows() + + print(f"validated {ACTION.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except AssertionError as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/release.sh b/scripts/release.sh index 417cafd..f7d111f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -108,9 +108,13 @@ update_version_files() { ## [$VERSION] - $DATE/" \ CHANGELOG.md && rm CHANGELOG.md.bak - echo "Updated version to $VERSION in Cargo.toml and CHANGELOG.md" + sed -i.bak \ + -e "/^ version:$/,/^ paths:$/ s/^ default: .*/ default: '$VERSION'/" \ + action.yml && rm action.yml.bak + + echo "Updated version to $VERSION in Cargo.toml, action.yml, and CHANGELOG.md" - cargo update --quiet + cargo check --quiet } ensure_clean_master() { @@ -146,8 +150,8 @@ create_pr() { update_version_files - git add Cargo.toml CHANGELOG.md Cargo.lock - git commit -m "release(KeyWatch): version $VERSION [skip ci]" + git add Cargo.toml CHANGELOG.md Cargo.lock action.yml + git commit -m "release(KeyWatch): version $VERSION" git push -u origin "$RELEASE_BRANCH" if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then @@ -236,4 +240,4 @@ if [[ "$FUNCTION" != "create_pr" && "$FUNCTION" != "create_tag" && "$FUNCTION" ! exit 1 fi -eval "$FUNCTION" \ No newline at end of file +eval "$FUNCTION" diff --git a/scripts/validate-keywatch-action.py b/scripts/validate-keywatch-action.py deleted file mode 100755 index f8946a3..0000000 --- a/scripts/validate-keywatch-action.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression checks for the KeyWatch composite action.""" - -import os -import re -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import NamedTuple - - -ROOT = Path(__file__).resolve().parents[1] -ACTION = ROOT / ".github" / "actions" / "keywatch-scan" / "action.yml" - - -class ScanScenario(NamedTuple): - name: str - paths: str - args: str - scanner_exit: int - report_mode: str - expected_status: int - expected_output: tuple[str, ...] = () - expected_capture: tuple[str, ...] = () - forbidden_capture: tuple[str, ...] = () - expected_stderr: tuple[str, ...] = () - expected_summary: tuple[str, ...] = () - preseed_report: bool = False - config: str = "" - - -def run_blocks(text: str) -> list[str]: - blocks: list[str] = [] - lines = text.splitlines() - index = 0 - while index < len(lines): - line = lines[index] - if re.match(r"^\s{6}run:\s*\|\s*$", line): - block: list[str] = [] - index += 1 - while index < len(lines): - next_line = lines[index] - if next_line and not next_line.startswith(" "): - break - block.append(next_line[8:] if next_line.startswith(" ") else "") - index += 1 - blocks.append("\n".join(block)) - continue - index += 1 - return blocks - - -def require(condition: bool, message: str) -> None: - if not condition: - raise AssertionError(message) - - -def write_keywatch_stub(bin_dir: Path) -> Path: - stub = bin_dir / "key-watch" - stub.write_text( - "#!/usr/bin/env bash\nset -euo pipefail\n: > \"$KEYWATCH_CAPTURE\"\n" - "for arg in \"$@\"; do printf '%s\\n' \"$arg\" >> \"$KEYWATCH_CAPTURE\"; done\n" - "out=\"\"\nwhile [ \"$#\" -gt 0 ]; do\n" - " if [ \"$1\" = \"--output\" ]; then shift; out=\"$1\"; fi\n" - " shift || true\ndone\ncase \"$KEYWATCH_REPORT_MODE\" in\n" - " valid) printf '%s\\n' '{\"findings\":[{},{}]}' > \"$out\" ;;\n" - " malformed) printf '%s\\n' 'not-json' > \"$out\" ;;\n missing) ;;\n *) exit 99 ;;\nesac\n" - "exit \"$KEYWATCH_STUB_EXIT\"\n", - encoding="utf-8", - ) - stub.chmod(0o755) - return stub - - -def run_scan_scenarios(scan_block: str) -> None: - scenarios = ( - ScanScenario("glob-expands", "scan/*.txt", "", 0, "valid", 0, ("exit_code=0", "findings_count=2"), ("--no-config-discovery", "scan/match.txt"), ("scan/*.txt", "--verbose")), - ScanScenario("explicit-config", ".", "", 0, "valid", 0, expected_capture=("--no-config-discovery\n", "--config\ntrusted.toml\n"), config="trusted.toml"), - ScanScenario("semicolon-literal", "literal;touch_pwned", "", 0, "valid", 0, expected_capture=("literal;touch_pwned",)), - ScanScenario("path-option-is-literal", "--verbose", "", 0, "valid", 0, expected_capture=("--\n--verbose\n",)), - ScanScenario("format-long-value-rejected", ".", "--format sarif", 0, "valid", 1, expected_stderr=("managed by action inputs",)), - ScanScenario("config-passthrough-rejected", ".", "--config .keywatch.toml", 0, "valid", 1, expected_stderr=("managed by action inputs",)), - ScanScenario("verbose-long-value-rejected", ".", "--verbose=true", 0, "valid", 1, expected_stderr=("managed by action inputs",)), - ScanScenario("verbose-compact-short-rejected", ".", "-vv", 0, "valid", 1, expected_stderr=("managed by action inputs",)), - ScanScenario("verbose-mode-long-allowed", ".", "--verbose-mode", 0, "valid", 0, expected_capture=("--verbose-mode",)), - ScanScenario("scanner-nonzero-propagates", ".", "", 1, "valid", 1, ("exit_code=1", "findings_count=2")), - ScanScenario("missing-report-zero-fails", ".", "", 0, "missing", 2, ("exit_code=0", "findings_count=unknown"), expected_stderr=("JSON report is missing",), expected_summary=("| Report | missing |",)), - ScanScenario("stale-report-ignored", ".", "", 0, "missing", 2, ("exit_code=0", "findings_count=unknown"), expected_stderr=("JSON report is missing",), expected_summary=("| Report | missing |",), preseed_report=True), - ) - - with tempfile.TemporaryDirectory(prefix="keywatch-action-") as raw_tmp: - tmp = Path(raw_tmp) - scan_script = tmp / "scan.sh" - scan_script.write_text(scan_block, encoding="utf-8") - scan_script.chmod(0o755) - bin_dir = tmp / "bin" - bin_dir.mkdir() - write_keywatch_stub(bin_dir) - - for scenario in scenarios: - workspace = tmp / scenario.name - workspace.mkdir() - (workspace / "scan").mkdir() - (workspace / "scan" / "match.txt").write_text("match", encoding="utf-8") - (workspace / "scan" / "other.md").write_text("other", encoding="utf-8") - (workspace / "detectors.toml").write_text("", encoding="utf-8") - if scenario.preseed_report: - (workspace / "keywatch-report.json").write_text( - '{"findings":[{}, {}, {}, {}]}\n', - encoding="utf-8", - ) - - env = { - "PATH": f"{bin_dir}:{os.environ.get('PATH', '')}", - "KEYWATCH_CONFIG_PATH": str(workspace / "detectors.toml"), - "GITHUB_OUTPUT": str(workspace / "output"), - "GITHUB_STEP_SUMMARY": str(workspace / "summary"), - "RUNNER_TEMP": str(workspace), - "INPUT_PATHS": scenario.paths, - "INPUT_ARGS": scenario.args, - "INPUT_EXIT_MODE": "strict", - "INPUT_OUTPUT": "", - "INPUT_VERBOSE": "false", - "INPUT_CONFIG": scenario.config, - "KEYWATCH_CAPTURE": str(workspace / "capture"), - "KEYWATCH_STUB_EXIT": str(scenario.scanner_exit), - "KEYWATCH_REPORT_MODE": scenario.report_mode, - } - result = subprocess.run( - ["bash", str(scan_script)], - check=False, - cwd=workspace, - env=env, - text=True, - capture_output=True, - ) - require( - result.returncode == scenario.expected_status, - f"{scenario.name}: got status {result.returncode}, " - f"expected {scenario.expected_status}; stderr={result.stderr!r}", - ) - - output = (workspace / "output").read_text(encoding="utf-8") if (workspace / "output").exists() else "" - summary = (workspace / "summary").read_text(encoding="utf-8") if (workspace / "summary").exists() else "" - capture = (workspace / "capture").read_text(encoding="utf-8") if (workspace / "capture").exists() else "" - for expected in scenario.expected_output: - require(expected in output, f"{scenario.name}: missing output {expected!r}") - for expected in scenario.expected_capture: - require(expected in capture, f"{scenario.name}: missing argv {expected!r}") - for forbidden in scenario.forbidden_capture: - require(forbidden not in capture, f"{scenario.name}: forbidden argv {forbidden!r}") - for expected in scenario.expected_stderr: - require(expected in result.stderr, f"{scenario.name}: missing stderr {expected!r}") - for expected in scenario.expected_summary: - require(expected in summary, f"{scenario.name}: missing summary {expected!r}") - - -def main() -> int: - text = ACTION.read_text(encoding="utf-8") - blocks = run_blocks(text) - shell = "\n".join(blocks) - - for block_index, block in enumerate(blocks, start=1): - with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".bash") as script: - script.write(block) - script.flush() - result = subprocess.run( - ["bash", "-n", script.name], - check=False, - text=True, - capture_output=True, - ) - require( - result.returncode == 0, - f"run block {block_index} is not valid Bash: {result.stderr.strip()}", - ) - - forbidden_fragments = [ - "sudo ", - "eval ", - "${VERBOSE:+--verbose}", - "${INPUT_VERBOSE:+--verbose}", - "key-watch scan $", - "FINDINGS_COUNT=\"0\"", - "|| echo \"0\"", - ] - for fragment in forbidden_fragments: - require(fragment not in shell, f"forbidden shell fragment remains: {fragment}") - - require("${{ inputs." not in shell, "inputs must be routed through step env, not run blocks") - require("keywatch_args=(scan --no-config-discovery)" in shell, "Action scans must disable untrusted config discovery") - require("keywatch_args+=(--config \"$INPUT_CONFIG\")" in shell, "explicit trusted config input must be supported") - require("read -r -a paths" in shell, "paths input must be parsed without shell evaluation") - require("compgen -G \"$path_token\"" in shell, "path globs must expand without shell evaluation") - require("expanded_paths+=(\"$path_token\")" in shell, "unmatched globs and metachar literals must stay literal") - require("keywatch_args+=(-- \"${expanded_paths[@]}\")" in shell, "path operands must be guarded by --") - require("read -r -a extra_args" in shell, "args input must be parsed without shell evaluation") - require("managed by action inputs" in shell, "args must not override verbose/output/exit-mode inputs") - require("--verbose|--verbose=*|-v*" in shell, "all verbose arg forms must be rejected") - require("--format|--format=*|-f|-f*" in shell, "all format arg forms must be rejected") - require("--config|--config=*|--no-config-discovery|--no-config-discovery=*" in shell, "config discovery controls must be managed by the Action") - require("verbose output is disabled" in shell, "verbose mode must not log matched secrets") - require("false|0|no|off|\"\")" in shell, "verbose=false must be an explicit non-verbose case") - require("asset_arch=\"aarch64\"" in shell, "Darwin ARM64 must map to aarch64 release assets") - require("VERSION=$(jq -er '.tag_name'" in shell, "latest must resolve to a concrete release tag") - require("invalid KeyWatch version/tag" in shell, "release tag must be validated before filesystem/URL use") - require("$RUNNER_TEMP/keywatch" in shell, "binary/config install must stay under RUNNER_TEMP") - require("KEYWATCH_CONFIG_PATH=$config_path" in shell, "detectors config path must be persisted") - require("echo \"$bin_dir\" >> \"$GITHUB_PATH\"" in shell, "binary dir must be published via GITHUB_PATH") - require("export PATH=\"$bin_dir:$PATH\"" in shell, "binary dir must be on current-step PATH") - require("config_url=\"https://raw.githubusercontent.com/$REPO/$VERSION/detectors.toml\"" in shell, "detectors.toml must come from the exact tag") - require("keywatch_args+=(--output \"$report_path\")" in shell, "scan must always request a JSON report") - require("rm -f -- \"$report_path\"" in shell, "stale reports must be removed before scanning") - require("scan_status=$?" in shell, "scanner exit status must be captured") - require("echo \"exit_code=$scan_status\"" in shell, "scanner status must be written to outputs") - require("findings_count=\"unknown\"" in shell, "missing/malformed reports must not default to zero findings") - require("jq -e '.findings | type == \"array\"'" in shell, "findings count must validate JSON report shape") - require("action_status=$scan_status" in shell, "action status must preserve scanner status by default") - require("action_status=2" in shell, "missing/malformed report after scanner success must fail integration") - require("GITHUB_STEP_SUMMARY" in shell, "action must append a Markdown summary") - require("exit \"$action_status\"" in shell, "action must exit with scanner or integration failure status") - require("Windows runners are not supported" in shell, "Windows must not be implied as supported") - - require(len(blocks) >= 2, "scan run block missing") - run_scan_scenarios(blocks[1]) - - print(f"validated {ACTION.relative_to(ROOT)}") - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except AssertionError as error: - print(f"ERROR: {error}", file=sys.stderr) - raise SystemExit(1)