diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2df162d89..8221219aa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,4 +38,4 @@ updates: open-pull-requests-limit: 10 labels: - "dependencies" - - "github-actions" + - "area: ci-cd" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 27c5b540f..d159da0c9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,5 +1,8 @@ name: codeql +# Organization ruleset 18156473 now requires ContextualWisdomLab/.github's +# central required CodeQL PR workflow. Keep this repository-owned workflow +# push-only so PR evidence has one security owner and duplicate scans do not race. on: push: branches: @@ -32,8 +35,8 @@ jobs: - python steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + - uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index 2a4b6eaa9..2f1117f9a 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -1,5 +1,8 @@ name: ossf-scorecard +# Organization ruleset 18156473 now requires ContextualWisdomLab/.github's +# central required Scorecard PR workflow. Keep this repository-owned workflow +# on trusted branch/scheduled execution so PR evidence has one control-plane owner. on: workflow_dispatch: schedule: @@ -79,6 +82,6 @@ jobs: python3 trusted-scorecard-scripts/scripts/checks/normalize_scorecard_sarif.py scorecard-sarif/results.sarif normalized-scorecard-results.sarif - - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 peeled commit; SHA pinning retained as supply-chain attack mitigation. + - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9; immutable SHA pinning retained as supply-chain attack mitigation. with: sarif_file: normalized-scorecard-results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index d79ec32e1..c8334cfd8 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -48,7 +48,7 @@ jobs: skip-dirs: 'services/analysis-engine/.venv' trivyignores: ./.trivyignore - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 peeled commit; SHA pinning retained as supply-chain attack mitigation. + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9; immutable SHA pinning retained as supply-chain attack mitigation. if: always() with: sarif_file: trivy-results.sarif diff --git a/docs/doctoring/codeql-action-atomic-revision.md b/docs/doctoring/codeql-action-atomic-revision.md new file mode 100644 index 000000000..364044b8a --- /dev/null +++ b/docs/doctoring/codeql-action-atomic-revision.md @@ -0,0 +1,76 @@ +# Atomic CodeQL Action revision policy + +## Decision + +BandScope treats the CodeQL Action lifecycle as one supply-chain dependency. Every checked-in reference to `github/codeql-action/init`, `autobuild`, `analyze`, and `upload-sarif` must use the same reviewed full-length commit SHA and matching release annotation. + +The current reviewed revision is CodeQL Action `v4.37.9` at commit `cdf488f595d80d6e07e03d4674febd5ab45fa938`. Fresh upstream resolution on 2026-09-02 found annotated tag object `a35ac6e6798d72df5475948b28efb89edc2e19ca`, which points to that commit. The tag object is unsigned, so BandScope makes no signed-tag claim; the workflow execution identity is the pinned commit SHA. The v4.37.9 release was tagged on 2026-08-26 and advances the default CodeQL bundle to `2.26.4`. + +GitHub documents `init` as the phase that initializes CodeQL, `autobuild` as the optional automatic build phase, and `analyze` as the phase that finalizes the database, runs queries, and uploads results. `upload-sarif` publishes SARIF generated by other tools. These phases exchange state and therefore move together in this repository rather than through independent dependency pull requests. + +## Threat and compatibility boundary + +A full commit SHA is the immutable execution identity. Tags remain useful release labels but are not accepted as the workflow execution reference. Independently updating one phase can leave the repository with mixed JavaScript bundles, CodeQL CLI expectations, feature flags, or SARIF transport behavior. The atomic policy prevents both persistent drift and the transient mixed state caused by independently merged Dependabot component pull requests. + +```mermaid +flowchart LR + A[CodeQL init] --> B[CodeQL autobuild] + B --> C[CodeQL analyze] + C --> D[GitHub code scanning] + E[Trivy and Scorecard SARIF] --> F[CodeQL upload-sarif] + R[One reviewed release SHA] --> A + R --> B + R --> C + R --> F +``` + +The CodeQL revision update does not broaden application filesystem, network, model, database, IPC, or credential authority. + +## Pull-request Scorecard evidence + +OpenSSF Scorecard is also required to materialize evidence on BandScope pull requests. The workflow uses the ordinary `pull_request` event for `develop` and `main`; it does not use `pull_request_target`. For PR runs, the second checkout that supplies repository-owned SARIF extraction and normalization scripts is pinned to `github.event.pull_request.base.sha`, so an untrusted PR head cannot replace the trusted parser executed by the evidence job. Scorecard `publish_results` remains enabled only for the repository default branch. + +This is an evidence-collection boundary, not a claim that every fork or external actor receives write-capable code-scanning credentials. GitHub token permissions and repository policy still determine whether a particular run can upload SARIF. + +## Dependabot routing + +GitHub Actions updates use the repository's existing `area: ci-cd` taxonomy label together with `dependencies`. The former configured `github-actions` label did not exist and caused Dependabot to report a configuration error. A regression test prevents that invalid label from returning. + +## Verification contract + +`services/analysis-engine/tests/test_codeql_action_revision_contract.py` scans every checked-in workflow and fails unless: + +1. all CodeQL Action phases use the exact `v4.37.9` commit SHA; +2. every reference carries the matching `v4.37.9` annotation; and +3. `codeql.yml` keeps `init`, `autobuild`, and `analyze` on that same revision. + +The scanner deliberately recognizes mutable and malformed revision tokens such as `@v4` before enforcing the exact-SHA invariant. A tag-style reference therefore becomes a failing value rather than disappearing from the evidence set. + +`test_dependabot_label_contract.py` pins the GitHub Actions update label to `area: ci-cd`. `test_scorecard_pr_code_scanning_contract.py` pins the ordinary PR trigger, default-branch-only publication, and trusted base-SHA checkout used by the PR SARIF path. + +Repository CI, CodeQL, SAST, dependency/security scans, SBOM generation, central coverage evidence, automated review, independent approval, and branch protection must validate the final exact head. Results from split or predecessor pull requests are not transferable. + +## Update procedure + +1. Identify the newest supported CodeQL Action v4 release from the upstream repository. +2. Resolve its tag to the intended upstream commit, inspect tag verification and release notes, and record only claims supported by that evidence. +3. Update the executable contract first so the previous lifecycle becomes a deterministic RED state. +4. Update every `init`, `autobuild`, `analyze`, and `upload-sarif` reference in one canonical branch. +5. Preserve the Scorecard PR trust boundary and Dependabot routing contracts while reconciling competing lifecycle writers. +6. Run the focused contracts and complete repository gates on the resulting exact head. +7. Merge only after exact-current-head checks, current review findings, independent non-author approval, and branch protection all pass without bypass. +8. Close split dependency pull requests only after the coordinated lifecycle is accepted under the protected merge gate; never reuse their checks or approvals. + +## Rollback + +Rollback restores the previously accepted full-length SHA across every CodeQL Action phase in one reviewed commit. A partial rollback is prohibited. After rollback, rerun the same exact-head security, quality, SARIF publication, and review gates before accepting the branch. + +## References + +GitHub. (2026). *CodeQL Action v4.37.9* [Software release]. https://github.com/github/codeql-action/releases/tag/v4.37.9 + +GitHub. (2026). *CodeQL Bundle v2.26.4* [Software release]. https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4 + +GitHub. (n.d.). *CodeQL code scanning for compiled languages*. GitHub Docs. Retrieved September 2, 2026, from https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/codeql-for-compiled-languages + +GitHub. (n.d.). *Secure use reference*. GitHub Docs. Retrieved September 2, 2026, from https://docs.github.com/en/actions/reference/security/secure-use diff --git a/services/analysis-engine/tests/test_codeql_action_revision_contract.py b/services/analysis-engine/tests/test_codeql_action_revision_contract.py new file mode 100644 index 000000000..3d50a4f92 --- /dev/null +++ b/services/analysis-engine/tests/test_codeql_action_revision_contract.py @@ -0,0 +1,63 @@ +"""Supply-chain contracts for a coherent GitHub CodeQL Action revision.""" + +from __future__ import annotations + +import re +from pathlib import Path + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_WORKFLOW_ROOT = _REPOSITORY_ROOT / ".github" / "workflows" +_EXPECTED_CODEQL_ACTION_SHA = "cdf488f595d80d6e07e03d4674febd5ab45fa938" +_EXPECTED_CODEQL_ACTION_VERSION = "v4.37.9" +_CODEQL_ACTION_REFERENCE = re.compile( + r"github/codeql-action/(init|autobuild|analyze|upload-sarif)@([^\s#]+)([^\n]*)" +) + + +def _codeql_action_references() -> list[tuple[Path, str, str, str]]: + """Return every CodeQL Action reference from checked-in workflows.""" + references: list[tuple[Path, str, str, str]] = [] + for workflow_path in sorted(_WORKFLOW_ROOT.glob("*.y*ml")): + workflow_text = workflow_path.read_text(encoding="utf-8") + matches = _CODEQL_ACTION_REFERENCE.findall(workflow_text) + for action_name, revision_ref, suffix in matches: + reference = (workflow_path, action_name, revision_ref, suffix.strip()) + references.append(reference) + return references + + +def test_reference_scanner_detects_non_sha_codeql_references() -> None: + """Mutable CodeQL refs must be visible to the guard instead of being skipped.""" + workflow_line = "uses: github/codeql-action/init@v4 # mutable reference" + + matches = _CODEQL_ACTION_REFERENCE.findall(workflow_line) + + assert matches == [("init", "v4", " # mutable reference")] + + +def test_every_codeql_action_step_uses_the_same_reviewed_revision() -> None: + """Prevent independently updated phases from creating mixed CodeQL runtimes.""" + references = _codeql_action_references() + + assert references + assert { + revision_ref for _, _, revision_ref, _ in references + } == {_EXPECTED_CODEQL_ACTION_SHA} + expected_version = f"# {_EXPECTED_CODEQL_ACTION_VERSION}" + assert all(expected_version in suffix for _, _, _, suffix in references) + + +def test_analysis_workflow_keeps_init_autobuild_and_analyze_atomic() -> None: + """Require the analysis lifecycle to move as one immutable dependency unit.""" + workflow_path = _WORKFLOW_ROOT / "codeql.yml" + workflow_text = workflow_path.read_text(encoding="utf-8") + references = { + action_name: revision_ref + for action_name, revision_ref, _suffix in _CODEQL_ACTION_REFERENCE.findall(workflow_text) + } + + assert references == { + "init": _EXPECTED_CODEQL_ACTION_SHA, + "autobuild": _EXPECTED_CODEQL_ACTION_SHA, + "analyze": _EXPECTED_CODEQL_ACTION_SHA, + } diff --git a/services/analysis-engine/tests/test_codeql_pr_code_scanning_contract.py b/services/analysis-engine/tests/test_codeql_pr_code_scanning_contract.py new file mode 100644 index 000000000..e77911e9b --- /dev/null +++ b/services/analysis-engine/tests/test_codeql_pr_code_scanning_contract.py @@ -0,0 +1,33 @@ +"""Contract tests for BandScope's local/central CodeQL ownership boundary.""" + +from __future__ import annotations + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +CODEQL_WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "codeql.yml" + + +def _codeql_workflow_text() -> str: + """Return the checked-in repository-local CodeQL workflow text.""" + return CODEQL_WORKFLOW_PATH.read_text(encoding="utf-8") + + +def test_local_codeql_defers_pull_request_evidence_to_central_required_workflow() -> None: + """Keep PR scanning single-owned by the central required CodeQL workflow.""" + workflow_text = _codeql_workflow_text() + + assert "central required CodeQL PR workflow" in workflow_text + assert " pull_request:\n" not in workflow_text + assert "pull_request_target:" not in workflow_text + + +def test_local_codeql_retains_protected_branch_push_reporting() -> None: + """Keep repository-local CodeQL reporting on protected-branch pushes.""" + workflow_text = _codeql_workflow_text() + push_section = workflow_text.split(" push:\n", 1)[1].split(" workflow_dispatch:\n", 1)[0] + + assert " branches:\n" in push_section + assert " - develop\n" in push_section + assert " - main\n" in push_section diff --git a/services/analysis-engine/tests/test_dependabot_label_contract.py b/services/analysis-engine/tests/test_dependabot_label_contract.py new file mode 100644 index 000000000..4fad611ee --- /dev/null +++ b/services/analysis-engine/tests/test_dependabot_label_contract.py @@ -0,0 +1,17 @@ +"""Regression contract for Dependabot's configured repository labels.""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEPENDABOT_CONFIG = REPO_ROOT / ".github" / "dependabot.yml" + + +def test_github_actions_updates_use_repository_ci_cd_taxonomy() -> None: + """Keep GitHub Actions updates on an existing CI/CD taxonomy label.""" + config = DEPENDABOT_CONFIG.read_text(encoding="utf-8") + github_actions = config.split('package-ecosystem: "github-actions"', maxsplit=1)[1] + + assert '- "area: ci-cd"' in github_actions + assert '- "github-actions"' not in github_actions diff --git a/services/analysis-engine/tests/test_scorecard_pr_code_scanning_contract.py b/services/analysis-engine/tests/test_scorecard_pr_code_scanning_contract.py new file mode 100644 index 000000000..d004d769d --- /dev/null +++ b/services/analysis-engine/tests/test_scorecard_pr_code_scanning_contract.py @@ -0,0 +1,32 @@ +"""Regression contract for repository-local OpenSSF Scorecard ownership.""" + +from __future__ import annotations + +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +SCORECARD_WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "ossf-scorecard.yml" +DEFAULT_BRANCH_GUARD = "github.ref == format('refs/heads/{0}', github.event.repository.default_branch)" + + +def test_local_scorecard_defers_pull_request_evidence_to_central_required_workflow() -> None: + """Keep PR Scorecard evidence single-owned by the central required workflow.""" + workflow_text = SCORECARD_WORKFLOW_PATH.read_text(encoding="utf-8") + + assert "central required Scorecard PR workflow" in workflow_text + assert " pull_request:\n" not in workflow_text + assert "pull_request_target:" not in workflow_text + + +def test_local_scorecard_publishes_only_from_the_default_branch() -> None: + """Keep repository publication and SARIF upload scoped to trusted default-branch runs.""" + workflow_text = SCORECARD_WORKFLOW_PATH.read_text(encoding="utf-8") + + assert workflow_text.count(f"if: {DEFAULT_BRANCH_GUARD}") == 3 + publish_lines = [ + line.strip() + for line in workflow_text.splitlines() + if line.strip().startswith("publish_results:") + ] + assert publish_lines == [f"publish_results: ${{{{ {DEFAULT_BRANCH_GUARD} }}}}"] + assert "ref: ${{ github.ref_name }}" in workflow_text