Skip to content

Commit f6d4e60

Browse files
Merge branch 'main' into vp/security-bump-followup
2 parents d112b5e + 11d3f59 commit f6d4e60

2 files changed

Lines changed: 328 additions & 0 deletions

File tree

.github/workflows/securityScan.yml

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
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

osv-scanner.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# OSV-Scanner suppressions for the databricks-sql-nodejs security gate.
2+
#
3+
# Each entry suppresses a CVE that is a documented false positive
4+
# against an artifact we ship, or is a dev-only finding that doesn't
5+
# reach the shipped `dist/`. Every entry has a justification.
6+
#
7+
# Trade-off worth noting: [[IgnoredVulns]] entries are CVE-id global --
8+
# they ignore the CVE across all packages OSV reports it against, not
9+
# just the artifact we have in mind. The alternative
10+
# ([[PackageOverrides]] with `vulnerability.ignore = true`) is
11+
# per-package but blanket-ignores ALL vulnerabilities on that package,
12+
# which is much worse. OSV-Scanner v2.3.8 does NOT support an
13+
# intersection ("this CVE on this package only").
14+
#
15+
# See google.github.io/osv-scanner/configuration/ for the schema.
16+
#
17+
# CONVENTION: use suppressions sparingly, only with a strong reason
18+
# (unreachable code path + no fix available, or dev-only + not shipped).
19+
# EVERY [[IgnoredVulns]] entry MUST set `ignoreUntil = "YYYY-MM-DD"`
20+
# (~6 months out). OSV-Scanner v2.3.8 honors it natively; when it lapses
21+
# the finding re-surfaces, forcing a re-review instead of a permanent
22+
# silent ignore.
23+
#
24+
# Example:
25+
# [[IgnoredVulns]]
26+
# id = "GHSA-xxxx-xxxx-xxxx"
27+
# ignoreUntil = "2026-01-15"
28+
# reason = "dev-only (eslint toolchain); not reachable from shipped dist/."
29+
#
30+
# This file starts empty -- populate iteratively as the first scan run
31+
# surfaces real false positives or dev-only findings worth excluding.
32+
# Do not pre-populate with speculative suppressions.

0 commit comments

Comments
 (0)