Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions .github/workflows/codeql-backfill.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ jobs:
- name: Enumerate target commits
id: commits
shell: bash
env:
BRANCH_INPUT: ${{ inputs.branch }}
COMMIT_COUNT_INPUT: ${{ inputs.commit_count }}
run: |
set -euo pipefail

count="${{ inputs.commit_count }}"
branch="${{ inputs.branch }}"
count="${COMMIT_COUNT_INPUT}"
branch="${BRANCH_INPUT}"

if ! [[ "${count}" =~ ^[0-9]+$ ]]; then
echo "commit_count must be a positive integer" >&2
Expand All @@ -54,8 +57,20 @@ jobs:
exit 1
fi

git fetch --no-tags --prune origin "${branch}"
git rev-list --max-count="${count}" "origin/${branch}" \
if ! normalized_branch="$(git check-ref-format --branch "${branch}")"; then
echo "branch must be a valid Git branch name" >&2
exit 1
fi

if [[ "${normalized_branch}" != "${branch}" ]]; then
echo "branch aliases are not accepted" >&2
exit 1
fi

source_ref="refs/heads/${branch}"
tracking_ref="refs/remotes/origin/${branch}"
git fetch --no-tags --prune origin -- "${source_ref}:${tracking_ref}"
git rev-list --max-count="${count}" "${tracking_ref}" \
| jq -R -s -c 'split("\n")[:-1]' \
| sed 's/^/commits=/' >> "${GITHUB_OUTPUT}"

Expand All @@ -81,15 +96,15 @@ jobs:
persist-credentials: false

- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: ${{ matrix.language }}

- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
category: "/language:${{ matrix.language }}/backfill"
ref: "refs/heads/${{ inputs.branch }}"
Expand Down
2 changes: 0 additions & 2 deletions backend/app/spec/relationship_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ def infer_relationships(snapshot: dict[str, Any] | None) -> list[dict[str, Any]]
pk_columns = snapshot.get("pk_columns") or []
fk_edges = snapshot.get("fk_edges") or []

rel_by_oid: dict[Any, dict[str, Any]] = {r.get("relation_oid"): r for r in relations}

