Skip to content

fix(mokken): bound zero-cell structural traversal #7166

fix(mokken): bound zero-cell structural traversal

fix(mokken): bound zero-cell structural traversal #7166

# Central bundled security gate for every ContextualWisdomLab repo.
#
# This is a REQUIRED org workflow (see the "CWL Central required workflows"
# ruleset). It bundles the supply-chain / vulnerability / posture scanners into
# one gate so they pass or fail as a unit:
#
# osv-scan HARD diff-scoped — fails on NEW vulns the PR introduces
# dependency-review HARD diff-scoped — fails on vulnerable/denied deps the PR adds
# trivy-fs HARD repo-wide — fails on FIXABLE MEDIUM/HIGH/CRITICAL findings
# gitleaks HARD commit-range — blocks secrets in ContextualWisdomLab/.github PRs
# scorecard SOFT repo posture — uploaded for visibility, never blocks
#
# This is the sole organization-required owner for OSV and Scorecard PR work.
# The standalone workflows remain local to this repository because its classic
# branch protection still requires their historical check contexts.
#
# Gating is by the JOB result (a failed job fails this required workflow ->
# merge blocked), NOT by the code_scanning ruleset rule. The code_scanning rule
# stays CodeQL-only on purpose: requiring multiple code-scanning TOOLS there is
# unsatisfiable because default-setup CodeQL uploads to refs/pull/N/head while
# pull_request workflows upload to refs/pull/N/merge, so no single ref ever holds
# all tools. Bundling at the workflow/check level is ref-independent.
#
# NOTE on dependency-review: unavailable evidence is not a clean result. Only
# an exact base/head comparison returning HTTP 200 may reach the pinned hard
# gate. Every other probe outcome fails closed without printing the response
# body. See docs/doctoring/dependency-review-fail-closed.md.
#
# NOTE on trivy-fs: it scans the whole repo, so a pre-existing FIXABLE
# MEDIUM/HIGH/CRITICAL finding blocks every PR in that repo until it is fixed.
# Trivy itself exits 0 so SARIF is always available; the following parser prints
# exact findings and then fails the job.
#
# NOTE on the changed-scope gate: each job below now runs only when the
# `changed-scope` job's diff-scoped output says it is in scope (`code` for
# trivy-fs/scorecard, `deps` for osv-scan/dependency-review). A doc/image-only
# PR skips every one of these jobs, and `scheduled-security-scan.yml` (push +
# default-branch schedule) and `scorecard-analysis.yml` (push + weekly cron)
# remain the full repo-wide backstops that make those skips safe.
name: Security Scan
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, closed]
# Do not restrict the base ref: stacked PRs must receive the same
# diff-scoped OSV/dependency and repo-wide Trivy gate as default-branch PRs.
concurrency:
group: >-
security-scan-${{
github.event_name == 'pull_request' && github.event.pull_request.base.repo.full_name || github.repository }}-${{
github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
# Scorecard Token-Permissions (alert #42): workflow-level token stays
# read-only. Every job that uploads SARIF (osv-scan, trivy-fs, scorecard)
# already declares security-events:write at job scope, so granting it here as
# well is redundant and over-broad.
permissions:
actions: read
contents: read
jobs:
changed-scope:
name: Detect changed scope
# 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
# docs/doctoring/required-workflow-path-filter-boundary.md.
# Fails OPEN: an unreadable, empty, or truncated file list scans everything.
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 }}
steps:
- name: Classify changed paths
id: scope
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.event.pull_request.base.repo.full_name || github.repository }}
PR: ${{ github.event.pull_request.number }}
EXPECTED_FILES: ${{ github.event.pull_request.changed_files }}
shell: bash
run: |
set -uo pipefail
code=true
deps=true
if [ -n "${PR}" ] && [ -n "${EXPECTED_FILES}" ]; then
changed=""
for attempt in 1 2 3; do
if changed="$(gh api --paginate "repos/${REPO}/pulls/${PR}/files?per_page=100" --jq '.[].filename')" && [ -n "$changed" ]; then
break
fi
changed=""
sleep $((attempt * 3))
done
# GitHub caps /pulls/N/files at 3000 entries; a short list would hide
# source files behind a doc-only verdict, so require an exact count.
if [ -n "$changed" ] && [ "$(printf '%s\n' "$changed" | wc -l | tr -d ' ')" = "${EXPECTED_FILES}" ]; then
code=false
deps=false
while IFS= read -r changed_path; do
case "$changed_path" in
*.md|*.markdown|*.rst|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.bmp|*.ico|LICENSE|LICENSE.txt|COPYING|COPYING.txt|NOTICE|NOTICE.txt|.github/ISSUE_TEMPLATE/*) ;;
*) code=true ;;
esac
case "$changed_path" in
requirements*.txt|*/requirements*.txt|pyproject.toml|*/pyproject.toml|uv.lock|*/uv.lock|pylock.*.toml|*/pylock.*.toml|package.json|*/package.json|package-lock.json|*/package-lock.json|pnpm-lock.yaml|*/pnpm-lock.yaml|yarn.lock|*/yarn.lock|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|go.mod|*/go.mod|go.sum|*/go.sum|pom.xml|*/pom.xml|build.gradle|*/build.gradle|build.gradle.kts|*/build.gradle.kts|DESCRIPTION|*/DESCRIPTION) deps=true ;;
esac
done <<<"$changed"
else
echo "::notice::changed-scope could not read a complete PR file list; scanning everything."
fi
fi
echo "code=${code}" >> "$GITHUB_OUTPUT"
echo "deps=${deps}" >> "$GITHUB_OUTPUT"
echo "changed-scope code=${code} deps=${deps}"
osv-scan:
needs: changed-scope
if: github.event.action != 'closed' && needs.changed-scope.outputs.deps == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 25
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Explain OSV scan mode and timeout budget
run: |
echo "::notice::OSV hard gate scans direct manifest and lockfile evidence with --no-resolve so external transitive registry resolver stalls cannot hold the required-check queue indefinitely. The job is capped at 25 minutes; if this budget is exceeded, rerun after the upstream registry/service recovers or inspect the uploaded debug artifacts."
- name: Checkout base
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: source
fetch-depth: 0
persist-credentials: false
- name: Verify OSV base checkout
env:
EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name }}
EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
actual_sha="$(git -C source rev-parse HEAD)"
if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then
echo "::error::OSV base checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}."
exit 1
fi
echo "SECURITY_CHECKOUT scanner=osv revision=base repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}"
- name: Scan base with OSV
id: osv_base
continue-on-error: true
timeout-minutes: 8
uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47
with:
scan-args: |
--format=json
--output=old-results.json
--maven-registry=https://maven-central.storage-download.googleapis.com/maven2
--no-resolve
--allow-no-lockfiles
-r
source/
- name: Explain base OSV resolver fallback
if: steps.osv_base.outcome == 'failure'
run: |
echo "::warning::OSV base scan failed or timed out before reporter output was trusted; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided."
- name: Retry base OSV without transitive resolution
if: steps.osv_base.outcome == 'failure'
continue-on-error: true
timeout-minutes: 4
uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47
with:
scan-args: |
--format=json
--output=old-results.json
--no-resolve
--allow-no-lockfiles
-r
source/
- name: Checkout head
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: source
fetch-depth: 0
persist-credentials: false
- name: Verify OSV head checkout
env:
EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
actual_sha="$(git -C source rev-parse HEAD)"
if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then
echo "::error::OSV head checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}."
exit 1
fi
echo "SECURITY_CHECKOUT scanner=osv revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}"
- name: Scan head with OSV
id: osv_head
continue-on-error: true
timeout-minutes: 8
uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47
with:
scan-args: |
--format=json
--output=new-results.json
--maven-registry=https://maven-central.storage-download.googleapis.com/maven2
--no-resolve
--allow-no-lockfiles
-r
source/
- name: Explain head OSV resolver fallback
if: steps.osv_head.outcome == 'failure'
run: |
echo "::warning::OSV head scan failed or timed out before reporter output was trusted; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided."
- name: Retry head OSV without transitive resolution
if: steps.osv_head.outcome == 'failure'
continue-on-error: true
timeout-minutes: 4
uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47
with:
scan-args: |
--format=json
--output=new-results.json
--no-resolve
--allow-no-lockfiles
-r
source/
- name: Require OSV scan output
run: |
set -euo pipefail
test -s old-results.json
test -s new-results.json
- name: Print OSV findings being compared
shell: python3 {0}
run: |
import json
from pathlib import Path
def iter_findings(path):
data = json.loads(Path(path).read_text(encoding="utf-8"))
for result in data.get("results") or []:
source = result.get("source", {})
source_name = source.get("path") or source.get("name") or "unknown"
for package in result.get("packages", []):
package_info = package.get("package", {})
package_name = package_info.get("name") or "unknown"
package_version = package_info.get("version") or "unknown"
for vulnerability in package.get("vulnerabilities", []):
aliases = ", ".join(vulnerability.get("aliases") or [])
summary = (vulnerability.get("summary") or "").replace("\n", " ").strip()
yield {
"source": source_name,
"package": package_name,
"version": package_version,
"id": vulnerability.get("id") or "unknown",
"aliases": aliases,
"summary": summary,
}
for label, path in (("base", "old-results.json"), ("head", "new-results.json")):
findings = list(iter_findings(path))
print(f"OSV {label} scan produced {len(findings)} finding(s) in {path}.")
for finding in findings[:50]:
alias_text = f" aliases={finding['aliases']}" if finding["aliases"] else ""
summary_text = f" - {finding['summary']}" if finding["summary"] else ""
print(
f"- {finding['source']}: {finding['package']}@{finding['version']} "
f"{finding['id']}{alias_text}{summary_text}"
)
if len(findings) > 50:
print(f"... {len(findings) - 50} additional {label} OSV finding(s) omitted from the log summary.")
- name: Report PR-introduced OSV findings
uses: google/osv-scanner-action/osv-reporter-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.3.8
with:
scan-args: |
--output=results.sarif
--old=old-results.json
--new=new-results.json
--gh-annotations=true
--fail-on-vuln=true
- name: Mark clean OSV SARIF as comprehensive
if: always() && hashFiles('results.sarif') != ''
shell: python3 {0}
run: |
import json
from pathlib import Path
sarif_path = Path("results.sarif")
sarif = json.loads(sarif_path.read_text(encoding="utf-8"))
total_results = 0
for run in sarif.get("runs", []):
total_results += len(run.get("results", []))
run.setdefault("tool", {}).setdefault("driver", {})["isComprehensive"] = True
temp_path = sarif_path.with_name(f"{sarif_path.name}.tmp")
temp_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8")
temp_path.replace(sarif_path)
print(
"OSV reporter SARIF contains "
f"{total_results} result(s); marked the code-scanning analysis "
"comprehensive so fixed PR-introduced alerts close after a clean "
"base/head comparison."
)
- name: Upload OSV SARIF to code scanning
id: upload_osv_sarif
if: always() && hashFiles('results.sarif') != ''
# The reporter above is the vulnerability gate. Preserve an upload
# quota failure in this step's log without reclassifying it as a CVE.
continue-on-error: true
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: results.sarif
# 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".
ref: refs/pull/${{ github.event.pull_request.number }}/head
sha: ${{ github.event.pull_request.head.sha }}
wait-for-processing: false
- name: Report OSV SARIF upload failure
if: steps.upload_osv_sarif.outcome == 'failure'
run: |
echo "::warning::OSV SARIF upload to code scanning failed after the base/head comparison. The PR-introduced vulnerability reporter above remains the hard gate, so upload rate limits cannot hide OSV findings."
- name: Upload OSV debug artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v6.0.0
with:
name: osv-scan-debug
path: |
old-results.json
new-results.json
results.sarif
if-no-files-found: ignore
retention-days: 5
dependency-review:
needs: changed-scope
if: github.event.action != 'closed' && needs.changed-scope.outputs.deps == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout exact dependency-review head
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Verify Dependency Review head checkout
env:
EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
actual_sha="$(git rev-parse HEAD)"
if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then
echo "::error::Dependency Review checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}."
exit 1
fi
echo "SECURITY_CHECKOUT scanner=dependency-review revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}"
- name: Check dependency review support
id: dependency_review_support
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}
run: |
set -euo pipefail
api_url="${GITHUB_API_URL:-https://api.github.com}"
set +e
status="$(
curl -sS --connect-timeout 10 --max-time 30 \
-o /dev/null \
-w '%{http_code}' \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}"
)"
curl_status=$?
set -e
case "$status" in
[0-9][0-9][0-9]) http_status="$status" ;;
"") http_status="unavailable" ;;
*) http_status="malformed" ;;
esac
case "${REPOSITORY_VISIBILITY:-}" in
public | private | internal) repository_visibility="$REPOSITORY_VISIBILITY" ;;
*) repository_visibility="unknown" ;;
esac
echo "DEPENDENCY_REVIEW_SUPPORT repository=${REPOSITORY} visibility=${repository_visibility} base_sha=${BASE_SHA} head_sha=${HEAD_SHA} http_status=${http_status} curl_exit=${curl_status}"
if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then
echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: HTTP ${http_status}; curl exit ${curl_status}. Verify dependency-graph/security configuration and GitHub service behavior, then rerun. Failing closed."
exit 1
fi
echo "supported=true" >>"$GITHUB_OUTPUT"
- name: Dependency review
if: steps.dependency_review_support.outputs.supported == 'true'
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
base-ref: ${{ github.event.pull_request.base.sha }}
head-ref: ${{ github.event.pull_request.head.sha }}
fail-on-severity: moderate
comment-summary-in-pr: never
# Keep the existing central-repository Gitleaks PR gate inside the required
# security bundle. It deliberately does not depend on changed-scope: secrets
# in Markdown or other document-only changes must still fail the PR. The
# repository condition preserves the standalone workflow's previous scope;
# push, schedule, and manual backstops remain in secret-scan.yml.
gitleaks:
name: gitleaks (secret scan)
if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github'
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
actions: read
env:
GITLEAKS_VERSION: "8.30.1"
GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout PR commit range
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Install gitleaks (pinned, checksum-verified)
run: |
set -euo pipefail
url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -fsSL "$url" -o gitleaks.tar.gz
echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c -
tar -xzf gitleaks.tar.gz gitleaks
chmod +x gitleaks
./gitleaks version
- name: Run gitleaks on PR commit range
id: gitleaks
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set +e
config_args=()
if [ -f .gitleaks.toml ]; then
config_args=(--config .gitleaks.toml)
fi
log_opts="${BASE_SHA}..${HEAD_SHA}"
echo "::notice::gitleaks scanning pull request commit range ${log_opts}."
./gitleaks git . \
"${config_args[@]}" \
--log-opts="${log_opts}" \
--redact \
--report-format sarif \
--report-path gitleaks-results.sarif \
--exit-code 2
echo "rc=$?" >> "$GITHUB_OUTPUT"
set -e
- name: Summarize redacted gitleaks findings
if: always() && hashFiles('gitleaks-results.sarif') != ''
run: |
set -euo pipefail
count="$(jq '[.runs[].results[]?] | length' gitleaks-results.sarif)"
if [ "$count" = "0" ]; then
echo "::notice::gitleaks completed with no findings."
exit 0
fi
echo "::error::gitleaks reported ${count} redacted finding(s). Rule, path, and line summary follows; secret values are not printed."
jq -r '
.runs[].results[]?
| "- rule: `" + (.ruleId // "unknown") + "`"
+ ", path: `" + (.locations[0].physicalLocation.artifactLocation.uri // "unknown") + "`"
+ ", line: `" + ((.locations[0].physicalLocation.region.startLine // "unknown") | tostring) + "`"
' gitleaks-results.sarif | sort | uniq -c
- name: Filter test-classified Gitleaks SARIF results
if: always() && hashFiles('gitleaks-results.sarif') != ''
run: |
python3 scripts/ci/filter_gitleaks_sarif.py \
gitleaks-results.sarif \
gitleaks-results.upload.sarif
- name: Upload gitleaks SARIF to code scanning
if: always() && hashFiles('gitleaks-results.upload.sarif') != ''
continue-on-error: true
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: gitleaks-results.upload.sarif
category: gitleaks
ref: refs/pull/${{ github.event.pull_request.number }}/head
sha: ${{ github.event.pull_request.head.sha }}
wait-for-processing: false
- name: Enforce secret-scan gate
if: steps.gitleaks.outputs.rc != '0'
run: |
echo "::error::gitleaks detected potential secrets (exit ${{ steps.gitleaks.outputs.rc }}). Rotate any exposed credential and scrub history."
exit 1
trivy-fs:
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
steps:
- name: Checkout exact Trivy head
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Verify Trivy head checkout
env:
EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
actual_sha="$(git rev-parse HEAD)"
if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then
echo "::error::Trivy checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}."
exit 1
fi
echo "SECURITY_CHECKOUT scanner=trivy-fs revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}"
- name: Trivy filesystem scan
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
severity: CRITICAL,HIGH,MEDIUM
ignore-unfixed: true
format: sarif
output: trivy-results.sarif
exit-code: "0"
# Without this, trivy-action rebuilds the SARIF scan with ALL
# severities and the parser below would gate LOW findings too,
# contradicting the documented MEDIUM-or-higher gate above.
limit-severities-for-sarif: true
- name: Require Trivy SARIF output
run: |
set -euo pipefail
if [ ! -s trivy-results.sarif ]; then
echo "::error::Trivy did not produce trivy-results.sarif; inspect the Trivy filesystem scan logs above."
exit 1
fi
- name: Print Trivy findings that failed the gate
# SARIF-only output otherwise leaves failures as just "exit code 1".
shell: python3 {0}
run: |
import json, pathlib
sarif = json.loads(pathlib.Path("trivy-results.sarif").read_text(encoding="utf-8"))
findings = []
for run in sarif.get("runs", []):
rules = {r["id"]: r for r in run.get("tool", {}).get("driver", {}).get("rules", [])}
for result in run.get("results", []):
rule = rules.get(result.get("ruleId", ""), {})
severity = rule.get("properties", {}).get("security-severity", "?")
lines = (result.get("message", {}).get("text") or "").strip().splitlines()
fields = {}
for entry in lines:
key, sep, value = entry.partition(":")
if sep:
fields[key.strip().lower()] = value.strip()
if fields.get("severity"):
severity = f"{fields['severity']} (security-severity={severity})"
message = fields.get("message") or (lines[0] if lines else result.get("ruleId", ""))
locations = result.get("locations", [])
if locations:
phys = locations[0].get("physicalLocation", {})
uri = phys.get("artifactLocation", {}).get("uri", "?")
line = phys.get("region", {}).get("startLine", "?")
where = f"{uri}:{line}"
else:
where = "-"
findings.append((severity, result.get("ruleId", "?"), where, message))
if not findings:
print("Trivy filesystem scan completed with 0 CRITICAL/HIGH/MEDIUM findings in trivy-results.sarif.")
else:
print(f"Trivy filesystem scan reported {len(findings)} finding(s):")
for severity, rule_id, where, message in findings:
print(f" [{severity}] {rule_id} {where} - {message}")
print("")
print("Remediate each finding at the shared base branch so open PRs inherit the fix.")
raise SystemExit(1)
- name: Upload Trivy SARIF to code scanning
id: upload_trivy_sarif
if: always() && hashFiles('trivy-results.sarif') != ''
# The parser above fails on every fixable Medium+ finding independently.
continue-on-error: true
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-results.sarif
category: trivy-fs
ref: refs/pull/${{ github.event.pull_request.number }}/head
sha: ${{ github.event.pull_request.head.sha }}
wait-for-processing: false
- name: Report Trivy SARIF upload failure
if: steps.upload_trivy_sarif.outcome == 'failure'
run: |
echo "::warning::Trivy SARIF upload to code scanning failed after the filesystem scan. The Trivy finding log above remains the hard gate, so upload rate limits cannot hide CRITICAL/HIGH/MEDIUM findings."
scorecard:
needs: changed-scope
if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true'
runs-on: ubuntu-24.04
# SOFT: posture findings are unrelated to the PR diff, so never block merge.
continue-on-error: true
permissions:
security-events: write
contents: read
actions: read
steps:
- name: Checkout exact Scorecard head
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Verify Scorecard head checkout
env:
EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
actual_sha="$(git rev-parse HEAD)"
if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then
echo "::error::Scorecard checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}."
exit 1
fi
echo "SECURITY_CHECKOUT scanner=scorecard revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}"
- name: Run Scorecard
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: false
- name: Filter delegated PR-only Scorecard SARIF findings
run: |
python3 <<'PY'
import json
import pathlib
PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"}
PR_GOVERNANCE_RULE_IDS = {"FuzzingID"}
PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS
sarif_path = pathlib.Path("results.sarif")
sarif = json.loads(sarif_path.read_text(encoding="utf-8"))
hard_gate_delegated = 0
governance_delegated = 0
for run in sarif.get("runs", []):
kept = []
for result in run.get("results", []):
rule_id = result.get("ruleId")
if rule_id in PR_DELEGATED_RULE_IDS:
if rule_id in PR_HARD_GATE_RULE_IDS:
hard_gate_delegated += 1
if rule_id in PR_GOVERNANCE_RULE_IDS:
governance_delegated += 1
continue
kept.append(result)
run["results"] = kept
filtered_path = sarif_path.with_name(f"{sarif_path.name}.filtered")
filtered_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8")
filtered_path.replace(sarif_path)
print(
"Delegated "
f"{hard_gate_delegated} PR-only Scorecard SAST/vulnerability finding(s) to "
"CodeQL, OSV, Trivy, and dependency-review hard gates."
)
print(
"Delegated "
f"{governance_delegated} PR-only Scorecard fuzzing posture finding(s) "
"to default-branch governance tracking."
)
PY
- name: Upload Scorecard SARIF to code scanning
id: upload_scorecard_sarif
# Scorecard is soft repository-posture evidence; upload quota is external.
continue-on-error: true
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: results.sarif
category: scorecard
ref: refs/pull/${{ github.event.pull_request.number }}/head
sha: ${{ github.event.pull_request.head.sha }}
wait-for-processing: false
- name: Report Scorecard SARIF upload failure
if: steps.upload_scorecard_sarif.outcome == 'failure'
run: |
echo "::warning::Scorecard SARIF upload to code scanning failed after delegated PR-only findings were filtered. Scorecard is PR posture evidence only; CodeQL, OSV, Trivy, and dependency-review remain the hard gates."