test(personfit): execute deterministic Monte Carlo acceptance #7379
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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." |