Skip to content

feat(automation): route trusted review-agent mentions #2961

feat(automation): route trusted review-agent mentions

feat(automation): route trusted review-agent mentions #2961

Workflow file for this run

# 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
# scorecard SOFT repo posture — uploaded for visibility, never blocks
#
# 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: dependency graph can be unavailable on some repos.
# Treat that as "not enforceable here" instead of making the required workflow
# unsatisfiable; keep medium-or-higher dependency findings hard-failing where the
# API is supported.
#
# 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.
name: Security Scan
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, closed]
branches: [main, master, develop]
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:
cancel-closed-pr-runs:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: echo "PR closed; this run only cancels older runs through workflow concurrency."
osv-scan:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
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 }}
fetch-depth: 0
persist-credentials: false
- name: Scan base with OSV
id: osv_base
continue-on-error: true
timeout-minutes: 8
uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8
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
./
- 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@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8
with:
scan-args: |
--format=json
--output=old-results.json
--no-resolve
--allow-no-lockfiles
-r
./
- 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 }}
fetch-depth: 0
clean: false
persist-credentials: false
- name: Scan head with OSV
id: osv_head
continue-on-error: true
timeout-minutes: 8
uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8
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
./
- 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@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8
with:
scan-args: |
--format=json
--output=new-results.json
--no-resolve
--allow-no-lockfiles
-r
./
- 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@8dc09193bb540e09b23da07ad7e30bd33bf87018 # 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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
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:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- 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 }}
run: |
set -euo pipefail
api_url="${GITHUB_API_URL:-https://api.github.com}"
response_file="$(mktemp)"
status="$(
curl -fsS -o "$response_file" -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}" \
|| true
)"
if [ "$status" = "200" ]; then
echo "supported=true" >>"$GITHUB_OUTPUT"
exit 0
fi
if [ "$status" = "403" ] || [ "$status" = "404" ]; then
echo "::warning::Dependency review is unavailable for ${REPOSITORY}; skipping dependency-review hard gate."
echo "supported=false" >>"$GITHUB_OUTPUT"
exit 0
fi
echo "::error::Dependency review support check failed with HTTP ${status}."
cat "$response_file"
exit 1
- name: Dependency review
if: steps.dependency_review_support.outputs.supported == 'true'
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: moderate
comment-summary-in-pr: on-failure
trivy-fs:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- 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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
sarif_file: trivy-results.sarif
category: trivy-fs
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:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
# 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
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- 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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
sarif_file: results.sarif
category: scorecard
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."