|
| 1 | +name: Security Scan |
| 2 | + |
| 3 | +# Single workflow, single job. Triggered three ways with DIFFERENT |
| 4 | +# thresholds: |
| 5 | +# |
| 6 | +# - pull_request to main: fail the job on any unsuppressed |
| 7 | +# CVSS >= 7 finding (HIGH+), or any unscored finding. MEDIUM/LOW |
| 8 | +# findings show in the step summary but don't block merges. Not yet |
| 9 | +# required-to-merge in branch protection. |
| 10 | +# |
| 11 | +# - cron (weekly): report ALL findings regardless of severity, fail the |
| 12 | +# job on any finding, and upload the raw scan output as an artifact. |
| 13 | +# The intent is full situational awareness -- emerging MEDIUM risks |
| 14 | +# should be visible before they cross the PR gate. A separate |
| 15 | +# cross-repo action collates the uploaded artifacts across all driver |
| 16 | +# repos and sends a single digest; this job does NOT email directly. |
| 17 | +# |
| 18 | +# - workflow_dispatch: behaves like the cron run (full reporting). |
| 19 | +# |
| 20 | +# Scanner: OSV-Scanner v2.3.8 (purl-based via OSV.dev; federates GHSA, |
| 21 | +# NVD, npm advisory DB, RustSec, Go vuln DB, PyPA). Reads |
| 22 | +# `package-lock.json` natively -- no separate SBOM tool needed. |
| 23 | +# |
| 24 | +# NOTE: this scans BOTH runtime and devDependencies (OSV treats |
| 25 | +# everything in package-lock.json equally). If a finding is dev-only |
| 26 | +# and shouldn't block merges, suppress it via osv-scanner.toml with a |
| 27 | +# justification ("dev-only, not shipped in dist/"). |
| 28 | +# |
| 29 | +# Suppressions live in `osv-scanner.toml` as [[IgnoredVulns]] entries |
| 30 | +# (CVE-id global; OSV-Scanner v2.3.8 doesn't support per-package CVE |
| 31 | +# scoping). Each entry has a justification comment and an `ignoreUntil` |
| 32 | +# expiry so suppressions re-surface for re-review rather than lingering. |
| 33 | + |
| 34 | +on: |
| 35 | + pull_request: |
| 36 | + branches: [main] |
| 37 | + schedule: |
| 38 | + - cron: '0 0 * * 0' # Run every Sunday at midnight UTC |
| 39 | + workflow_dispatch: |
| 40 | + |
| 41 | +permissions: |
| 42 | + id-token: write |
| 43 | + contents: read |
| 44 | + |
| 45 | +jobs: |
| 46 | + security-scan: |
| 47 | + name: Security Scan |
| 48 | + runs-on: |
| 49 | + group: databricks-protected-runner-group |
| 50 | + labels: linux-ubuntu-latest |
| 51 | + |
| 52 | + steps: |
| 53 | + - name: Checkout repository |
| 54 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 55 | + |
| 56 | + # JFrog OIDC + npm registry: skipped on fork PRs (no OIDC token |
| 57 | + # from GitHub's perspective). OSV-Scanner reads package-lock.json |
| 58 | + # directly without fetching from the npm registry, so fork PRs |
| 59 | + # still work; we keep setup-jfrog here only for parity with the |
| 60 | + # other workflows in this repo. If you remove it later, also |
| 61 | + # remove the `id-token: write` permission above. |
| 62 | + - name: Setup JFrog |
| 63 | + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository |
| 64 | + uses: ./.github/actions/setup-jfrog |
| 65 | + |
| 66 | + - name: Install osv-scanner |
| 67 | + run: | |
| 68 | + set -euo pipefail |
| 69 | + curl -fsSL -o /tmp/osv-scanner \ |
| 70 | + https://github.com/google/osv-scanner/releases/download/v2.3.8/osv-scanner_linux_amd64 |
| 71 | + chmod +x /tmp/osv-scanner |
| 72 | + /tmp/osv-scanner --version |
| 73 | +
|
| 74 | + - name: Run OSV-Scanner |
| 75 | + # osv-scanner's exit codes are meaningful and we must NOT blanket |
| 76 | + # `|| true` them: exit 0 = no vulns, exit 1 = vulns found (expected |
| 77 | + # -- our real gate is the CVSS>=7 filter below), any OTHER non-zero |
| 78 | + # (126/127/128+, network error reaching OSV.dev, corrupt binary, |
| 79 | + # partial DB load) = a scanner error that must fail the job CLOSED. |
| 80 | + # A blanket `|| true` + zero-byte guard let an errored-but-non-empty |
| 81 | + # output pass as "clean" (fail-open); capture and classify the code. |
| 82 | + run: | |
| 83 | + set -uo pipefail |
| 84 | +
|
| 85 | + if [ ! -f package-lock.json ]; then |
| 86 | + echo "::error::package-lock.json not found at repo root." |
| 87 | + exit 1 |
| 88 | + fi |
| 89 | +
|
| 90 | + scan_rc=0 |
| 91 | + /tmp/osv-scanner scan source \ |
| 92 | + --lockfile=package-lock.json \ |
| 93 | + --config=osv-scanner.toml \ |
| 94 | + --format=json \ |
| 95 | + --output-file=/tmp/osv-out.json \ |
| 96 | + || scan_rc=$? |
| 97 | +
|
| 98 | + # Tolerate only 0 (clean) and 1 (findings present). Anything else |
| 99 | + # is a scanner failure -> fail closed rather than reporting zero. |
| 100 | + if [ "$scan_rc" -ne 0 ] && [ "$scan_rc" -ne 1 ]; then |
| 101 | + echo "::error::OSV-Scanner exited $scan_rc (scanner error, not a findings result). Failing closed." |
| 102 | + exit 1 |
| 103 | + fi |
| 104 | +
|
| 105 | + if [ ! -s /tmp/osv-out.json ]; then |
| 106 | + echo "::error::OSV-Scanner did not produce an output file." |
| 107 | + exit 1 |
| 108 | + fi |
| 109 | +
|
| 110 | + # Validate the output is well-formed JSON with a results array |
| 111 | + # before any downstream parsing. A truncated/partial write is |
| 112 | + # non-empty (defeats the -s guard) but unparseable -- catch it |
| 113 | + # here so it fails closed instead of parsing to zero findings. |
| 114 | + if ! jq -e 'has("results") and (.results | type == "array")' /tmp/osv-out.json >/dev/null 2>&1; then |
| 115 | + echo "::error::OSV-Scanner output is not valid JSON with a .results array (partial/corrupt scan). Failing closed." |
| 116 | + exit 1 |
| 117 | + fi |
| 118 | +
|
| 119 | + # Parse OSV's JSON into job outputs. The terminal steps below |
| 120 | + # (PR-fail and scheduled-fail) consume these outputs. |
| 121 | + # |
| 122 | + # Two thresholds: PR gating uses CVSS >= 7 (high_count) so we don't |
| 123 | + # block merges on MEDIUM/LOW noise; the weekly reports everything |
| 124 | + # (total_findings) so the team has full situational awareness of |
| 125 | + # emerging risk before it crosses the gate. |
| 126 | + - name: Collect findings |
| 127 | + id: findings |
| 128 | + run: | |
| 129 | + set -uo pipefail |
| 130 | +
|
| 131 | + # All findings (sorted by severity desc). |
| 132 | + # |
| 133 | + # Severity resolution is defense-in-depth against fail-open: |
| 134 | + # OSV's group-level `.max_severity` is EMPTY ("") for advisory |
| 135 | + # groups that lack a CVSS vector (common for GHSA-only and MAL-* |
| 136 | + # malware advisories). jq's `//` only coalesces null/false -- an |
| 137 | + # empty string is truthy and would pass through, then |
| 138 | + # `"" | tonumber? // 0` scores it 0, silently sailing a real HIGH |
| 139 | + # past the CVSS>=7 gate. So we do NOT trust max_severity alone: |
| 140 | + # 1. Try group `.max_severity` (numeric only; empty/"" -> null). |
| 141 | + # 2. Fall back to the max CVSS score across the group's own |
| 142 | + # vulnerabilities' `.severities[].score` (parsed from the |
| 143 | + # CVSS vector's numeric base score when present). |
| 144 | + # 3. If still unresolved, emit the sentinel "UNKNOWN" (a |
| 145 | + # non-numeric string), never scored 0. |
| 146 | + # |
| 147 | + # BLOCKING RULE: unlike the Go driver (whose scoreless findings are |
| 148 | + # mostly Go-stdlib advisories delivered via GOTOOLCHAIN and thus |
| 149 | + # report-only), npm advisories reliably carry a CVSS score. A |
| 150 | + # scoreless UNKNOWN here means a GHSA-only or MAL-* malware advisory |
| 151 | + # on a package we depend on -- always BLOCKING (fail closed). |
| 152 | + # |
| 153 | + # NOTE ON jq NUMBER PARSING: use `try (x|tonumber) catch null`, |
| 154 | + # NOT `x|tonumber?`. On a non-numeric string, `tonumber?` yields |
| 155 | + # EMPTY (not null); inside an `... as $var` binding that makes the |
| 156 | + # entire finding row vanish -- silently dropping a scoreless HIGH. |
| 157 | + # try/catch normalizes non-numeric to null so the row survives and |
| 158 | + # the fallback logic runs. |
| 159 | + ALL_FINDINGS=$(jq -c ' |
| 160 | + def cvss_num($sev): |
| 161 | + # OSV severity entries: {type:"CVSS_V3", score:"9.8"} (numeric) |
| 162 | + # or a full vector string. Take numeric scores only; vectors |
| 163 | + # without a bare numeric score contribute nothing (null). |
| 164 | + ($sev // []) | map(try (.score | tonumber) catch null) |
| 165 | + | map(select(. != null)) | (max // null); |
| 166 | + [ |
| 167 | + .results[].packages[]? | |
| 168 | + .package as $pkg | |
| 169 | + (.vulnerabilities // []) as $vulns | |
| 170 | + .groups[]? | |
| 171 | + .ids as $gids | |
| 172 | + # max CVSS across the vulnerabilities referenced by this group |
| 173 | + ([ $vulns[] | select(.id as $id | ($gids | index($id)) != null) | cvss_num(.severities) ] |
| 174 | + | map(select(. != null)) | (max // null)) as $vuln_score | |
| 175 | + (try (.max_severity | tonumber) catch null) as $grp_score | |
| 176 | + ($grp_score // $vuln_score) as $resolved | |
| 177 | + { |
| 178 | + pkg: ($pkg.name + "@" + $pkg.version), |
| 179 | + ids: .ids, |
| 180 | + severity: (if $resolved == null then "UNKNOWN" else ($resolved | tostring) end) |
| 181 | + } |
| 182 | + ] | sort_by(if (try (.severity | tonumber) catch null) == null then -1 else - (.severity | tonumber) end) |
| 183 | + ' /tmp/osv-out.json) |
| 184 | + TOTAL_FINDINGS=$(echo "$ALL_FINDINGS" | jq 'length') |
| 185 | +
|
| 186 | + # Scoreless (UNKNOWN) findings -- all blocking (see note above). |
| 187 | + UNKNOWN_COUNT=$(echo "$ALL_FINDINGS" | jq '[.[] | select(.severity == "UNKNOWN")] | length') |
| 188 | +
|
| 189 | + # Blocking findings = CVSS >= 7 (any package) OR scoreless UNKNOWN. |
| 190 | + HIGH_FINDINGS=$(echo "$ALL_FINDINGS" | jq -c '[.[] | select(((.severity | tonumber? // 0) >= 7) or (.severity == "UNKNOWN"))]') |
| 191 | + HIGH_COUNT=$(echo "$HIGH_FINDINGS" | jq 'length') |
| 192 | +
|
| 193 | + # Guard against empty counts propagating to the numeric gates |
| 194 | + # below. If any jq above failed, the var would be "" and |
| 195 | + # `[ "$X" -gt 0 ]` errors / `'' != '0'` reads true. Default to 0 |
| 196 | + # and, since a failed parse should never be silently "clean", |
| 197 | + # fail closed if the counts didn't resolve to integers. |
| 198 | + TOTAL_FINDINGS=${TOTAL_FINDINGS:-} |
| 199 | + HIGH_COUNT=${HIGH_COUNT:-} |
| 200 | + UNKNOWN_COUNT=${UNKNOWN_COUNT:-} |
| 201 | + if ! [[ "$TOTAL_FINDINGS" =~ ^[0-9]+$ ]] || ! [[ "$HIGH_COUNT" =~ ^[0-9]+$ ]]; then |
| 202 | + echo "::error::Could not compute finding counts from OSV output (parse failure). Failing closed." |
| 203 | + exit 1 |
| 204 | + fi |
| 205 | +
|
| 206 | + # Persist the full findings list to a file rather than a job |
| 207 | + # output -- GitHub Actions outputs are size-capped at 1 MB and |
| 208 | + # the formatted finding list can be larger than that. |
| 209 | + echo "$ALL_FINDINGS" > /tmp/all-findings.json |
| 210 | +
|
| 211 | + echo "total_findings=$TOTAL_FINDINGS" >> "$GITHUB_OUTPUT" |
| 212 | + echo "high_count=$HIGH_COUNT" >> "$GITHUB_OUTPUT" |
| 213 | + echo "unknown_count=$UNKNOWN_COUNT" >> "$GITHUB_OUTPUT" |
| 214 | +
|
| 215 | + # Step summary so findings are visible in the GH Actions UI |
| 216 | + # without downloading artifacts. |
| 217 | + { |
| 218 | + echo "## OSV-Scanner Findings" |
| 219 | + echo "" |
| 220 | + echo "- Total findings (any severity): \`$TOTAL_FINDINGS\`" |
| 221 | + echo "- Blocking findings (CVSS >= 7, or unscored; PR-blocking): \`$HIGH_COUNT\`" |
| 222 | + echo "- Unscored/UNKNOWN findings: \`$UNKNOWN_COUNT\` (all blocking)" |
| 223 | + if [ "$TOTAL_FINDINGS" -gt 0 ]; then |
| 224 | + echo "" |
| 225 | + echo "All findings (sorted by severity desc):" |
| 226 | + echo "" |
| 227 | + echo "| Severity | Package | IDs |" |
| 228 | + echo "|---|---|---|" |
| 229 | + echo "$ALL_FINDINGS" | jq -r '.[] | "| \(.severity) | \(.pkg) | \(.ids | join(",")) |"' |
| 230 | + fi |
| 231 | + } >> "$GITHUB_STEP_SUMMARY" |
| 232 | +
|
| 233 | + # Also dump the findings to the job log so they're visible in |
| 234 | + # the default "Logs" view, not just the step summary panel. |
| 235 | + echo "OSV: $TOTAL_FINDINGS total findings, $HIGH_COUNT blocking (CVSS>=7 or unscored)" |
| 236 | + if [ "$TOTAL_FINDINGS" -gt 0 ]; then |
| 237 | + echo "" |
| 238 | + echo "All findings (sorted by severity desc):" |
| 239 | + echo "$ALL_FINDINGS" | jq -r '.[] | " [\(.severity)] \(.pkg) \(.ids | join(", "))"' |
| 240 | + fi |
| 241 | +
|
| 242 | + # --- Terminal: PR event --- |
| 243 | + # Fail the job so the PR's check goes red. No email. |
| 244 | + # PR gate is CVSS >= 7 (or unscored) only; MEDIUM/LOW findings show |
| 245 | + # up in the step summary but don't block merges. |
| 246 | + - name: Fail on findings (PR) |
| 247 | + if: github.event_name == 'pull_request' && steps.findings.outputs.high_count != '0' |
| 248 | + run: | |
| 249 | + set -uo pipefail |
| 250 | + # List the actual blocking findings inline so the author sees what |
| 251 | + # needs fixing without clicking through to the step summary |
| 252 | + # panel or downloading artifacts. |
| 253 | + HIGH_FINDINGS=$(jq -c '[.[] | select(((.severity | tonumber? // 0) >= 7) or (.severity == "UNKNOWN"))]' /tmp/all-findings.json) |
| 254 | +
|
| 255 | + echo "::error::${{ steps.findings.outputs.high_count }} unsuppressed blocking finding(s) (CVSS>=7, or unscored) in this PR:" |
| 256 | + echo "" |
| 257 | + echo "$HIGH_FINDINGS" | jq -r '.[] | " [\(.severity)] \(.pkg) \(.ids | join(", "))"' |
| 258 | + echo "" |
| 259 | + echo "Fix by either:" |
| 260 | + echo " 1. Bumping the affected dependency to a patched version, or" |
| 261 | + echo " 2. Adding a documented [[IgnoredVulns]] entry to osv-scanner.toml" |
| 262 | + echo " with a clear justification for why the CVE doesn't apply to our usage." |
| 263 | + echo "" |
| 264 | + echo "Full step summary: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" |
| 265 | + exit 1 |
| 266 | +
|
| 267 | + # --- Terminal: scheduled/manual event --- |
| 268 | + # Weekly reports ALL findings (not just CVSS >= 7) so emerging risk is |
| 269 | + # visible before it crosses the PR gate. PR-time is narrower to avoid |
| 270 | + # blocking on MEDIUM/LOW noise; weekly is broader for situational |
| 271 | + # awareness. |
| 272 | + # |
| 273 | + # Notification is intentionally NOT done here: a separate cross-repo |
| 274 | + # action collates findings from all driver repos and sends a single |
| 275 | + # digest. This job's job is to (a) fail so the scheduled run is red |
| 276 | + # when anything is found, and (b) upload the raw osv-out.json artifact |
| 277 | + # for the collator to consume. |
| 278 | + - name: Fail on findings (scheduled/manual) |
| 279 | + if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && steps.findings.outputs.total_findings != '0' |
| 280 | + run: | |
| 281 | + echo "::error::${{ steps.findings.outputs.total_findings }} OSV finding(s) on main (${{ steps.findings.outputs.high_count }} blocking at CVSS>=7 or unscored). See the security-scan-reports artifact." |
| 282 | + exit 1 |
| 283 | +
|
| 284 | + # Always upload the raw scan output so triagers -- and the planned |
| 285 | + # cross-repo collation/notification action -- can pull findings |
| 286 | + # without rerunning. This is the machine-readable source of truth now |
| 287 | + # that per-repo email has been removed. |
| 288 | + - name: Upload reports |
| 289 | + if: always() |
| 290 | + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 |
| 291 | + with: |
| 292 | + name: security-scan-reports |
| 293 | + path: | |
| 294 | + /tmp/osv-out.json |
| 295 | + /tmp/all-findings.json |
| 296 | + if-no-files-found: ignore |
0 commit comments