diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index 267a96f..f6b5b46 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -37,6 +37,3 @@ jobs: if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) with: sarif_file: results.sarif - - name: Skip OSSF Scorecard on non-default branch - if: github.ref != format('refs/heads/{0}', github.event.repository.default_branch) - run: echo "OSSF Scorecard only supports the default branch; skipped for ${GITHUB_REF}." diff --git a/docs/agents/skills/bandscope-supply-chain-warning-remediation/SKILL.md b/docs/agents/skills/bandscope-supply-chain-warning-remediation/SKILL.md new file mode 100644 index 0000000..cca21a6 --- /dev/null +++ b/docs/agents/skills/bandscope-supply-chain-warning-remediation/SKILL.md @@ -0,0 +1,75 @@ +--- +name: bandscope-supply-chain-warning-remediation +description: Use when BandScope verification, CI, GitHub Actions, Dependabot, OSSF Scorecard, cargo audit, npm audit, CodeQL, security gates, or PR review emits warnings, deprecations, notices, or supply-chain failures. +--- + +# BandScope Supply-Chain Warning Remediation + +## Overview + +Treat every supply-chain warning as evidence to classify, fix, or track. The goal is clean verification without weakening BandScope security gates or hiding externally owned risk. + +## Workflow + +1. Capture the exact warning or failure text, command, working directory, commit SHA, tool version, and whether it appeared locally, in CI, or on a PR. +2. Classify the source: repo code, workflow shape, direct dependency, transitive dependency, scanner/platform limit, or stale issue metadata. +3. Trace the direct owner with structured tooling: + - GitHub Actions: `gh run view --log-failed` + - Dependabot: `gh api repos/seonghobae/bandscope/dependabot/alerts/` + - Rust/Tauri: `cargo tree -i --manifest-path apps/desktop/src-tauri/Cargo.toml` + - npm: `npm explain ` + - Python: `uv tree --project services/analysis-engine --package ` +4. Add a failing regression guard first when repo code can prevent recurrence. +5. Fix the root cause. Do not use broad log filtering, generic quiet flags, or gate removal. +6. If no maintained fix exists, document the owner chain and create or link a follow-up issue with acceptance criteria and Security Notes. +7. Re-run the original warning command plus the smallest relevant policy/test command. +8. For PR review warnings, push the fix and re-check robot review/check evidence instead of dismissing the review. + +## BandScope Rules + +- Preserve required checks from `docs/security/github-required-checks.md`. +- Do not disable or downgrade SBOM, dependency review, CodeQL, Trivy, Bandit, secret scanning, OSSF Scorecard, Windows build, or macOS build gates. +- OSSF Scorecard publishing jobs must contain only `uses:` steps. Remove shell diagnostics from the publishing job or move them to a separate non-publishing job. +- Direct dependency changes require lockfile updates and the dependency admission rationale defined in `docs/security/dependency-policy.md`. +- For transitive Rust/Tauri vulnerabilities, prefer minimal lockfile updates. If blocked upstream, record the exact crate chain and patched-version status. +- Treat `+deprecated` Cargo version metadata as a tracked dependency signal, not automatically as a compiler warning. +- Every supply-chain PR or issue update must include Security Notes. + +## Verification Commands + +Run the narrowest command first, then widen as needed: + +- `python3 scripts/checks/verify_supply_chain.py` +- `python3 scripts/checks/security_gates.py` +- `uv run --project services/analysis-engine pytest services/analysis-engine/tests/test_supply_chain_policy.py` +- `npm audit --workspaces --audit-level=high` +- `BANDSCOPE_ENABLE_RUST_CHECK=1 ./scripts/harness/quickcheck.sh` +- `cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked` when Rust changes are in scope + +## Issue Template + +When a warning remains externally owned, update or create an issue with: + +- Current state and exact evidence link +- Root cause and owner chain +- Repo-controlled actions already attempted +- Next maintained fix path or upstream dependency +- Acceptance criteria +- Security Notes covering risk, gate impact, logging/privacy, and test points + +## Common Mistakes + +| Mistake | Fix | +| --- | --- | +| Adding `grep -v`, `2>/dev/null`, or global quiet flags | Fix the source or add a narrow documented allowance. | +| Treating default-branch CI failure as blocked | Fix repo-controlled workflow failures unless auth, network, permission, or platform capability blocks it. | +| Removing security workflows to unblock a PR | Preserve gates and fix their inputs or job shape. | +| Closing stale warning issues without evidence | Link the passing command, PR, commit, or successor issue. | + +## Done Criteria + +- Original warning command re-run. +- Output is clean, or residual risk has an owner and linked follow-up issue. +- Regression guard covers repo-controlled recurrence. +- Required checks remain intact. +- PR/issue comments include durable evidence and Security Notes. diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index 5a4715d..f6a2822 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -39,6 +39,10 @@ "publish_results: ${{ github.ref == format('refs/heads/{0}', " "github.event.repository.default_branch) }}" ) +OSSF_PUBLISH_USES_ONLY_VIOLATION = ( + "ossf scorecard publishing job must only contain uses steps; split run steps " + "into a separate non-publishing job" +) RELEASE_ARTIFACT_GLOB = re.compile(r"(?:^|\s)artifacts/\*") RELEASE_ASSET_VALIDATOR = ( "scripts/release/select_release_assets.py --output release-assets.txt" @@ -248,6 +252,60 @@ def read_workflow(path: Path, label: str, missing: list[str]) -> str: return path.read_text(encoding="utf-8") +def ossf_scorecard_publish_restriction_violations( + content: str, path: Path | None = None +) -> list[str]: + """Return OSSF publishing job violations that GitHub cannot publish.""" + violations: list[str] = [] + current_job_lines: list[str] = [] + current_job_start_line = 0 + in_jobs = False + + def evaluate_job(job_lines: list[str], start_line: int) -> None: + if not job_lines: + return + job_content = "\n".join(job_lines) + if "ossf/scorecard-action" not in job_content: + return + if "publish_results:" not in job_content: + return + has_run_step = any( + stripped.startswith("run:") or re.match(r"^-\s+run:", stripped) + for stripped in (line.strip() for line in job_lines) + ) + if has_run_step: + if path is None: + violations.append(OSSF_PUBLISH_USES_ONLY_VIOLATION) + else: + violations.append( + f"{path}:{start_line or 1} -> {OSSF_PUBLISH_USES_ONLY_VIOLATION}" + ) + + for idx, line in enumerate(content.splitlines(), start=1): + indent = len(line) - len(line.lstrip(" ")) + stripped = line.strip() + if indent == 0 and stripped == "jobs:": + in_jobs = True + continue + if not in_jobs: + continue + if indent == 0 and stripped: + evaluate_job(current_job_lines, current_job_start_line) + current_job_lines = [] + current_job_start_line = 0 + in_jobs = False + continue + if indent == 2 and stripped.endswith(":") and not stripped.startswith("-"): + evaluate_job(current_job_lines, current_job_start_line) + current_job_lines = [line] + current_job_start_line = idx + continue + current_job_lines.append(line) + + evaluate_job(current_job_lines, current_job_start_line) + return violations + + def verify_workflow_coverage() -> list[str]: """Return workflow trigger and artifact coverage violations.""" missing: list[str] = [] @@ -351,6 +409,15 @@ def verify_workflow_coverage() -> list[str]: missing.append( "ossf scorecard publish_results must use the repository default branch guard" ) + workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted( + Path(".github/workflows").glob("*.yaml") + ) + for workflow_path in workflow_paths: + missing.extend( + ossf_scorecard_publish_restriction_violations( + workflow_path.read_text(encoding="utf-8"), workflow_path + ) + ) return missing diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index defa42f..77ba26b 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -207,6 +207,139 @@ def test_supply_chain_check_rejects_hardcoded_ossf_publish_results_branch( ) +def test_supply_chain_check_rejects_ossf_publish_job_run_steps( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure Scorecard publishing jobs satisfy OSSF uses-only restrictions.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_ossf_uses_only" + ) + publish_guard = supply_chain.OSSF_DEFAULT_BRANCH_PUBLISH_GUARD.partition(": ")[2] + default_branch_ref = "format('refs/heads/{0}', github.event.repository.default_branch)" + scorecard_action = ( + " - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3" + ) + + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ossf-scorecard.yml").write_text( + "\n".join( + [ + "name: ossf-scorecard", + "on:", + " push:", + " branches:", + " - develop", + " - main", + " schedule:", + " - cron: '30 1 * * 1'", + "jobs:", + " analysis:", + " name: ossf-scorecard", + " steps:", + " - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2", + scorecard_action, + f" if: github.ref == {default_branch_ref}", + " with:", + f" publish_results: {publish_guard}", + " - name: Skip OSSF Scorecard on non-default branch", + f" if: github.ref != {default_branch_ref}", + ' run: echo "skip"', + ] + ), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_workflow_coverage() + + assert any( + "ossf scorecard publishing job must only contain uses steps; split run steps " + "into a separate non-publishing job" in violation + for violation in violations + ) + + +def test_supply_chain_check_rejects_ossf_publish_run_steps_in_any_workflow( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure OSSF publishing restrictions follow Scorecard if it moves workflows.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_ossf_any_workflow" + ) + publish_guard = supply_chain.OSSF_DEFAULT_BRANCH_PUBLISH_GUARD.partition(": ")[2] + default_branch_ref = "format('refs/heads/{0}', github.event.repository.default_branch)" + scorecard_action = ( + " - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3" + ) + + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ossf-scorecard.yml").write_text( + "\n".join( + [ + "name: ossf-scorecard", + "on: push", + "jobs:", + " analysis:", + " name: ossf-scorecard", + " steps:", + " - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2", + scorecard_action, + f" if: github.ref == {default_branch_ref}", + " with:", + f" publish_results: {publish_guard}", + ] + ), + encoding="utf-8", + ) + (workflow_dir / "scorecard-security-gate.yml").write_text( + "\n".join( + [ + "name: scorecard-security-gate", + "on: push", + "jobs:", + " moved-scorecard:", + " steps:", + scorecard_action, + f" if: github.ref == {default_branch_ref}", + " with:", + f" publish_results: {publish_guard}", + " - name: extra diagnostics", + ' run: echo "this breaks OSSF publishing"', + ] + ), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_workflow_coverage() + + assert any( + violation.startswith(".github/workflows/scorecard-security-gate.yml:") + and "ossf scorecard publishing job must only contain uses steps" in violation + for violation in violations + ) + + +def test_supply_chain_check_accepts_repo_ossf_publish_restrictions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ensure checked-in OSSF Scorecard workflow follows publish restrictions.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_ossf_repo" + ) + repo_root = Path(__file__).resolve().parents[3] + + monkeypatch.chdir(repo_root) + + violations = supply_chain.verify_workflow_coverage() + + assert not any("ossf scorecard" in violation for violation in violations) + + def test_supply_chain_check_rejects_release_published_asset_upload( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: