diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..ca79ca5b4d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/e2e-harness.yml b/.github/workflows/e2e-harness.yml index 0fb30ba099..c6173a9b55 100644 --- a/.github/workflows/e2e-harness.yml +++ b/.github/workflows/e2e-harness.yml @@ -12,7 +12,7 @@ permissions: contents: read env: - TRUFFLEHOG_IMAGE: ghcr.io/trufflesecurity/trufflehog@sha256:aa821cf4ace8861c7d096d83818cdf7bb9719028a52d37a52eaad44086a52577 # v3.96.0 + TRUFFLEHOG_IMAGE: ghcr.io/trufflesecurity/trufflehog@sha256:562bc231afa9de3d04de44cfe624252b08207de1fc3cebc5e7ed92bed7f279e4 # v3.97.4 UV_VERSION: "0.10.12" concurrency: @@ -77,60 +77,13 @@ jobs: DIAGNOSTICS_PATH: ${{ runner.temp }}/powercontext-e2e-diagnostics-${{ matrix.database }} EVIDENCE_PATH: ${{ github.workspace }}/.powercontext-e2e/${{ matrix.database }}/acceptance SCENARIO_OUTCOME: ${{ steps.acceptance_scenarios.outcome }} - run: | - set -eu - mkdir -p "${DIAGNOSTICS_PATH}" - summary="${DIAGNOSTICS_PATH}/summary.txt" - { - echo "database=${DATABASE}" - echo "diagnostic_format=powercontext-e2e-v1" - echo "scenario_outcome=${SCENARIO_OUTCOME}" - echo "scanner_image=${TRUFFLEHOG_IMAGE}" - } > "$summary" - - if ! test -d "${EVIDENCE_PATH}"; then - echo "evidence_status=missing" >> "$summary" - echo "Replay evidence directory was not produced." >&2 - exit 1 - fi - - file_count="$(find "${EVIDENCE_PATH}" -type f | wc -l | tr -d '[:space:]')" - if test "$file_count" = 0; then - echo "evidence_status=empty" >> "$summary" - echo "Replay evidence directory was empty." >&2 - exit 1 - fi - { - echo "evidence_status=present" - echo "evidence_file_count=${file_count}" - } >> "$summary" - - set +e - docker run --rm \ - --network none \ - --volume "${EVIDENCE_PATH}:/evidence:ro" \ - "${TRUFFLEHOG_IMAGE}" \ - filesystem /evidence \ - --no-verification \ - --results=verified,unknown,unverified \ - --fail \ - --fail-on-scan-errors \ - --no-update \ - --json > /dev/null 2>&1 - scanner_status=$? - set -e - if test "$scanner_status" -ne 0; then - { - echo "scan_status=failed" - echo "scanner_exit_code=${scanner_status}" - } >> "$summary" - echo "Replay evidence failed secret scanning; raw evidence will not be uploaded." >&2 - exit 1 - fi - echo "scan_status=clean" >> "$summary" + run: bash scripts/scan_e2e_evidence.sh - name: Upload sanitized replay diagnostics - if: always() + if: >- + always() && + (steps.evidence_scan.outcome != 'success' || + steps.acceptance_scenarios.outcome != 'success') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: e2e-acceptance-diagnostics-${{ matrix.database }}-${{ github.sha }} @@ -153,9 +106,3 @@ jobs: env: POWERCONTEXT_E2E_DATABASE: ${{ matrix.database }} run: make harness-compose-down - - - name: Enforce replay evidence policy - if: always() && steps.evidence_scan.outcome != 'success' - run: | - echo "Replay evidence is missing or did not pass secret scanning." >&2 - exit 1 diff --git a/Makefile b/Makefile index 9f6b7e9dc7..9cf84a4b6b 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,11 @@ notebooks-test: ## Execute provider-free tutorials in fresh kernels; use ARGS fo @uv run --locked --group notebooks python examples/jupyter/run.py $(ARGS) .PHONY: check -check: integration-manifest-check ## Run code quality tools. +check: workflow-actions-check integration-manifest-check ## Run code quality tools. + +.PHONY: workflow-actions-check +workflow-actions-check: ## Verify third-party GitHub Actions use immutable commit pins. + @uv run python scripts/check_workflow_actions.py .github/workflows .github/actions @echo "🚀 Checking lock file consistency with 'pyproject.toml'" @uv lock --locked @echo "🚀 Linting code: Running prek" diff --git a/scripts/check_workflow_actions.py b/scripts/check_workflow_actions.py new file mode 100644 index 0000000000..29cc9220c2 --- /dev/null +++ b/scripts/check_workflow_actions.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Require immutable commit pins for third-party GitHub Actions.""" + +from __future__ import annotations + +import re +import sys +from collections import deque +from pathlib import Path + +import yaml +from yaml.resolver import BaseResolver + +COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) +SUPPORTED_SUFFIXES = {".yaml", ".yml"} + + +class _UniqueKeyLoader(yaml.SafeLoader): + """Reject duplicate mapping keys so validation matches one YAML meaning.""" + + +class _DuplicateKeyError(yaml.YAMLError): + def __init__(self) -> None: + super().__init__("duplicate YAML key") + + +class _InvalidMappingKeyError(yaml.YAMLError): + def __init__(self) -> None: + super().__init__("unhashable YAML mapping key") + + +def _construct_unique_mapping( + loader: _UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False +) -> dict[object, object]: + mapping: dict[object, object] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + if key in mapping: + raise _DuplicateKeyError() + mapping[key] = loader.construct_object(value_node, deep=deep) + except TypeError as error: + raise _InvalidMappingKeyError() from error + return mapping + + +_UniqueKeyLoader.add_constructor(BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping) + + +def _files_to_scan(arguments: list[str]) -> list[Path]: + files: list[Path] = [] + for argument in arguments: + path = Path(argument) + if path.is_file(): + if path.suffix.lower() in SUPPORTED_SUFFIXES: + files.append(path) + continue + if path.is_dir(): + files.extend( + candidate + for candidate in path.rglob("*") + if candidate.is_file() + and candidate.suffix.lower() in SUPPORTED_SUFFIXES + and ".git" not in candidate.parts + ) + continue + print(f"path does not exist: {path}", file=sys.stderr) + raise SystemExit(2) + return sorted(set(files), key=lambda path: str(path).lower()) + + +def _action_references(value: object) -> list[str]: + references: list[str] = [] + pending = deque([value]) + visited: set[int] = set() + while pending: + current = pending.popleft() + if not isinstance(current, (dict, list)): + continue + current_id = id(current) + if current_id in visited: + continue + visited.add(current_id) + if isinstance(current, dict): + for key, child in current.items(): + if key == "uses": + references.append(child if isinstance(child, str) else "") + else: + pending.append(child) + else: + pending.extend(current) + return references + + +def _display_reference(reference: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_.@/-]{1,240}", reference): + return reference + return "[REDACTED]" + + +def main(arguments: list[str]) -> int: + references_checked = 0 + violations: list[str] = [] + for path in _files_to_scan(arguments or [".github/workflows", ".github/actions"]): + try: + document = yaml.load( + path.read_text(encoding="utf-8"), + Loader=_UniqueKeyLoader, # noqa: S506 + ) + except (OSError, UnicodeError, RecursionError, yaml.YAMLError): + print(f"{path}: could not parse YAML", file=sys.stderr) + return 1 + for reference in _action_references(document): + references_checked += 1 + if reference.startswith("./") or reference.startswith("../"): + continue + if "@" not in reference or not COMMIT_SHA.fullmatch(reference.rsplit("@", 1)[1]): + display_reference = _display_reference(reference) + violations.append(f"{path}: uses reference '{display_reference}' must use a 40-character commit SHA") + + if violations: + print("\n".join(violations), file=sys.stderr) + return 1 + print(f"{references_checked} action references checked") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/scan_e2e_evidence.sh b/scripts/scan_e2e_evidence.sh new file mode 100644 index 0000000000..84529e05c0 --- /dev/null +++ b/scripts/scan_e2e_evidence.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Scan replay evidence and publish only bounded, sanitized diagnostics. + +set -euo pipefail + +: "${DATABASE:?DATABASE is required}" +: "${DIAGNOSTICS_PATH:?DIAGNOSTICS_PATH is required}" +: "${EVIDENCE_PATH:?EVIDENCE_PATH is required}" +: "${SCENARIO_OUTCOME:?SCENARIO_OUTCOME is required}" +: "${TRUFFLEHOG_IMAGE:?TRUFFLEHOG_IMAGE is required}" + +mkdir -p "${DIAGNOSTICS_PATH}" +summary="${DIAGNOSTICS_PATH}/summary.txt" +scanner_json="${DIAGNOSTICS_PATH}/trufflehog.jsonl" +scanner_stderr="${DIAGNOSTICS_PATH}/trufflehog.stderr.log" +sanitize_database() { + case "$1" in + sqlite|oceanbase) printf '%s' "$1" ;; + *) printf '[REDACTED]' ;; + esac +} + +sanitize_scenario_outcome() { + case "$1" in + success|failure|cancelled|skipped) printf '%s' "$1" ;; + *) printf '[REDACTED]' ;; + esac +} + +sanitize_scanner_image() { + if test "${#1}" -le 240 \ + && printf '%s' "$1" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}@sha256:[0-9A-Fa-f]{64}$'; then + printf '%s' "$1" + else + printf '[REDACTED]' + fi +} + +scanner_image_summary="$(sanitize_scanner_image "${TRUFFLEHOG_IMAGE}")" +{ + echo "database=$(sanitize_database "${DATABASE}")" + echo "diagnostic_format=powercontext-e2e-v1" + echo "scenario_outcome=$(sanitize_scenario_outcome "${SCENARIO_OUTCOME}")" + echo "scanner_image=${scanner_image_summary}" +} > "${summary}" + +if test "${scanner_image_summary}" = "[REDACTED]"; then + { + echo "scan_status=infra_error" + echo "scanner_attempts=0" + echo "scanner_exit_code=2" + echo "scanner_error_category=configuration" + } >> "${summary}" + echo "Replay evidence scanning configuration is invalid; raw evidence will not be uploaded." >&2 + exit 1 +fi + +if ! test -d "${EVIDENCE_PATH}"; then + echo "evidence_status=missing" >> "${summary}" + echo "Replay evidence directory was not produced." >&2 + exit 1 +fi + +file_count="$(find "${EVIDENCE_PATH}" -type f | wc -l | tr -d '[:space:]')" +if test "${file_count}" = 0; then + echo "evidence_status=empty" >> "${summary}" + echo "Replay evidence directory was empty." >&2 + exit 1 +fi +{ + echo "evidence_status=present" + echo "evidence_file_count=${file_count}" +} >> "${summary}" + +run_scan() { + local json_output="$1" + local stderr_output="$2" + docker run --rm \ + --network none \ + --volume "${EVIDENCE_PATH}:/evidence:ro" \ + "${TRUFFLEHOG_IMAGE}" \ + filesystem /evidence \ + --no-verification \ + --results=verified,unknown,unverified \ + --fail \ + --fail-on-scan-errors \ + --no-update \ + --json > "${json_output}" 2> "${stderr_output}" || return $? +} + +sanitize_detector() { + local value="$1" + local lower_value="${value,,}" + if test "${#value}" -le 120; then + case "${value}" in + ""|[!A-Za-z0-9]*) ;; + *) + if test -z "$(printf '%s' "${value}" | tr -d 'A-Za-z0-9 _./-')"; then + case "${lower_value}" in + *password*|*secret*|*authorization*|*bearer*|*basic*|*api_key*|*api-key*|*api\ key*|*://*|*@*|*=*) + ;; + *) + printf '%s' "${value}" + return + ;; + esac + fi + ;; + esac + fi + printf '[REDACTED]' +} + +sanitize_path() { + local value="$1" + if [[ -z "${value}" ]]; then + printf 'unknown' + else + printf '[REDACTED]' + fi +} + +file_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + printf 'unavailable' + fi +} + +scanner_error_category() { + local stderr_file="$1" + if grep -Eiq 'docker daemon|cannot connect to the docker daemon|is the docker daemon running' "${stderr_file}"; then + printf 'docker_daemon' + elif grep -Eiq 'pull|manifest|image|ghcr\.io|not found' "${stderr_file}"; then + printf 'image_pull' + elif grep -Eiq 'network|timeout|connection|dns|resolve' "${stderr_file}"; then + printf 'network' + else + printf 'unknown' + fi +} + +if run_scan "${scanner_json}" "${scanner_stderr}"; then + scanner_status=0 +else + scanner_status=$? +fi +scanner_attempts=1 +if test "${scanner_status}" -ne 0 && test "${scanner_status}" -ne 183; then + scanner_attempts=2 + scanner_json_retry="${DIAGNOSTICS_PATH}/trufflehog-retry.jsonl" + scanner_stderr_retry="${DIAGNOSTICS_PATH}/trufflehog-retry.stderr.log" + if run_scan "${scanner_json_retry}" "${scanner_stderr_retry}"; then + scanner_status=0 + else + scanner_status=$? + fi + scanner_json="${scanner_json_retry}" + scanner_stderr="${scanner_stderr_retry}" +fi + +echo "scanner_attempts=${scanner_attempts}" >> "${summary}" +if test "${scanner_status}" -eq 183; then + echo "scan_status=findings" >> "${summary}" + echo "scanner_exit_code=183" >> "${summary}" + if command -v jq >/dev/null 2>&1; then + while IFS=$'\t' read -r detector path; do + detector="${detector%$'\r'}" + path="${path%$'\r'}" + printf 'finding=%s path=%s\n' \ + "$(sanitize_detector "${detector}")" \ + "$(sanitize_path "${path}")" >> "${summary}" + done < <( + jq -nr ' + limit(20; + inputs + | select(.DetectorName? != null) + | [ + (.DetectorName | tostring), + ((.SourceMetadata.Data.Filesystem.file // .SourceMetadata.Data.Filesystem.path // "unknown") + | tostring + | sub("^/evidence/?"; "")) + ] + | @tsv + ) + ' "${scanner_json}" 2>/dev/null || true + ) + else + echo "finding_diagnostics=jq_unavailable" >> "${summary}" + fi + echo "Replay evidence contained scanner findings; raw evidence will not be uploaded." >&2 + exit 1 +fi + +if test "${scanner_status}" -ne 0; then + echo "scan_status=infra_error" >> "${summary}" + echo "scanner_exit_code=${scanner_status}" >> "${summary}" + stderr_lines="$(wc -l < "${scanner_stderr}" | tr -d '[:space:]')" + if test "${stderr_lines}" -gt 50; then + stderr_lines=50 + fi + echo "scanner_error_category=$(scanner_error_category "${scanner_stderr}")" >> "${summary}" + echo "scanner_stderr_tail_lines=${stderr_lines}" >> "${summary}" + echo "scanner_stderr_sha256=$(file_sha256 "${scanner_stderr}")" >> "${summary}" + echo "Replay evidence scanning failed due to infrastructure error; raw evidence will not be uploaded." >&2 + exit 1 +fi + +echo "scan_status=clean" >> "${summary}" diff --git a/tests/test_e2e_workflow_contract.py b/tests/test_e2e_workflow_contract.py new file mode 100644 index 0000000000..a48290c62f --- /dev/null +++ b/tests/test_e2e_workflow_contract.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +WORKFLOW = (Path(__file__).parents[1] / ".github" / "workflows" / "e2e-harness.yml").read_text(encoding="utf-8") + + +def test_e2e_scan_keeps_raw_scanner_output_out_of_artifacts() -> None: + assert "--json > /dev/null 2>&1" not in WORKFLOW + assert "run: bash scripts/scan_e2e_evidence.sh" in WORKFLOW + assert "path: ${{ runner.temp }}/powercontext-e2e-diagnostics-${{ matrix.database }}/summary.txt" in WORKFLOW + + script = Path(__file__).parents[1] / "scripts" / "scan_e2e_evidence.sh" + script_text = script.read_text(encoding="utf-8") + assert 'scanner_json="${DIAGNOSTICS_PATH}/trufflehog.jsonl"' in script_text + assert 'scanner_stderr="${DIAGNOSTICS_PATH}/trufflehog.stderr.log"' in script_text + assert "path: ${{ runner.temp }}/powercontext-e2e-diagnostics-${{ matrix.database }}/summary.txt" in WORKFLOW + + +def test_e2e_scan_classifies_findings_and_retries_infrastructure_errors() -> None: + script = (Path(__file__).parents[1] / "scripts" / "scan_e2e_evidence.sh").read_text(encoding="utf-8") + assert 'test "${scanner_status}" -eq 183' in script + assert 'echo "scan_status=findings"' in script + assert 'echo "scan_status=infra_error"' in script + assert "scanner_attempts=2" in script + assert 'test "${scanner_status}" -ne 0 && test "${scanner_status}" -ne 183' in script + + +def test_e2e_diagnostics_upload_only_runs_for_failed_validation_or_scan() -> None: + assert "steps.evidence_scan.outcome != 'success'" in WORKFLOW + assert "steps.acceptance_scenarios.outcome != 'success'" in WORKFLOW + assert "Enforce replay evidence policy" not in WORKFLOW diff --git a/tests/test_scan_e2e_evidence.py b/tests/test_scan_e2e_evidence.py new file mode 100644 index 0000000000..b67177742e --- /dev/null +++ b/tests/test_scan_e2e_evidence.py @@ -0,0 +1,244 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).parents[1] +SCRIPT = REPOSITORY_ROOT / "scripts" / "scan_e2e_evidence.sh" + + +def run_scan( + tmp_path: Path, *, fake_docker: str, env_overrides: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + docker = tmp_path / "docker" + docker.write_text(fake_docker, encoding="utf-8") + docker.chmod(0o755) + evidence = tmp_path / "evidence" + evidence.mkdir() + (evidence / "replay.json").write_text("{}\n", encoding="utf-8") + diagnostics = tmp_path / "diagnostics" + env = { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "DATABASE": "sqlite", + "DIAGNOSTICS_PATH": str(diagnostics), + "EVIDENCE_PATH": str(evidence), + "SCENARIO_OUTCOME": "failure", + "TRUFFLEHOG_IMAGE": ( + "example/trufflehog@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ), + } + if env_overrides is not None: + env.update(env_overrides) + bash = shutil.which("bash") + assert bash is not None + command = [bash, str(SCRIPT)] + if os.name == "nt": + git = shutil.which("git") + assert git is not None + git_bin = Path(git).resolve().parent + git_bash = git_bin / "bash.exe" + assert git_bash.is_file() + cygpath = git_bin.parent / "usr" / "bin" / "cygpath.exe" + + def to_posix(path: Path) -> str: + return subprocess.run( + [str(cygpath), "-u", str(path)], capture_output=True, text=True, check=True + ).stdout.strip() + + bash = str(git_bash) + jq = shutil.which("jq") + jq_path = f":{to_posix(Path(jq).parent)}" if jq else "" + env["PATH"] = f"{to_posix(tmp_path)}:/usr/bin:/bin{jq_path}" + env["DIAGNOSTICS_PATH"] = to_posix(diagnostics) + env["EVIDENCE_PATH"] = to_posix(evidence) + command = [bash, to_posix(SCRIPT)] + return subprocess.run( + command, + cwd=REPOSITORY_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def test_findings_use_exit_183_and_only_safe_fields_reach_summary(tmp_path: Path) -> None: + result = run_scan( + tmp_path, + fake_docker="""#!/usr/bin/env bash +printf '%s\n' '{"DetectorName":"GitHub Token","Raw":"raw-secret-value","SourceMetadata":{"Data":{"Filesystem":{"file":"/evidence/replay.json"}}}}' +exit 183 +""", + ) + + summary = (tmp_path / "diagnostics" / "summary.txt").read_text(encoding="utf-8") + assert result.returncode == 1 + assert "scan_status=findings" in summary + assert "scanner_exit_code=183" in summary + assert "finding=GitHub Token path=[REDACTED]" in summary + assert "replay.json" not in summary + assert "raw-secret-value" not in summary + assert "scanner_attempts=1" in summary + + +def test_infrastructure_failure_retries_once_and_keeps_only_redacted_tail(tmp_path: Path) -> None: + result = run_scan( + tmp_path, + fake_docker="""#!/usr/bin/env bash +printf '%s\n' 'network timeout: {"password":"SyntheticP4ss!"} Authorization: Basic dXNlcjpzZWNyZXQ= https://user:secret@example.test' >&2 +exit 125 +""", + ) + + summary = (tmp_path / "diagnostics" / "summary.txt").read_text(encoding="utf-8") + assert result.returncode == 1 + assert "scan_status=infra_error" in summary + assert "scanner_exit_code=125" in summary + assert "scanner_attempts=2" in summary + assert "scanner_error_category=network" in summary + assert "scanner_stderr_tail_lines=1" in summary + assert "scanner_stderr_sha256=" in summary + assert "SyntheticP4ss!" not in summary + assert "Basic dXNlcjpzZWNyZXQ=" not in summary + assert "https://user:secret@example.test" not in summary + + +def test_clean_scan_accepts_immutable_image_and_safe_headers(tmp_path: Path) -> None: + result = run_scan( + tmp_path, + fake_docker="""#!/usr/bin/env bash +exit 0 +""", + env_overrides={ + "DATABASE": "sqlite", + "SCENARIO_OUTCOME": "failure", + "TRUFFLEHOG_IMAGE": ( + "ghcr.io/trufflesecurity/trufflehog@sha256:" + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ), + }, + ) + + summary = (tmp_path / "diagnostics" / "summary.txt").read_text(encoding="utf-8") + assert result.returncode == 0 + assert "database=sqlite" in summary + assert "scenario_outcome=failure" in summary + assert "scanner_image=ghcr.io/trufflesecurity/trufflehog@sha256:" in summary + assert "scan_status=clean" in summary + + +def test_findings_redact_unsafe_detector_and_path_values(tmp_path: Path) -> None: + result = run_scan( + tmp_path, + fake_docker="""#!/usr/bin/env bash +printf '%s\n' '{"DetectorName":"{\\\"password\\\":\\\"SyntheticP4ss!\\\"}","SourceMetadata":{"Data":{"Filesystem":{"file":"/evidence/{\\\"password\\\":\\\"SyntheticP4ss!\\\"}"}}}}' +exit 183 +""", + ) + + summary = (tmp_path / "diagnostics" / "summary.txt").read_text(encoding="utf-8") + assert result.returncode == 1 + assert "scan_status=findings" in summary + assert "SyntheticP4ss!" not in summary + assert "password" not in summary + assert "finding=[REDACTED] path=[REDACTED]" in summary + + +def test_findings_redact_credential_shaped_paths(tmp_path: Path) -> None: + result = run_scan( + tmp_path, + fake_docker="""#!/usr/bin/env bash +printf '%s\n' '{"DetectorName":"GitHub Token","SourceMetadata":{"Data":{"Filesystem":{"file":"/evidence/ghp_abcdefghijklmnopqrstuvwxyz1234567890/replay.json"}}}}' +exit 183 +""", + ) + + summary = (tmp_path / "diagnostics" / "summary.txt").read_text(encoding="utf-8") + assert result.returncode == 1 + assert "scan_status=findings" in summary + assert "finding=GitHub Token path=[REDACTED]" in summary + assert "ghp_abcdefghijklmnopqrstuvwxyz1234567890" not in summary + assert "replay.json" not in summary + + +def test_findings_limit_applies_to_complete_jsonl_stream(tmp_path: Path) -> None: + result = run_scan( + tmp_path, + fake_docker="""#!/usr/bin/env bash +i=1 +while test "$i" -le 45; do + printf '{"DetectorName":"Generic Detector %02d","SourceMetadata":{"Data":{"Filesystem":{"file":"/evidence/path-%02d.json"}}}}\n' "$i" "$i" + i=$((i + 1)) +done +exit 183 +""", + ) + + summary = (tmp_path / "diagnostics" / "summary.txt").read_text(encoding="utf-8") + finding_lines = [line for line in summary.splitlines() if line.startswith("finding=")] + assert result.returncode == 1 + assert len(finding_lines) == 20 + assert "finding=Generic Detector 20 path=[REDACTED]" in summary + assert "Generic Detector 21" not in summary + + +def test_findings_redact_non_ascii_values(tmp_path: Path) -> None: + result = run_scan( + tmp_path, + fake_docker="""#!/usr/bin/env bash +printf '%s\n' '{"DetectorName":"泄漏检测器","SourceMetadata":{"Data":{"Filesystem":{"file":"/evidence/秘密.json"}}}}' +exit 183 +""", + ) + + summary = (tmp_path / "diagnostics" / "summary.txt").read_text(encoding="utf-8") + assert result.returncode == 1 + assert "泄漏检测器" not in summary + assert "秘密.json" not in summary + assert "finding=[REDACTED] path=[REDACTED]" in summary + + +def test_summary_redacts_untrusted_header_environment_values(tmp_path: Path) -> None: + marker = tmp_path / "docker-called" + result = run_scan( + tmp_path, + fake_docker="""#!/usr/bin/env bash +touch "$DOCKER_CALLED" +exit 183 +""", + env_overrides={ + "DATABASE": "sqlite\npassword=SyntheticP4ss!", + "SCENARIO_OUTCOME": "failure\nAuthorization: Basic dXNlcjpzZWNyZXQ=", + "TRUFFLEHOG_IMAGE": "https://user:secret@example.test/trufflehog@sha256:deadbeef", + "DOCKER_CALLED": str(marker), + }, + ) + + summary = (tmp_path / "diagnostics" / "summary.txt").read_text(encoding="utf-8") + assert result.returncode == 1 + assert "scanner_attempts=0" in summary + assert "scanner_error_category=configuration" in summary + assert not marker.exists() + assert "database=[REDACTED]" in summary + assert "scenario_outcome=[REDACTED]" in summary + assert "scanner_image=[REDACTED]" in summary + assert "SyntheticP4ss!" not in summary + assert "Basic dXNlcjpzZWNyZXQ=" not in summary + assert "https://user:secret@example.test" not in summary diff --git a/tests/test_workflow_actions.py b/tests/test_workflow_actions.py new file mode 100644 index 0000000000..7d1cc9b750 --- /dev/null +++ b/tests/test_workflow_actions.py @@ -0,0 +1,206 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).parents[1] +CHECKER = REPOSITORY_ROOT / "scripts" / "check_workflow_actions.py" + + +def run_checker(*paths: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(CHECKER), *(str(path) for path in paths)], + cwd=REPOSITORY_ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_workflow_action_checker_accepts_sha_pins_and_local_actions(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text( + """ +jobs: + check: + steps: + - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567 # v1 + - uses: ./.github/actions/setup-python-env +""".lstrip(), + encoding="utf-8", + ) + + result = run_checker(workflow) + + assert result.returncode == 0, result.stderr + assert "2 action references checked" in result.stdout + + +def test_workflow_action_checker_rejects_mutable_refs(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text( + """ +jobs: + check: + steps: + - uses: actions/checkout@v7 +""".lstrip(), + encoding="utf-8", + ) + + result = run_checker(workflow) + + assert result.returncode == 1 + assert "must use a 40-character commit SHA" in result.stderr + assert "checkout@v7" in result.stderr + + +def test_workflow_action_checker_parses_flow_style_and_quoted_references(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text( + """ +jobs: + check: + steps: + - {uses: actions/checkout@v7} + - "uses": actions/setup-python@v7 +""".lstrip(), + encoding="utf-8", + ) + + result = run_checker(workflow) + + assert result.returncode == 1 + assert "checkout@v7" in result.stderr + assert "setup-python@v7" in result.stderr + + +def test_workflow_action_checker_accepts_quoted_sha_and_local_references(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text( + """ +jobs: + check: + steps: + - uses: "actions/checkout@0123456789abcdef0123456789abcdef01234567" + - uses: "./.github/actions/setup-python-env" +""".lstrip(), + encoding="utf-8", + ) + + result = run_checker(workflow) + + assert result.returncode == 0, result.stderr + assert "2 action references checked" in result.stdout + + +def test_workflow_action_checker_rejects_malformed_yaml(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text("jobs:\n check: [password: SyntheticP4ss!\n", encoding="utf-8") + + result = run_checker(workflow) + + assert result.returncode == 1 + assert "could not parse" in result.stderr + assert "SyntheticP4ss!" not in result.stderr + + +def test_workflow_action_checker_redacts_unsafe_reference_diagnostics(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text( + """ +jobs: + check: + steps: + - uses: "https://user:secret@example.test/action@v1" +""".lstrip(), + encoding="utf-8", + ) + + result = run_checker(workflow) + + assert result.returncode == 1 + assert "must use a 40-character commit SHA" in result.stderr + assert "https://user:secret@example.test" not in result.stderr + assert "[REDACTED]" in result.stderr + + +def test_workflow_action_checker_rejects_duplicate_yaml_keys(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text( + """ +jobs: + check: + steps: + - uses: actions/checkout@v7 + uses: actions/checkout@0123456789abcdef0123456789abcdef01234567 +""".lstrip(), + encoding="utf-8", + ) + + result = run_checker(workflow) + + assert result.returncode == 1 + assert "could not parse YAML" in result.stderr + + +def test_workflow_action_checker_handles_recursive_yaml_alias(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text("loop: &loop [*loop]\n", encoding="utf-8") + + result = run_checker(workflow) + + assert result.returncode == 0 + assert "0 action references checked" in result.stdout + + +def test_workflow_action_checker_rejects_unhashable_yaml_keys(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text("? [a, b]\n: c\n", encoding="utf-8") + + result = run_checker(workflow) + + assert result.returncode == 1 + assert "could not parse" in result.stderr + assert "Traceback" not in result.stderr + + +def test_workflow_action_checker_scans_nested_composite_actions(tmp_path: Path) -> None: + workflow = tmp_path / "workflow.yml" + workflow.write_text( + "jobs:\n check:\n steps:\n - uses: ./.github/actions/nested\n", + encoding="utf-8", + ) + action = tmp_path / ".github" / "actions" / "nested" / "action.yml" + action.parent.mkdir(parents=True) + action.write_text( + "runs:\n using: composite\n steps:\n - uses: actions/setup-python@v7\n", + encoding="utf-8", + ) + + result = run_checker(tmp_path / ".github") + + assert result.returncode == 1 + assert "setup-python@v7" in result.stderr + + +def test_repository_workflow_actions_are_pinned() -> None: + result = run_checker(REPOSITORY_ROOT / ".github" / "workflows", REPOSITORY_ROOT / ".github" / "actions") + + assert result.returncode == 0, result.stderr + assert "action references checked" in result.stdout