diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index c8579fa..bae2046 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -13,6 +13,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: bandit-scan: name: Bandit Security Scan diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 70591d7..aec39ae 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -15,6 +15,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: build-windows-native: name: build / windows / amd64 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c4d0ff..0664304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: verify: name: ci / build-and-test diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8b3ddf9..b62d3c2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,6 +14,11 @@ permissions: actions: read contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: analyze: name: codeql diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 593e0ec..49b5a54 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -9,6 +9,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: dependency-review: name: dependency-review diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index f3f9270..6b7b2d7 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -10,6 +10,11 @@ on: permissions: read-all +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: analysis: name: ossf-scorecard diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29e0e9e..42786fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: release-preflight: name: release-preflight diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 3de9210..31fd4bd 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -18,6 +18,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: supplemental-inventory: name: supply-chain-inventory diff --git a/.github/workflows/secret-scan-gate.yml b/.github/workflows/secret-scan-gate.yml index 67d4b80..b06a6ab 100644 --- a/.github/workflows/secret-scan-gate.yml +++ b/.github/workflows/secret-scan-gate.yml @@ -13,6 +13,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: secret-scan: name: secret-scan-gate diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index c2dbae7..cadafe0 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -13,6 +13,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: audit: name: security-audit diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 6b468b7..459eeb8 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -13,6 +13,11 @@ on: permissions: contents: read +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + jobs: trivy-fs-scan: name: trivy-fs-scan diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index 5b1e72b..4694fd0 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -53,6 +53,15 @@ "release artifact download must use skip-decompress: true and " "repo-owned extraction before asset validation" ) +CHECKOUT_DEFAULT_BRANCH_GUARD_VIOLATION = ( + "workflows using actions/checkout must set workflow-level " + "GIT_CONFIG_* init.defaultBranch env to avoid Git initial-branch warnings" +) +CHECKOUT_DEFAULT_BRANCH_GUARD_ENV = { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "init.defaultBranch", + "GIT_CONFIG_VALUE_0": "develop", +} OSSF_ARTIFACT_EXTRACTOR = "scripts/checks/extract_scorecard_artifact.py" RELEASE_ARTIFACT_EXTRACTOR = "scripts/release/extract_release_artifacts.py" OSSF_SARIF_NORMALIZER = "scripts/checks/normalize_scorecard_sarif.py" @@ -389,6 +398,67 @@ def verify_pinned_actions() -> list[str]: return violations +def workflow_top_level_env(content: str) -> dict[str, str]: + """Return the simple top-level env mapping from a GitHub Actions workflow.""" + env: dict[str, str] = {} + lines = content.splitlines() + for index, line in enumerate(lines): + line_without_comment = line.partition("#")[0].rstrip() + if line_without_comment != "env:": + continue + child_indent: int | None = None + for env_line in lines[index + 1 :]: + env_line_without_comment = env_line.partition("#")[0].rstrip() + if not env_line_without_comment.strip(): + continue + indent = len(env_line_without_comment) - len( + env_line_without_comment.lstrip(" ") + ) + if indent == 0: + break + if child_indent is None: + child_indent = indent + if indent != child_indent: + continue + match = re.match( + r"^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$", + env_line_without_comment, + ) + if match is None: + continue + value = match.group(2).strip().strip('"\'') + env[match.group(1)] = value + break + return env + + +def verify_checkout_default_branch_guard() -> list[str]: + """Return checkout workflows missing the Git default-branch warning guard.""" + violations: list[str] = [] + checkout_uses_pattern = re.compile( + r"^\s*-?\s*uses:\s*(?:[\"'])?actions/checkout@" + ) + workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted( + Path(".github/workflows").glob("*.yaml") + ) + for path in workflow_paths: + content = path.read_text(encoding="utf-8") + has_checkout = any( + checkout_uses_pattern.search(line.partition("#")[0]) + for line in content.splitlines() + ) + if not has_checkout: + continue + env = workflow_top_level_env(content) + if all( + env.get(key) == value + for key, value in CHECKOUT_DEFAULT_BRANCH_GUARD_ENV.items() + ): + continue + violations.append(f"{path}: {CHECKOUT_DEFAULT_BRANCH_GUARD_VIOLATION}") + return violations + + def verify_dependabot_coverage() -> list[str]: """Return missing Dependabot ecosystems from the repo configuration.""" path = Path(".github/dependabot.yml") @@ -1546,6 +1616,7 @@ def main() -> int: violations: list[str] = [] violations.extend(f"missing file: {item}" for item in verify_required_files()) violations.extend(verify_pinned_actions()) + violations.extend(verify_checkout_default_branch_guard()) violations.extend(verify_dependabot_coverage()) violations.extend(verify_workflow_coverage()) violations.extend(verify_immutable_release_upload_policy()) diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index 4e34a5e..7a13173 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -93,6 +93,302 @@ def test_build_baseline_upload_artifact_pins_are_consistent() -> None: assert len(set(pins)) == 1 +def test_supply_chain_check_requires_checkout_default_branch_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure checkout workflows suppress Git initial-branch warnings at source.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_checkout_default_branch_guard", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + """ +name: ci +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [ + ".github/workflows/ci.yml: workflows using actions/checkout must set " + "workflow-level GIT_CONFIG_* init.defaultBranch env to avoid Git " + "initial-branch warnings" + ] + + +def test_supply_chain_check_rejects_commented_checkout_default_branch_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure commented guard examples do not satisfy the checkout warning guard.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_commented_checkout_default_branch_guard", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + """ +name: ci +# env: +# GIT_CONFIG_COUNT: "1" +# GIT_CONFIG_KEY_0: init.defaultBranch +# GIT_CONFIG_VALUE_0: develop +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [ + ".github/workflows/ci.yml: workflows using actions/checkout must set " + "workflow-level GIT_CONFIG_* init.defaultBranch env to avoid Git " + "initial-branch warnings" + ] + + +def test_supply_chain_check_ignores_commented_checkout_reference( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure commented checkout references do not trigger guard enforcement.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_commented_checkout_reference", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + """ +name: ci +# - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +jobs: + verify: + runs-on: ubuntu-latest + steps: + - run: node --version +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [] + + +def test_supply_chain_check_ignores_run_step_checkout_reference( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure run-step checkout text does not trigger guard enforcement.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_run_step_checkout_reference", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + """ +name: ci +jobs: + verify: + runs-on: ubuntu-latest + steps: + - run: | + echo actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [] + + +def test_supply_chain_check_rejects_run_step_checkout_default_branch_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure later shell text does not satisfy the checkout warning guard.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_run_step_checkout_default_branch_guard", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + """ +name: ci +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - run: | + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [ + ".github/workflows/ci.yml: workflows using actions/checkout must set " + "workflow-level GIT_CONFIG_* init.defaultBranch env to avoid Git " + "initial-branch warnings" + ] + + +def test_supply_chain_check_rejects_nested_checkout_default_branch_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure a single nested env block cannot satisfy the workflow guard.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_nested_checkout_default_branch_guard", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + """ +name: ci +jobs: + guarded: + runs-on: ubuntu-latest + env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + unguarded: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [ + ".github/workflows/ci.yml: workflows using actions/checkout must set " + "workflow-level GIT_CONFIG_* init.defaultBranch env to avoid Git " + "initial-branch warnings" + ] + + +def test_supply_chain_check_rejects_top_level_nested_env_checkout_default_branch_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure nested top-level env maps cannot satisfy the checkout warning guard.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_top_level_nested_env_checkout_default_branch_guard", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + """ +name: ci +env: + CONFIGS: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [ + ".github/workflows/ci.yml: workflows using actions/checkout must set " + "workflow-level GIT_CONFIG_* init.defaultBranch env to avoid Git " + "initial-branch warnings" + ] + + +def test_supply_chain_check_accepts_checkout_default_branch_guard_comments( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure top-level env comments do not break valid checkout warning guards.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_checkout_default_branch_guard_comments", + ) + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "ci.yml").write_text( + """ +name: ci +env: # Git subprocess defaults inherited by actions/checkout. + GIT_CONFIG_COUNT: "1" # one key/value pair follows + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [] + + +def test_supply_chain_check_accepts_checkout_default_branch_guard( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ensure checked-in checkout workflows carry the warning guard.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_repo_checkout_default_branch_guard", + ) + repo_root = Path(__file__).resolve().parents[3] + + monkeypatch.chdir(repo_root) + + violations = supply_chain.verify_checkout_default_branch_guard() + + assert violations == [] + + def test_python_security_audit_does_not_ignore_patched_pygments_advisory() -> None: """Ensure patched Python advisories are not left as stale audit ignores.""" repo_root = Path(__file__).resolve().parents[3]