diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b035c84 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git/ +.gitignore +.github/ +target/ +*.md +COMPARISON.md +**/*.rs.bk +.DS_Store diff --git a/.github/actions/keywatch-scan/action.yml b/.github/actions/keywatch-scan/action.yml new file mode 100644 index 0000000..6de3401 --- /dev/null +++ b/.github/actions/keywatch-scan/action.yml @@ -0,0 +1,281 @@ +name: 'KeyWatch Scan' +description: 'Scan files and directories for secrets with KeyWatch' +author: 'Pa1Nark' + +inputs: + paths: + description: 'Paths to scan (space-separated, supports globs)' + required: false + default: '.' + args: + description: 'Additional CLI arguments to pass to key-watch' + required: false + default: '' + exit-mode: + description: 'Exit code behavior: strict, critical, or always' + required: false + default: 'strict' + output: + description: 'Path to write the JSON report file' + required: false + default: '' + verbose: + description: 'Deprecated: verbose scanner output is disabled to avoid logging matched secrets' + required: false + default: 'false' + +outputs: + findings-count: + description: 'Number of findings detected' + value: ${{ steps.scan.outputs.findings_count }} + exit-code: + description: 'Exit code from the scan' + value: ${{ steps.scan.outputs.exit_code }} + +runs: + using: 'composite' + steps: + - name: Install KeyWatch + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + for required_tool in curl jq; do + if ! command -v "$required_tool" >/dev/null 2>&1; then + echo "ERROR: $required_tool is required on this runner" >&2 + exit 1 + fi + done + + REPO="pixincreate/KeyWatch" + VERSION="${KEYWATCH_VERSION:-latest}" + + 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 + VERSION="v$VERSION" + fi + + if [[ ! "$VERSION" =~ ^v[0-9A-Za-z._-]+$ ]]; then + echo "ERROR: invalid KeyWatch version/tag '$VERSION'" >&2 + exit 1 + fi + + case "$RUNNER_OS" in + Linux) asset_os="linux"; exe_suffix="" ;; + macOS) asset_os="darwin"; exe_suffix="" ;; + Windows) + echo "ERROR: Windows runners are not supported by this composite action" >&2 + exit 1 + ;; + *) + echo "ERROR: unsupported runner OS: $RUNNER_OS" >&2 + exit 1 + ;; + esac + + case "$RUNNER_ARCH" in + X64) asset_arch="x86_64" ;; + ARM64) + if [ "$asset_os" = "darwin" ]; then + asset_arch="aarch64" + else + echo "ERROR: unsupported runner architecture for $RUNNER_OS: $RUNNER_ARCH" >&2 + exit 1 + fi + ;; + *) + echo "ERROR: unsupported runner architecture: $RUNNER_ARCH" >&2 + exit 1 + ;; + esac + + install_dir="$RUNNER_TEMP/keywatch/$VERSION" + bin_dir="$install_dir/bin" + config_path="$install_dir/detectors.toml" + 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" + + mkdir -p "$bin_dir" + + echo "Downloading KeyWatch $VERSION for $asset_os-$asset_arch..." + curl -fsSL "$binary_url" -o "$binary_path" + chmod +x "$binary_path" + + echo "Downloading KeyWatch detectors config from $VERSION..." + curl -fsSL "$config_url" -o "$config_path" + + export PATH="$bin_dir:$PATH" + export KEYWATCH_CONFIG_PATH="$config_path" + echo "$bin_dir" >> "$GITHUB_PATH" + echo "KEYWATCH_CONFIG_PATH=$config_path" >> "$GITHUB_ENV" + + echo "Installed KeyWatch $VERSION ($asset_name)" + key-watch --version + + - name: Run KeyWatch Scan + id: scan + shell: bash + env: + INPUT_PATHS: ${{ inputs.paths }} + INPUT_ARGS: ${{ inputs.args }} + INPUT_EXIT_MODE: ${{ inputs.exit-mode }} + INPUT_OUTPUT: ${{ inputs.output }} + INPUT_VERBOSE: ${{ inputs.verbose }} + run: | + set -euo pipefail + + for required_tool in jq; do + if ! command -v "$required_tool" >/dev/null 2>&1; then + echo "ERROR: $required_tool is required on this runner" >&2 + exit 1 + fi + done + + if [ -z "${KEYWATCH_CONFIG_PATH:-}" ] || [ ! -f "$KEYWATCH_CONFIG_PATH" ]; then + echo "ERROR: KEYWATCH_CONFIG_PATH is not set to a readable detectors.toml" >&2 + exit 1 + fi + + case "$INPUT_EXIT_MODE" in + always|critical|strict) ;; + *) + echo "ERROR: invalid exit-mode '$INPUT_EXIT_MODE' (expected: always, critical, strict)" >&2 + exit 1 + ;; + esac + + verbose_normalized=$(tr '[:upper:]' '[:lower:]' <<<"$INPUT_VERBOSE") + case "$verbose_normalized" in + true|1|yes|on) + echo "ERROR: verbose output is disabled in this action to avoid logging matched secrets" >&2 + exit 1 + ;; + false|0|no|off|"") ;; + *) + echo "ERROR: invalid verbose value '$INPUT_VERBOSE' (expected: true or false)" >&2 + exit 1 + ;; + esac + + paths=() + if [ -n "$INPUT_PATHS" ]; then + read -r -a paths <<<"$INPUT_PATHS" + fi + + expanded_paths=() + if ((${#paths[@]})); then + for path_token in "${paths[@]}"; do + case "$path_token" in + *'*'*|*'?'*|*'['*) + matches=() + while IFS= read -r match; do + matches+=("$match") + done < <(compgen -G "$path_token" || true) + + if ((${#matches[@]})); then + expanded_paths+=("${matches[@]}") + else + expanded_paths+=("$path_token") + fi + ;; + *) + expanded_paths+=("$path_token") + ;; + esac + done + fi + + extra_args=() + if [ -n "$INPUT_ARGS" ]; then + read -r -a extra_args <<<"$INPUT_ARGS" + fi + + if ((${#extra_args[@]})); then + for arg in "${extra_args[@]}"; do + case "$arg" in + --verbose|--verbose=*|-v*|--format|--format=*|-f|-f*|--output|--output=*|-o|-o*|--exit-mode|--exit-mode=*) + echo "ERROR: '$arg' is managed by action inputs and cannot be passed through args" >&2 + exit 1 + ;; + esac + done + fi + + if [ -n "$INPUT_OUTPUT" ]; then + report_path="$INPUT_OUTPUT" + report_dir=$(dirname -- "$report_path") + if [ "$report_dir" != "." ]; then + mkdir -p "$report_dir" + fi + else + report_path="$RUNNER_TEMP/keywatch-report.json" + mkdir -p "$(dirname -- "$report_path")" + fi + + keywatch_args=(scan) + keywatch_args+=(--exit-mode "$INPUT_EXIT_MODE") + if ((${#extra_args[@]})); then + keywatch_args+=("${extra_args[@]}") + fi + keywatch_args+=(--output "$report_path") + if ((${#expanded_paths[@]})); then + keywatch_args+=(-- "${expanded_paths[@]}") + fi + + rm -f -- "$report_path" + + echo "Running KeyWatch scan..." + set +e + key-watch "${keywatch_args[@]}" + scan_status=$? + set -e + + findings_count="unknown" + report_status="missing" + if [ -s "$report_path" ]; then + if jq -e '.findings | type == "array"' "$report_path" >/dev/null 2>&1; then + findings_count=$(jq -r '.findings | length' "$report_path") + report_status="ok" + else + report_status="malformed" + fi + fi + + action_status=$scan_status + if [ "$report_status" != "ok" ] && [ "$scan_status" -eq 0 ]; then + echo "ERROR: KeyWatch exited successfully but the JSON report is $report_status" >&2 + action_status=2 + fi + + { + echo "exit_code=$scan_status" + echo "findings_count=$findings_count" + } >> "$GITHUB_OUTPUT" + + { + echo "## KeyWatch Scan Results" + echo "" + echo "| Metric | Value |" + echo "|--------|-------|" + echo "| Findings | $findings_count |" + echo "| Exit Code | $scan_status |" + echo "| Report | $report_status |" + } >> "$GITHUB_STEP_SUMMARY" + + exit "$action_status" + +branding: + icon: 'shield' + color: 'blue' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2240918..c112ee2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@master @@ -43,10 +43,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Spell check - uses: crate-ci/typos@master + uses: crate-ci/typos@v1.48.0 test: name: test-${{ matrix.runner }} @@ -66,7 +66,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install mold linker uses: rui314/setup-mold@v1 @@ -80,7 +80,7 @@ jobs: toolchain: stable 2 weeks ago components: clippy - - uses: Swatinem/rust-cache@v2.7.0 + - uses: Swatinem/rust-cache@v2.9.1 with: save-if: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..7bac9bb --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,54 @@ +name: Publish Docker Image + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: 'Image tag' + required: true + default: 'latest' + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build and push Docker image + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,format=short + type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v7.3.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a42394c..5a81a26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,22 +38,27 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Extract Release Notes id: release-notes - if: matrix.platform.os == 'ubuntu-latest' + shell: bash run: | VERSION=${GITHUB_REF#refs/tags/} - NOTES=$(awk -v ver="$VERSION" ' - /^## \[/ { if (p) { exit }; if ($2 == "['ver']") { p=1; next } } + VER=${VERSION#v} + NOTES=$(awk -v ver="$VER" ' + /^## \[/ { if (p) { exit }; if ($2 == "["ver"]") { p=1; next } } p { print } ' CHANGELOG.md) - echo "NOTES<> $GITHUB_OUTPUT - echo "$NOTES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + if [ -z "$(printf '%s' "$NOTES" | tr -d '[:space:]')" ]; then + echo "::error::Release notes for $VERSION not found in CHANGELOG.md" + exit 1 + fi + echo "NOTES<> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" - uses: dtolnay/rust-toolchain@stable with: @@ -74,6 +79,9 @@ jobs: mkdir -p release cp target/${{ matrix.platform.target }}/release/key-watch${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }} \ release/${{ env.BINARY_NAME }} + if [ "${{ matrix.platform.os }}" = "ubuntu-latest" ]; then + cp detectors.toml release/detectors.toml + fi # Windows: Generate checksum using cmd and Windows syntax. - name: Generate SHA-256 (Windows) @@ -90,15 +98,20 @@ jobs: run: | cd release shasum -a 256 "$BINARY_NAME" > "$BINARY_NAME".sha256 + if [ "${{ matrix.platform.os }}" = "ubuntu-latest" ]; then + shasum -a 256 detectors.toml > detectors.toml.sha256 + fi # Create Release - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3.0.2 with: name: "KeyWatch ${{ github.ref_name }}" files: | release/${{ env.BINARY_NAME }} release/${{ env.BINARY_NAME }}.sha256 + ${{ matrix.platform.os == 'ubuntu-latest' && 'release/detectors.toml' || '' }} + ${{ matrix.platform.os == 'ubuntu-latest' && 'release/detectors.toml.sha256' || '' }} body: ${{ steps.release-notes.outputs.NOTES }} draft: false prerelease: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f64ab..60c31ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to this project will be documented in this file. - **Stdin scanning** — `scan --stdin` reads content from stdin instead of files - **Git history scanning** — `scan --git-history` scans `git log -p` output for committed secrets - 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 +- `.dockerignore` for optimized Docker builds ### Changed @@ -36,6 +39,9 @@ All notable changes to this project will be documented in this file. - `Severity::from_string()` now trims whitespace from input before parsing - `scan_stream()` chunk overlap fixed for accurate multiline detection on split chunks - Graceful error handling when `git` is not installed on the system +- `action.yml` removed `eval "$CMD"` pattern for security +- `action.yml` removed hardcoded GitHub authentication header +- `.dockerignore` now preserves `Cargo.lock` for reproducible builds ### Removed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa063d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 +# Stage 1: Build +# Alpine's musl toolchain produces a fully static binary with no libc +# dependency, so the runtime stage can stay tiny. +# +# Pin to the MSRV declared in Cargo.toml (rust-version = "1.85") so the +# Docker build always uses the minimum supported compiler, not whatever +# happens to be "latest" at build time. +FROM rust:1.85-alpine3.21 AS build + +RUN apk add --no-cache musl-dev + +WORKDIR /src + +# Copy manifests and sources together; Cargo.lock is required for --locked. +COPY Cargo.toml Cargo.lock ./ +COPY src/ src/ +COPY templates/ templates/ +COPY detectors.toml . + +RUN cargo build --locked --release && \ + strip target/release/key-watch + +# Stage 2: Runtime +# Alpine provides git (required for --git-history scanning and hook +# installation). The binary is statically linked against musl, so no libc +# needs to be installed. +FROM alpine:3.23 + +RUN apk add --no-cache git ca-certificates && \ + adduser -D keywatch + +COPY --from=build /src/target/release/key-watch /usr/local/bin/key-watch +COPY --from=build /src/detectors.toml /etc/keywatch/detectors.toml + +USER keywatch + +ENV KEYWATCH_CONFIG_PATH=/etc/keywatch/detectors.toml + +ENTRYPOINT ["key-watch"] +CMD ["--help"] diff --git a/scripts/validate-keywatch-action.py b/scripts/validate-keywatch-action.py new file mode 100755 index 0000000..f99fd36 --- /dev/null +++ b/scripts/validate-keywatch-action.py @@ -0,0 +1,231 @@ +#!/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 + + +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"), ("scan/match.txt",), ("scan/*.txt", "--verbose")), + 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("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", + "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)" in shell, "scanner argv must be built with a Bash array") + 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("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)