Skip to content
Merged
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
55 changes: 24 additions & 31 deletions .github/workflows/sast-semgrep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,36 @@ permissions:
contents: read

jobs:
changed-scope:
name: Detect changed scope
semgrep:
name: Semgrep (multi-language SAST)
# The org ruleset IGNORES every `on:` filter (paths, branches, types) when it
# runs this workflow in another repository, and a trigger-level skip would
# leave `.github`'s classic required contexts Pending forever. Both
# mechanisms honour a JOB-level skip, so the doc/image-only decision is made
# here and consumed through `needs`. See
# mechanisms honour a job that runs and concludes on its own, so the
# doc/image-only decision is made by the classifier step below and consumed
# by the expensive steps' `if:` guards. See
# docs/doctoring/required-workflow-path-filter-boundary.md.
# Fails OPEN: an unreadable, empty, or truncated file list scans everything.
# The gate lives inside this job as a step-level guard (one runner, not two).
if: github.event.action != 'closed'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
code: ${{ steps.scope.outputs.code }}
deps: ${{ steps.scope.outputs.deps }}
security-events: write
actions: read
env:
# Deterministic, no telemetry: registry rules are fetched but no scan data
# is sent back.
SEMGREP_SEND_METRICS: "off"
# Semgrep OSS 1.169.0. Keep the immutable manifest reference in one
# place so hosted scans and local reproduction cannot drift.
SEMGREP_IMAGE: "semgrep/semgrep@sha256:2b33f46ba66cf8cc2ad59ccfa7d22951fd00c632c38f1339e84ec8e6e641a942"
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Classify changed paths
id: scope
env:
Expand Down Expand Up @@ -99,35 +110,15 @@ jobs:
echo "code=${code}" >> "$GITHUB_OUTPUT"
echo "deps=${deps}" >> "$GITHUB_OUTPUT"
echo "changed-scope code=${code} deps=${deps}"

semgrep:
name: Semgrep (multi-language SAST)
needs: changed-scope
if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
actions: read
env:
# Deterministic, no telemetry: registry rules are fetched but no scan data
# is sent back.
SEMGREP_SEND_METRICS: "off"
# Semgrep OSS 1.169.0. Keep the immutable manifest reference in one
# place so hosted scans and local reproduction cannot drift.
SEMGREP_IMAGE: "semgrep/semgrep@sha256:2b33f46ba66cf8cc2ad59ccfa7d22951fd00c632c38f1339e84ec8e6e641a942"
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout exact submitted revision
if: steps.scope.outputs.code == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Verify exact submitted revision
if: steps.scope.outputs.code == 'true'
env:
EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
Expand All @@ -140,6 +131,7 @@ jobs:
fi
echo "SAST_CHECKOUT scanner=semgrep repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}"
- name: Verify pinned Semgrep manifest
if: steps.scope.outputs.code == 'true'
run: |
set -euo pipefail
if [[ "${SEMGREP_IMAGE}" =~ ^semgrep/semgrep@sha256:[0-9a-f]{64}$ ]]; then
Expand All @@ -151,6 +143,7 @@ jobs:
fi
- name: Run Semgrep (SARIF)
id: semgrep
if: steps.scope.outputs.code == 'true'
run: |
set +e
echo "Using ${SEMGREP_IMAGE}"
Expand Down Expand Up @@ -219,7 +212,7 @@ jobs:
echo "SEMGREP_ENGINE_FAILURE rc=${SEMGREP_RC:-missing}: Semgrep failed without a WARNING/ERROR SARIF result; inspect the scan command output above."
fi
- name: Enforce Semgrep gate (fail on Medium+ findings)
if: always() && (steps.semgrep_sarif.outputs.finding_count != '0' || steps.semgrep.outputs.rc != '0')
if: always() && steps.scope.outputs.code == 'true' && (steps.semgrep_sarif.outputs.finding_count != '0' || steps.semgrep.outputs.rc != '0')
env:
SEMGREP_RC: ${{ steps.semgrep.outputs.rc }}
SEMGREP_FINDING_COUNT: ${{ steps.semgrep_sarif.outputs.finding_count }}
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ jobs:
with:
scan-args: |
--format=json
--output=old-results.json
--output-file=old-results.json
--maven-registry=https://maven-central.storage-download.googleapis.com/maven2
--no-resolve
--allow-no-lockfiles
Expand All @@ -182,7 +182,7 @@ jobs:
with:
scan-args: |
--format=json
--output=old-results.json
--output-file=old-results.json
--no-resolve
--allow-no-lockfiles
-r
Expand Down Expand Up @@ -215,7 +215,7 @@ jobs:
with:
scan-args: |
--format=json
--output=new-results.json
--output-file=new-results.json
--maven-registry=https://maven-central.storage-download.googleapis.com/maven2
--no-resolve
--allow-no-lockfiles
Expand All @@ -233,7 +233,7 @@ jobs:
with:
scan-args: |
--format=json
--output=new-results.json
--output-file=new-results.json
--no-resolve
--allow-no-lockfiles
-r
Expand Down Expand Up @@ -286,7 +286,7 @@ jobs:
uses: google/osv-scanner-action/osv-reporter-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.3.8
with:
scan-args: |
--output=results.sarif
--output-files=results.sarif
--old=old-results.json
--new=new-results.json
--gh-annotations=true
Expand Down Expand Up @@ -323,6 +323,10 @@ jobs:
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: results.sarif
# The exact head checkout lives in `source`, not the workspace root;
# without this binding upload-sarif logs "does not appear to be a git
# repository" twice and falls back to server-derived commit identity.
checkout_path: ${{ github.workspace }}/source
# results.sarif is produced after checkout of the pull request head.
# Uploading it against refs/pull/*/merge can race GitHub's synthetic
# merge ref and fail with "commit_oid is not a merge commit".
Expand Down
21 changes: 19 additions & 2 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -3243,8 +3243,8 @@ intended contract before rewriting the assertion — left for a dedicated follow