# relation_name (lower) -> list of relation dicts (there may be same name in
# multiple schemas; we only infer within the same schema to avoid noise).
by_name: dict[str, list[dict[str, Any]]] = {}
Expand Down
168 changes: 168 additions & 0 deletions backend/tests/test_codeql_backfill_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Regression tests for the manual CodeQL backfill workflow contract."""

from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest


REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/codeql-backfill.yml"
VALIDATOR_PATH = REPOSITORY_ROOT / "scripts/ci/validate_codeql_backfill.py"

_SPEC = importlib.util.spec_from_file_location(
"validate_codeql_backfill",
VALIDATOR_PATH,
)
assert _SPEC is not None
assert _SPEC.loader is not None
_VALIDATOR = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_VALIDATOR)


def _validate(workflow: str) -> None:
_VALIDATOR.validate_workflow(workflow)


def test_current_codeql_backfill_workflow_passes_static_contract() -> None:
"""Accept the reviewed workflow without special test-only exceptions."""

_validate(WORKFLOW_PATH.read_text(encoding="utf-8"))


@pytest.mark.parametrize(
"expression",
(
"${{ inputs.branch }}",
"${{ inputs.branch }}",
"${{ inputs.commit_count }}",
"${{ github.event.inputs.branch }}",
),
)
def test_validator_rejects_unapproved_input_expression_use(
expression: str,
) -> None:
"""Reject new shell interpolation even when assignment spelling changes."""

workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
unsafe = workflow.replace(
" set -euo pipefail",
f' echo "{expression}"\n set -euo pipefail',
1,
)

with pytest.raises(AssertionError, match="workflow expression"):
_validate(unsafe)


@pytest.mark.parametrize(
"expression",
(
"${{ inputs.unreviewed_input }}",
"${{ inputs['unreviewed_input'] }}",
"${{ github.event.inputs['unreviewed_input'] }}",
"${{ github['event']['inputs']['unreviewed_input'] }}",
"${{ github.event['inputs'].unreviewed_input }}",
"${{ toJSON(inputs) }}",
"${{ toJSON(github.event.inputs) }}",
"${{ format('{{{0}}}', inputs.unreviewed_input) }}",
"${{ github.sha }}",
),
)
def test_validator_rejects_unknown_workflow_expression(
expression: str,
) -> None:
"""Reject every expression outside the reviewed expression allowlist."""

workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
unsafe = workflow.replace(
" COMMIT_COUNT_INPUT: ${{ inputs.commit_count }}",
" COMMIT_COUNT_INPUT: ${{ inputs.commit_count }}\n"
f" EXTRA_INPUT: {expression}",
1,
)

with pytest.raises(AssertionError, match="workflow expression"):
_validate(unsafe)


def test_validator_rejects_multiline_workflow_expression() -> None:
"""Reject folded expressions that the line-oriented verifier cannot parse."""

workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
unsafe = workflow.replace(
" COMMIT_COUNT_INPUT: ${{ inputs.commit_count }}",
" COMMIT_COUNT_INPUT: ${{ inputs.commit_count }}\n"
" EXTRA_INPUT: >-\n"
" ${{ toJSON(\n"
" inputs) }}",
1,
)

with pytest.raises(AssertionError, match="workflow expression"):
_validate(unsafe)


def test_validator_rejects_approved_expression_at_new_location() -> None:
"""Reject an approved spelling copied into another step or mapping."""

workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
unsafe = workflow.replace(
" - name: Initialize CodeQL",
" - name: Unreviewed input consumer\n"
" env:\n"
" BRANCH_INPUT: ${{ inputs.branch }}\n"
" run: echo unreviewed\n\n"
" - name: Initialize CodeQL",
1,
)

with pytest.raises(AssertionError, match="workflow expression"):
_validate(unsafe)


@pytest.mark.parametrize(
"unsafe",
(
"permissions:\n contents: read\n security-events: write",
" permissions:\n contents: read\n security-events: write",
),
)
def test_validator_limits_security_event_write_to_analysis_job(
unsafe: str,
) -> None:
"""Reject CodeQL upload authority at workflow or enumerate-job scope."""

workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
original = (
"permissions:\n contents: read"
if unsafe.startswith("permissions:")
else " permissions:\n contents: read"
)
mutated = workflow.replace(original, unsafe, 1)

with pytest.raises(AssertionError, match="security-events: write"):
_validate(mutated)


def test_validator_requires_previous_branch_alias_rejection() -> None:
"""Keep @{-n} aliases from passing validation with their raw refspec form."""

workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
unsafe = workflow.replace(
'if ! normalized_branch="$(git check-ref-format --branch "${branch}")"; then',
'if ! git check-ref-format --branch "${branch}" >/dev/null; then',
1,
).replace(
"\n if [[ \"${normalized_branch}\" != \"${branch}\" ]]; then\n"
" echo \"branch aliases are not accepted\" >&2\n"
" exit 1\n"
" fi\n",
"\n",
1,
)

with pytest.raises(AssertionError, match="normalized branch"):
_validate(unsafe)
18 changes: 14 additions & 4 deletions docs/security/codeql-sast-backfill.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ Use the GitHub Actions UI and run `codeql-sast-backfill` with:
- `branch`: `main`
- `commit_count`: `30`

The workflow enumerates recent commits from `origin/<branch>` and analyzes each
commit for:
The workflow fetches the requested `refs/heads/<branch>` into an explicit
`refs/remotes/origin/<branch>` tracking ref, enumerates that ref, and analyzes
each commit for:

- `javascript-typescript`
- `python`
Expand All @@ -31,6 +32,10 @@ commit for:
- Repository contents are read-only except the analyze job, which requires
`security-events: write` to upload CodeQL results.
- Checkout credentials are not persisted.
- Dispatch inputs enter shell steps only through reviewed `env` mappings.
Branch input must be a valid branch name and must equal Git's normalized
result, so previous-checkout aliases such as `@{-1}` are rejected before an
explicit, option-terminated refspec is constructed.
- The uploaded SARIF analysis is attributed to `refs/heads/<branch>` and the
specific commit SHA selected by the matrix.
- `commit_count` is capped at `127` so the two-language matrix plus the
Expand All @@ -45,5 +50,10 @@ python scripts\ci\validate_codeql_backfill.py
```

The verifier checks that the workflow remains manually dispatched, keeps the
expected inputs, grants `security-events: write` only where the CodeQL upload
needs it, and keeps the expected language matrix for this repository.
expected inputs, requires read-only workflow and enumeration permissions, and
grants `security-events: write` only to the CodeQL analysis job. It also keeps
the expected language matrix and exact-allowlists every workflow expression by
expression body, source line, and line content. Unknown contexts, direct or indexed input
access, function-wrapped input access, and relocated expressions are rejected.
Multiline or otherwise unparseable expressions fail closed. The verifier also
requires the normalized-branch equality guard.
6 changes: 3 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"overrides": {
"esbuild": "^0.25.0",
"nanoid": "^3.3.18",
"postcss": "^8.5.18"
}
}
Loading
Loading