From f7ab0e2eee64f4e06d3ae1c05c7e5756f9c864d0 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Mon, 14 Sep 2026 11:46:40 +0800 Subject: [PATCH 1/5] ci: improve evidence scan diagnostics and action pinning Lore: Make replay-scan failures distinguish secret findings from infrastructure errors while keeping raw scanner output out of uploaded artifacts. Constraint: Preserve the network-disabled, read-only evidence scan and enforce immutable third-party Action references across workflow and composite-action files. Tested: 57 focused and contract tests passed; action pin scan, Ruff, shell syntax, YAML parsing, and diff checks passed. Not-tested: Full make check remains blocked by pre-existing ty errors in artifact_processing.py and test_artifact_processing.py. Co-authored-by: OmX --- .github/dependabot.yml | 6 ++ .github/workflows/e2e-harness.yml | 65 ++------------ Makefile | 6 +- scripts/check_workflow_actions.py | 75 ++++++++++++++++ scripts/scan_e2e_evidence.sh | 135 ++++++++++++++++++++++++++++ tests/test_e2e_workflow_contract.py | 44 +++++++++ tests/test_scan_e2e_evidence.py | 111 +++++++++++++++++++++++ tests/test_workflow_actions.py | 95 ++++++++++++++++++++ 8 files changed, 477 insertions(+), 60 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 scripts/check_workflow_actions.py create mode 100644 scripts/scan_e2e_evidence.sh create mode 100644 tests/test_e2e_workflow_contract.py create mode 100644 tests/test_scan_e2e_evidence.py create mode 100644 tests/test_workflow_actions.py 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..7e24ad5acd --- /dev/null +++ b/scripts/check_workflow_actions.py @@ -0,0 +1,75 @@ +#!/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 pathlib import Path + +ACTION_REFERENCE = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)", re.MULTILINE) +COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) +SUPPORTED_SUFFIXES = {".yaml", ".yml"} + + +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 main(arguments: list[str]) -> int: + references_checked = 0 + violations: list[str] = [] + for path in _files_to_scan(arguments or [".github/workflows", ".github/actions"]): + text = path.read_text(encoding="utf-8") + for match in ACTION_REFERENCE.finditer(text): + references_checked += 1 + reference = match.group(1) + line_number = text.count("\n", 0, match.start()) + 1 + if reference.startswith("./") or reference.startswith("../"): + continue + if "@" not in reference or not COMMIT_SHA.fullmatch(reference.rsplit("@", 1)[1]): + violations.append( + f"{path}:{line_number}: uses reference '{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..502c96ed6d --- /dev/null +++ b/scripts/scan_e2e_evidence.sh @@ -0,0 +1,135 @@ +#!/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" +{ + 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}" + +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 $? +} + +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 + jq -r ' + select(.DetectorName? != null) + | [ + (.DetectorName | tostring | .[0:120]), + ((.SourceMetadata.Data.Filesystem.file // .SourceMetadata.Data.Filesystem.path // "unknown") + | tostring + | sub("^/evidence/?"; "") + | .[0:240]) + ] + | @tsv + ' "${scanner_json}" 2>/dev/null \ + | head -n 20 \ + | sed $'s/^/finding=/; s/\t/ path=/' \ + | sed -E \ + -e 's/(Authorization:[[:space:]]*Bearer[[:space:]]+)[^[:space:]]+/\1[REDACTED]/Ig' \ + -e 's/((token|secret|password|api[_-]?key)[=:][[:space:]]*)[^[:space:]]+/\1[REDACTED]/Ig' \ + -e 's/[A-Za-z0-9+\/_=-]{32,}/[REDACTED]/g' >> "${summary}" || 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}" + { + echo "scanner_stderr_tail_begin" + tail -n 50 "${scanner_stderr}" \ + | tr -cd '\11\12\15\40-\176' \ + | sed -E \ + -e 's/(Authorization:[[:space:]]*Bearer[[:space:]]+)[^[:space:]]+/\1[REDACTED]/Ig' \ + -e 's/((token|secret|password|api[_-]?key)[=:][[:space:]]*)[^[:space:]]+/\1[REDACTED]/Ig' \ + -e 's/[A-Za-z0-9+\/_=-]{32,}/[REDACTED]/g' + echo "scanner_stderr_tail_end" + } >> "${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..fa9672ba58 --- /dev/null +++ b/tests/test_scan_e2e_evidence.py @@ -0,0 +1,111 @@ +# 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) -> 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:deadbeef", + } + 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=replay.json" 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 token=super-secret-value' >&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 "token=[REDACTED]" in summary + assert "super-secret-value" not in summary + assert "scanner_stderr_tail_begin" in summary + assert "scanner_stderr_tail_end" in summary diff --git a/tests/test_workflow_actions.py b/tests/test_workflow_actions.py new file mode 100644 index 0000000000..52f5384e12 --- /dev/null +++ b/tests/test_workflow_actions.py @@ -0,0 +1,95 @@ +# 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 + + +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 From 5aef900218b705fd0f7fcbb0abb7b000bef9bc25 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Tue, 15 Sep 2026 00:36:12 +0800 Subject: [PATCH 2/5] ci: harden diagnostic redaction and YAML action checks Lore: Harden uploaded E2E diagnostics against arbitrary scanner text and parse YAML action references semantically. Constraint: Keep raw scanner JSON and stderr in runner-local files while exposing only bounded, fixed diagnostics and requiring 40-character third-party action pins. Tested: uv run --locked python -m pytest tests/test_scan_e2e_evidence.py tests/test_workflow_actions.py tests/test_e2e_workflow_contract.py; uv run --locked ruff check; uv run --locked ruff format --check; make contract-test; bash -n scripts/scan_e2e_evidence.sh; uv run --locked python scripts/check_workflow_actions.py .github/workflows .github/actions; git diff --check. Not-tested: make workflow-actions-check reaches the repository's pre-existing ty baseline errors in artifact_processing.py and its tests. Co-authored-by: OmX --- scripts/check_workflow_actions.py | 31 ++++++-- scripts/scan_e2e_evidence.sh | 125 +++++++++++++++++++++++------- tests/test_scan_e2e_evidence.py | 29 +++++-- tests/test_workflow_actions.py | 49 ++++++++++++ 4 files changed, 194 insertions(+), 40 deletions(-) diff --git a/scripts/check_workflow_actions.py b/scripts/check_workflow_actions.py index 7e24ad5acd..9dd65cfa6a 100644 --- a/scripts/check_workflow_actions.py +++ b/scripts/check_workflow_actions.py @@ -21,7 +21,8 @@ import sys from pathlib import Path -ACTION_REFERENCE = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)", re.MULTILINE) +import yaml + COMMIT_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) SUPPORTED_SUFFIXES = {".yaml", ".yml"} @@ -48,21 +49,35 @@ def _files_to_scan(arguments: list[str]) -> list[Path]: return sorted(set(files), key=lambda path: str(path).lower()) +def _action_references(value: object) -> list[str]: + references: list[str] = [] + if isinstance(value, dict): + for key, child in value.items(): + if key == "uses": + references.append(child if isinstance(child, str) else "") + else: + references.extend(_action_references(child)) + elif isinstance(value, list): + for child in value: + references.extend(_action_references(child)) + return references + + def main(arguments: list[str]) -> int: references_checked = 0 violations: list[str] = [] for path in _files_to_scan(arguments or [".github/workflows", ".github/actions"]): - text = path.read_text(encoding="utf-8") - for match in ACTION_REFERENCE.finditer(text): + try: + document = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as error: + print(f"{path}: could not parse YAML: {error}", file=sys.stderr) + return 1 + for reference in _action_references(document): references_checked += 1 - reference = match.group(1) - line_number = text.count("\n", 0, match.start()) + 1 if reference.startswith("./") or reference.startswith("../"): continue if "@" not in reference or not COMMIT_SHA.fullmatch(reference.rsplit("@", 1)[1]): - violations.append( - f"{path}:{line_number}: uses reference '{reference}' must use a 40-character commit SHA" - ) + violations.append(f"{path}: uses reference '{reference}' must use a 40-character commit SHA") if violations: print("\n".join(violations), file=sys.stderr) diff --git a/scripts/scan_e2e_evidence.sh b/scripts/scan_e2e_evidence.sh index 502c96ed6d..dfefa7ccfb 100644 --- a/scripts/scan_e2e_evidence.sh +++ b/scripts/scan_e2e_evidence.sh @@ -67,6 +67,77 @@ run_scan() { --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 + ""|[![:alnum:]]*) ;; + *) + if test -z "$(printf '%s' "${value}" | tr -d '[:alnum:] _./-')"; 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" + local lower_value="${value,,}" + if [[ -z "${value}" ]]; then + printf 'unknown' + elif test "${#value}" -le 240; then + case "${value}" in + [![:alnum:]]*|*..*) ;; + *) + if test -z "$(printf '%s' "${value}" | tr -d '[:alnum:]_.\/-')"; 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]' +} + +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 @@ -91,23 +162,26 @@ 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 - jq -r ' - select(.DetectorName? != null) - | [ - (.DetectorName | tostring | .[0:120]), - ((.SourceMetadata.Data.Filesystem.file // .SourceMetadata.Data.Filesystem.path // "unknown") - | tostring - | sub("^/evidence/?"; "") - | .[0:240]) - ] - | @tsv - ' "${scanner_json}" 2>/dev/null \ - | head -n 20 \ - | sed $'s/^/finding=/; s/\t/ path=/' \ - | sed -E \ - -e 's/(Authorization:[[:space:]]*Bearer[[:space:]]+)[^[:space:]]+/\1[REDACTED]/Ig' \ - -e 's/((token|secret|password|api[_-]?key)[=:][[:space:]]*)[^[:space:]]+/\1[REDACTED]/Ig' \ - -e 's/[A-Za-z0-9+\/_=-]{32,}/[REDACTED]/g' >> "${summary}" || true + 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 -r ' + limit(20; + 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 @@ -118,16 +192,13 @@ fi if test "${scanner_status}" -ne 0; then echo "scan_status=infra_error" >> "${summary}" echo "scanner_exit_code=${scanner_status}" >> "${summary}" - { - echo "scanner_stderr_tail_begin" - tail -n 50 "${scanner_stderr}" \ - | tr -cd '\11\12\15\40-\176' \ - | sed -E \ - -e 's/(Authorization:[[:space:]]*Bearer[[:space:]]+)[^[:space:]]+/\1[REDACTED]/Ig' \ - -e 's/((token|secret|password|api[_-]?key)[=:][[:space:]]*)[^[:space:]]+/\1[REDACTED]/Ig' \ - -e 's/[A-Za-z0-9+\/_=-]{32,}/[REDACTED]/g' - echo "scanner_stderr_tail_end" - } >> "${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 diff --git a/tests/test_scan_e2e_evidence.py b/tests/test_scan_e2e_evidence.py index fa9672ba58..5000a54198 100644 --- a/tests/test_scan_e2e_evidence.py +++ b/tests/test_scan_e2e_evidence.py @@ -95,7 +95,7 @@ def test_infrastructure_failure_retries_once_and_keeps_only_redacted_tail(tmp_pa result = run_scan( tmp_path, fake_docker="""#!/usr/bin/env bash -printf '%s\n' 'network token=super-secret-value' >&2 +printf '%s\n' 'network timeout: {"password":"SyntheticP4ss!"} Authorization: Basic dXNlcjpzZWNyZXQ= https://user:secret@example.test' >&2 exit 125 """, ) @@ -105,7 +105,26 @@ def test_infrastructure_failure_retries_once_and_keeps_only_redacted_tail(tmp_pa assert "scan_status=infra_error" in summary assert "scanner_exit_code=125" in summary assert "scanner_attempts=2" in summary - assert "token=[REDACTED]" in summary - assert "super-secret-value" not in summary - assert "scanner_stderr_tail_begin" in summary - assert "scanner_stderr_tail_end" 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_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 diff --git a/tests/test_workflow_actions.py b/tests/test_workflow_actions.py index 52f5384e12..685c83a01b 100644 --- a/tests/test_workflow_actions.py +++ b/tests/test_workflow_actions.py @@ -69,6 +69,55 @@ def test_workflow_action_checker_rejects_mutable_refs(tmp_path: Path) -> None: assert "must use a 40-character commit SHA" 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:\n steps: [\n", encoding="utf-8") + + result = run_checker(workflow) + + assert result.returncode == 1 + assert "could not parse" in result.stderr + + def test_workflow_action_checker_scans_nested_composite_actions(tmp_path: Path) -> None: workflow = tmp_path / "workflow.yml" workflow.write_text( From 5c250c6c6d17ec6b7ff73a4cfa0e6df1687bc483 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Tue, 15 Sep 2026 07:31:15 +0800 Subject: [PATCH 3/5] ci: harden scanner inputs and YAML validation Lore: Keep diagnostic headers and scanner inputs bounded, and make YAML action validation deterministic under malformed or aliased documents. Constraint: Raw scanner output stays runner-local; uploaded diagnostics contain only fixed fields, immutable image references, and allowlisted findings. Tested: uv run --locked python -m pytest tests/test_scan_e2e_evidence.py tests/test_workflow_actions.py tests/test_e2e_workflow_contract.py; uv run --locked ruff check scripts/check_workflow_actions.py tests/test_scan_e2e_evidence.py tests/test_workflow_actions.py; uv run --locked ruff format --check scripts/check_workflow_actions.py tests/test_scan_e2e_evidence.py tests/test_workflow_actions.py; bash -n scripts/scan_e2e_evidence.sh; uv run --locked python scripts/check_workflow_actions.py .github/workflows .github/actions; make contract-test; git diff --check. Not-tested: make workflow-actions-check remains blocked only by the repository's pre-existing ty diagnostics in artifact_processing.py and its tests. Co-authored-by: OmX --- scripts/check_workflow_actions.py | 78 +++++++++++++++++++++++++----- scripts/scan_e2e_evidence.sh | 49 ++++++++++++++++--- tests/test_scan_e2e_evidence.py | 79 ++++++++++++++++++++++++++++++- tests/test_workflow_actions.py | 64 ++++++++++++++++++++++++- 4 files changed, 247 insertions(+), 23 deletions(-) diff --git a/scripts/check_workflow_actions.py b/scripts/check_workflow_actions.py index 9dd65cfa6a..4f6fe4ba57 100644 --- a/scripts/check_workflow_actions.py +++ b/scripts/check_workflow_actions.py @@ -19,6 +19,7 @@ import re import sys +from collections import deque from pathlib import Path import yaml @@ -27,6 +28,38 @@ 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(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping) + + def _files_to_scan(arguments: list[str]) -> list[Path]: files: list[Path] = [] for argument in arguments: @@ -51,33 +84,52 @@ def _files_to_scan(arguments: list[str]) -> list[Path]: def _action_references(value: object) -> list[str]: references: list[str] = [] - if isinstance(value, dict): - for key, child in value.items(): - if key == "uses": - references.append(child if isinstance(child, str) else "") - else: - references.extend(_action_references(child)) - elif isinstance(value, list): - for child in value: - references.extend(_action_references(child)) + 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.safe_load(path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, yaml.YAMLError) as error: - print(f"{path}: could not parse YAML: {error}", file=sys.stderr) + 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]): - violations.append(f"{path}: uses reference '{reference}' must use a 40-character commit SHA") + 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) diff --git a/scripts/scan_e2e_evidence.sh b/scripts/scan_e2e_evidence.sh index dfefa7ccfb..f89fa725d5 100644 --- a/scripts/scan_e2e_evidence.sh +++ b/scripts/scan_e2e_evidence.sh @@ -27,13 +27,48 @@ 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=${DATABASE}" + echo "database=$(sanitize_database "${DATABASE}")" echo "diagnostic_format=powercontext-e2e-v1" - echo "scenario_outcome=${SCENARIO_OUTCOME}" - echo "scanner_image=${TRUFFLEHOG_IMAGE}" + 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 @@ -72,9 +107,9 @@ sanitize_detector() { local lower_value="${value,,}" if test "${#value}" -le 120; then case "${value}" in - ""|[![:alnum:]]*) ;; + ""|[!A-Za-z0-9]*) ;; *) - if test -z "$(printf '%s' "${value}" | tr -d '[:alnum:] _./-')"; then + 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*|*://*|*@*|*=*) ;; @@ -97,9 +132,9 @@ sanitize_path() { printf 'unknown' elif test "${#value}" -le 240; then case "${value}" in - [![:alnum:]]*|*..*) ;; + [!A-Za-z0-9]*|*..*) ;; *) - if test -z "$(printf '%s' "${value}" | tr -d '[:alnum:]_.\/-')"; then + 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*|*://*|*@*|*=*) ;; diff --git a/tests/test_scan_e2e_evidence.py b/tests/test_scan_e2e_evidence.py index 5000a54198..619cc8a76d 100644 --- a/tests/test_scan_e2e_evidence.py +++ b/tests/test_scan_e2e_evidence.py @@ -23,7 +23,9 @@ SCRIPT = REPOSITORY_ROOT / "scripts" / "scan_e2e_evidence.sh" -def run_scan(tmp_path: Path, *, fake_docker: str) -> subprocess.CompletedProcess[str]: +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) @@ -38,8 +40,12 @@ def run_scan(tmp_path: Path, *, fake_docker: str) -> subprocess.CompletedProcess "DIAGNOSTICS_PATH": str(diagnostics), "EVIDENCE_PATH": str(evidence), "SCENARIO_OUTCOME": "failure", - "TRUFFLEHOG_IMAGE": "example/trufflehog@sha256:deadbeef", + "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)] @@ -113,6 +119,30 @@ def test_infrastructure_failure_retries_once_and_keeps_only_redacted_tail(tmp_pa 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, @@ -128,3 +158,48 @@ def test_findings_redact_unsafe_detector_and_path_values(tmp_path: Path) -> None assert "SyntheticP4ss!" not in summary assert "password" not in summary assert "finding=[REDACTED] path=[REDACTED]" 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 index 685c83a01b..7d1cc9b750 100644 --- a/tests/test_workflow_actions.py +++ b/tests/test_workflow_actions.py @@ -67,6 +67,7 @@ def test_workflow_action_checker_rejects_mutable_refs(tmp_path: Path) -> None: 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: @@ -110,12 +111,73 @@ def test_workflow_action_checker_accepts_quoted_sha_and_local_references(tmp_pat def test_workflow_action_checker_rejects_malformed_yaml(tmp_path: Path) -> None: workflow = tmp_path / "workflow.yml" - workflow.write_text("jobs:\n check:\n steps: [\n", encoding="utf-8") + 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: From fc606e55f16fcb3e47b38ca6586d95c84c68f43b Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Tue, 15 Sep 2026 07:34:57 +0800 Subject: [PATCH 4/5] ci: harden scanner inputs and YAML validation Lore: Keep diagnostic headers and scanner inputs bounded, and make YAML action validation deterministic under malformed or aliased documents. Constraint: Raw scanner output stays runner-local; uploaded diagnostics contain only fixed fields, immutable image references, and allowlisted findings. Tested: uv run --locked python -m pytest tests/test_scan_e2e_evidence.py tests/test_workflow_actions.py tests/test_e2e_workflow_contract.py; uv run --locked ruff check scripts/check_workflow_actions.py tests/test_scan_e2e_evidence.py tests/test_workflow_actions.py; uv run --locked ruff format --check scripts/check_workflow_actions.py tests/test_scan_e2e_evidence.py tests/test_workflow_actions.py; bash -n scripts/scan_e2e_evidence.sh; uv run --locked python scripts/check_workflow_actions.py .github/workflows .github/actions; make contract-test; git diff --check. Not-tested: make check remains blocked only by the repository's pre-existing ty diagnostics in artifact_processing.py and its tests; the new checker warning was fixed by explicit BaseResolver import. Co-authored-by: OmX --- scripts/check_workflow_actions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check_workflow_actions.py b/scripts/check_workflow_actions.py index 4f6fe4ba57..29cc9220c2 100644 --- a/scripts/check_workflow_actions.py +++ b/scripts/check_workflow_actions.py @@ -23,6 +23,7 @@ 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"} @@ -57,7 +58,7 @@ def _construct_unique_mapping( return mapping -_UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping) +_UniqueKeyLoader.add_constructor(BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping) def _files_to_scan(arguments: list[str]) -> list[Path]: From 5598e4d3f59307a8414831695127525694755705 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Tue, 15 Sep 2026 15:48:01 +0800 Subject: [PATCH 5/5] fix(ci): bound scanner finding diagnostics --- scripts/scan_e2e_evidence.sh | 25 ++++---------------- tests/test_scan_e2e_evidence.py | 41 ++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/scripts/scan_e2e_evidence.sh b/scripts/scan_e2e_evidence.sh index f89fa725d5..84529e05c0 100644 --- a/scripts/scan_e2e_evidence.sh +++ b/scripts/scan_e2e_evidence.sh @@ -127,27 +127,11 @@ sanitize_detector() { sanitize_path() { local value="$1" - local lower_value="${value,,}" if [[ -z "${value}" ]]; then printf 'unknown' - elif test "${#value}" -le 240; 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 + else + printf '[REDACTED]' fi - printf '[REDACTED]' } file_sha256() { @@ -204,9 +188,10 @@ if test "${scanner_status}" -eq 183; then "$(sanitize_detector "${detector}")" \ "$(sanitize_path "${path}")" >> "${summary}" done < <( - jq -r ' + jq -nr ' limit(20; - select(.DetectorName? != null) + inputs + | select(.DetectorName? != null) | [ (.DetectorName | tostring), ((.SourceMetadata.Data.Filesystem.file // .SourceMetadata.Data.Filesystem.path // "unknown") diff --git a/tests/test_scan_e2e_evidence.py b/tests/test_scan_e2e_evidence.py index 619cc8a76d..b67177742e 100644 --- a/tests/test_scan_e2e_evidence.py +++ b/tests/test_scan_e2e_evidence.py @@ -92,7 +92,8 @@ def test_findings_use_exit_183_and_only_safe_fields_reach_summary(tmp_path: Path assert result.returncode == 1 assert "scan_status=findings" in summary assert "scanner_exit_code=183" in summary - assert "finding=GitHub Token path=replay.json" 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 @@ -160,6 +161,44 @@ def test_findings_redact_unsafe_detector_and_path_values(tmp_path: Path) -> None 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,