## Items 15/16/17 measurement: `Detect changed scope` gate jobs — 2 of 3 are pure runner overhead — 2026-09-05

**Status:** Measured, not yet fixed. Recorded so the fix is grounded in real numbers rather than the intuition
this measurement partly refuted.
**Status:** Measured 2026-09-05; `sast-semgrep.yml` fixed 2026-09-13 (below); `strix.yml` deferred. Recorded so
the fix is grounded in real numbers rather than the intuition this measurement partly refuted.

**Why measured.** Items 15/16/17 ask to remove needlessly-triggered workflows, consolidate workflow files
("bootup에도 시간이 듦"), and cut redundant steps; the standing complaint is the org's 60-concurrent-job
Expand Down Expand Up @@ -3359,3 +3359,20 @@ queries the check-runs API at its own time, order-independently. The implementin
their change was safe because they had scoped it narrowly, not because they had checked for the name
collision — which is the more useful lesson: **a job name is unique only within one workflow file, and the
same name in another file can carry the opposite safety property.**

**Fixed for `sast-semgrep.yml`, 2026-09-13.** The standalone `changed-scope` job is gone; its
"Classify changed paths" step now runs inside the single consumer `semgrep` (after `harden-runner`,
which must audit the classifier's own `gh api` egress) and the four expensive steps plus the final
"Enforce Semgrep gate" step carry `steps.scope.outputs.code == 'true'`. The job keeps
`if: github.event.action != 'closed'` with no `needs.` term, so a doc-only PR's run still executes one
job that concludes `success` -- the load-bearing property from
[`required-workflow-path-filter-boundary.md`](doctoring/required-workflow-path-filter-boundary.md) is
preserved, and neither `Detect changed scope` nor `Semgrep (multi-language SAST)` is among `.github`'s
classic required contexts, so nothing goes Pending there. One trap the first draft would have shipped:
the enforce step's `always() && (... || steps.semgrep.outputs.rc != '0')` evaluates `rc` as the empty
string when `Run Semgrep` is step-skipped, which is `!= '0'` and would have failed every doc-only PR;
the guard on that step is what makes the fold safe. Net: one runner allocation per PR for this
workflow instead of two, org-wide. `strix.yml` (the other single-consumer gate) is deliberately left
alone -- it is a documented multi-PR hot-file collision zone. Contract:
`tests/test_docs_only_pr_runner_admission.py::test_sast_semgrep_folds_the_gate_into_its_single_consumer_at_step_level`,
`tests/test_required_security_runner_image_contract.py`.
90 changes: 72 additions & 18 deletions scripts/ci/codeql_sarif_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,66 @@ def iter_sarif_files(root: Path) -> list[Path]:
return sorted(root.rglob("*.sarif"))


def _rule_for_result(result: dict[str, Any], rules: list[Any]) -> dict[str, Any]:
"""Resolve the SARIF rule definition referenced by a result."""
rules_by_id = {
str(rule.get("id") or ""): rule for rule in rules if isinstance(rule, dict)
}
rule = rules_by_id.get(str(result.get("ruleId") or ""), {})
if rule:
return rule
rule_index = result.get("ruleIndex")
if isinstance(rule_index, int) and 0 <= rule_index < len(rules):
candidate = rules[rule_index]
if isinstance(candidate, dict):
UNRESOLVED_RULE_LEVEL = "unresolved-rule"


def _component_rules(result: dict[str, Any], tool: dict[str, Any]) -> list[Any] | None:
"""Return the rules of the tool component a result references (SARIF 2.1.0 §3.54).

No ``rule.toolComponent`` means the driver. Otherwise the reference selects one of
``tool.extensions`` by ``index``, ``guid``, or ``name``; an unmatched reference
returns ``None`` so the caller can fail closed instead of consulting the wrong
component (issue #2150).
"""
reference = result.get("rule") if isinstance(result.get("rule"), dict) else {}
component_ref = reference.get("toolComponent")
if not isinstance(component_ref, dict):
return (tool.get("driver") or {}).get("rules") or []
extensions = [ext for ext in tool.get("extensions") or [] if isinstance(ext, dict)]
index = component_ref.get("index")
if isinstance(index, int):
if 0 <= index < len(extensions):
return extensions[index].get("rules") or []
return None
for key in ("guid", "name"):
wanted = component_ref.get(key)
if wanted is not None:
for extension in extensions:
if extension.get(key) == wanted:
return extension.get("rules") or []
return None
return None


def _rule_for_result(result: dict[str, Any], tool: dict[str, Any]) -> dict[str, Any] | None:
"""Resolve the SARIF rule definition a result references, or ``None`` if it cannot be.

Resolution order inside the referenced component: ``rule.index`` (validated
against the declared id), then id lookup (``ruleId`` / ``rule.id``), then the
legacy ``ruleIndex``. Colliding ids across components stay distinct because
lookup never leaves the referenced component.
"""
rules = _component_rules(result, tool)
if rules is None:
return None
reference = result.get("rule") if isinstance(result.get("rule"), dict) else {}
declared_ids = {str(v) for v in (result.get("ruleId"), reference.get("id")) if v}
if len(declared_ids) > 1:
return None
declared_id = next(iter(declared_ids), "")
for index in (reference.get("index"), result.get("ruleIndex")):
if isinstance(index, int):
candidate = rules[index] if 0 <= index < len(rules) else None
if not isinstance(candidate, dict):
return None
if declared_id and str(candidate.get("id") or "") != declared_id:
return None
return candidate
if declared_id:
for rule in rules:
if isinstance(rule, dict) and str(rule.get("id") or "") == declared_id:
return rule
return None
return {}


Expand All @@ -56,11 +103,16 @@ def _is_medium_plus(score: float | None, level: str, security_rule: bool) -> boo
return security_rule and level in SEVERITY_LEVELS


def _finding_from_result(result: dict[str, Any], rules: list[Any]) -> Finding | None:
"""Build a `Finding` for one SARIF result, or None if it doesn't gate the PR."""
def _finding_from_result(result: dict[str, Any], tool: dict[str, Any]) -> Finding | None:
"""Build a `Finding` for one SARIF result, or None if it doesn't gate the PR.

A result whose rule reference cannot be resolved and that carries no explicit
security-severity gates as ``unresolved-rule`` rather than passing silently.
"""
if not isinstance(result, dict) or result.get("suppressions"):
return None
rule = _rule_for_result(result, rules)
resolved = _rule_for_result(result, tool)
rule = resolved or {}
result_properties = result.get("properties") or {}
rule_properties = rule.get("properties") or {}
raw_score = result_properties.get("security-severity", rule_properties.get("security-severity"))
Expand All @@ -71,7 +123,9 @@ def _finding_from_result(result: dict[str, Any], rules: list[Any]) -> Finding |
level = str(result.get("level") or (rule.get("defaultConfiguration") or {}).get("level") or "none").lower()
tags = {str(tag).lower() for tag in rule_properties.get("tags") or []}
security_rule = "security" in tags or any(tag.startswith("external/cwe/") for tag in tags)
if not _is_medium_plus(score, level, security_rule):
if resolved is None and score is None:
level = UNRESOLVED_RULE_LEVEL
elif not _is_medium_plus(score, level, security_rule):
return None
physical = ((result.get("locations") or [{}])[0].get("physicalLocation") or {})
artifact = (physical.get("artifactLocation") or {}).get("uri") or "unknown"
Expand All @@ -95,12 +149,12 @@ def gather_findings(root: Path) -> tuple[list[Finding], int, int]:
for path in paths:
payload = json.loads(path.read_text(encoding="utf-8"))
for run in payload.get("runs") or []:
rules = ((run.get("tool") or {}).get("driver") or {}).get("rules") or []
tool = run.get("tool") if isinstance(run.get("tool"), dict) else {}
for result in run.get("results") or []:
if not isinstance(result, dict):
continue
total_results += 1
finding = _finding_from_result(result, rules)
finding = _finding_from_result(result, tool)
if finding is not None:
findings.append(finding)
return findings, total_results, len(paths)
Expand Down
Loading
Loading