Skip to content
Merged
316 changes: 316 additions & 0 deletions .github/workflows/securityScan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
name: Security Scan

# Single workflow, single job. Triggered three ways with DIFFERENT
# thresholds:
#
# - pull_request to main: fail the job on any unsuppressed
# CVSS >= 7 finding (HIGH+). MEDIUM/LOW findings show in the step
# summary but don't block merges. Not yet required-to-merge in
# branch protection.
#
# - cron (weekly): report ALL findings regardless of severity. Sends
# an email with the full sorted list and fails the job on any
# finding. The intent is full situational awareness for the team --
# emerging MEDIUM risks should be visible before they cross the PR
# gate, and the weekly is read by humans, not enforced by code.
#
# - workflow_dispatch: behaves like the cron run (full reporting).
#
# Scanner: OSV-Scanner v2.3.8 (purl-based via OSV.dev; federates GHSA,
# NVD, Go vuln DB, RustSec, PyPA). Reads `go.mod`/`go.sum` natively --
# no separate SBOM tool needed.
#
# Suppressions live in `osv-scanner.toml` as [[IgnoredVulns]] entries
# (CVE-id global; OSV-Scanner v2.3.8 doesn't support per-package CVE
# scoping). Each entry has a justification comment.

on:
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * 0' # Run every Sunday at midnight UTC
workflow_dispatch:

permissions:
id-token: write
contents: read

jobs:
security-scan:
name: Security Scan
runs-on:
group: databricks-protected-runner-group
labels: linux-ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# JFrog OIDC + GOPROXY: skipped on fork PRs (no OIDC token from
# GitHub's perspective). Fork PRs still work because OSV-Scanner
# reads `go.mod` directly without needing to download modules.
- name: Setup JFrog
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/actions/setup-jfrog

- name: Set up Go Toolchain
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
# Must be >= the go.mod directive (1.25.0). A floating '1.25.x'
# resolves to the runner's cached latest 1.25 patch, which always
# satisfies the 1.25.0 floor. osv-scanner shells out to
# govulncheck, which loads the module with this toolchain; keeping
# the directive at the honest 1.25.0 floor (not an inflated patch)
# means '1.25.x' never falls below it.
go-version: '1.25.x'
cache: false

- name: Install osv-scanner
run: |
set -euo pipefail
curl -fsSL -o /tmp/osv-scanner \
https://github.com/google/osv-scanner/releases/download/v2.3.8/osv-scanner_linux_amd64
chmod +x /tmp/osv-scanner
/tmp/osv-scanner --version

- name: Run OSV-Scanner
# osv-scanner's exit codes are meaningful and we must NOT blanket
# `|| true` them: exit 0 = no vulns, exit 1 = vulns found (expected
# -- our real gate is the CVSS>=7 filter below), any OTHER non-zero
# (126/127/128+, network error reaching OSV.dev, corrupt binary,
# partial DB load) = a scanner error that must fail the job CLOSED.
# A blanket `|| true` + zero-byte guard let an errored-but-non-empty
# output pass as "clean" (fail-open); capture and classify the code.
run: |
set -uo pipefail

if [ ! -f go.mod ]; then
echo "::error::go.mod not found at repo root."
exit 1
fi

scan_rc=0
/tmp/osv-scanner scan source \
--lockfile=go.mod \
--config=osv-scanner.toml \
--format=json \
--output-file=/tmp/osv-out.json \
|| scan_rc=$?

# Tolerate only 0 (clean) and 1 (findings present). Anything else
# is a scanner failure -> fail closed rather than reporting zero.
if [ "$scan_rc" -ne 0 ] && [ "$scan_rc" -ne 1 ]; then
echo "::error::OSV-Scanner exited $scan_rc (scanner error, not a findings result). Failing closed."
exit 1
fi

if [ ! -s /tmp/osv-out.json ]; then
echo "::error::OSV-Scanner did not produce an output file."
exit 1
fi

# Validate the output is well-formed JSON with a results array
# before any downstream parsing. A truncated/partial write is
# non-empty (defeats the -s guard) but unparseable -- catch it
# here so it fails closed instead of parsing to zero findings.
if ! jq -e 'has("results") and (.results | type == "array")' /tmp/osv-out.json >/dev/null 2>&1; then
echo "::error::OSV-Scanner output is not valid JSON with a .results array (partial/corrupt scan). Failing closed."
exit 1
fi

# Parse OSV's JSON into job outputs. The terminal steps below
# (PR-fail and email) consume these outputs.
#
# Two thresholds: PR gating uses CVSS >= 7 (high_count) so we don't
# block merges on MEDIUM/LOW noise; the weekly email reports
# everything (total_findings) so the team has full situational
# awareness of emerging risk before it crosses the gate.
- name: Collect findings
id: findings
run: |
set -uo pipefail

# All findings (sorted by severity desc).
#
# Severity resolution is defense-in-depth against fail-open:
# OSV's group-level `.max_severity` is EMPTY ("") for advisory
# groups that lack a CVSS vector (common for GHSA-only, GO-xxxx,
# and MAL-* malware advisories). jq's `//` only coalesces
# null/false -- an empty string is truthy and would pass through,
# then `"" | tonumber? // 0` scores it 0, silently sailing a real
# HIGH past the CVSS>=7 gate. So we do NOT trust max_severity
# alone:
# 1. Try group `.max_severity` (numeric only; empty/"" -> null).
# 2. Fall back to the max CVSS score across the group's own
# vulnerabilities' `.severities[].score` (parsed from the
# CVSS vector's numeric base score when present).
# 3. If still unresolved, emit the sentinel "UNKNOWN" (a
# non-numeric string), never scored 0.
#
# BLOCKING RULE for UNKNOWN depends on the package:
# - Third-party UNKNOWN -> BLOCKING (fail closed). A scoreless
# GHSA-only or MAL-* malware advisory on a dep we ship must not
# slip the gate.
# - stdlib UNKNOWN -> report-only (NON-blocking). These are
# Go stdlib advisories flagged against the go.mod directive
# floor (e.g. `go 1.25.0`); they are delivered to consumers via
# GOTOOLCHAIN patch auto-download and are not a real exposure of
# the module itself. Inflating the directive to the latest patch
# purely to silence them would tighten the customer floor for no
# security gain, so we surface them (visible in the report) but
# do not block. Each finding carries `is_stdlib` for this split.
#
# NOTE ON jq NUMBER PARSING: use `try (x|tonumber) catch null`,
# NOT `x|tonumber?`. On a non-numeric string, `tonumber?` yields
# EMPTY (not null); inside an `... as $var` binding that makes the
# entire finding row vanish -- silently dropping a scoreless HIGH.
# try/catch normalizes non-numeric to null so the row survives and
# the fallback logic runs.
ALL_FINDINGS=$(jq -c '
def cvss_num($sev):
# OSV severity entries: {type:"CVSS_V3", score:"9.8"} (numeric)
# or a full vector string. Take numeric scores only; vectors
# without a bare numeric score contribute nothing (null).
($sev // []) | map(try (.score | tonumber) catch null)
| map(select(. != null)) | (max // null);
[
.results[].packages[]? |
.package as $pkg |
(.vulnerabilities // []) as $vulns |
.groups[]? |
.ids as $gids |
# max CVSS across the vulnerabilities referenced by this group
([ $vulns[] | select(.id as $id | ($gids | index($id)) != null) | cvss_num(.severities) ]
| map(select(. != null)) | (max // null)) as $vuln_score |
(try (.max_severity | tonumber) catch null) as $grp_score |
($grp_score // $vuln_score) as $resolved |
{
pkg: ($pkg.name + "@" + $pkg.version),
ids: .ids,
# OSV names the Go standard library package literally "stdlib".
is_stdlib: ($pkg.name == "stdlib"),
severity: (if $resolved == null then "UNKNOWN" else ($resolved | tostring) end)
}
] | sort_by(if (try (.severity | tonumber) catch null) == null then -1 else - (.severity | tonumber) end)
' /tmp/osv-out.json)
TOTAL_FINDINGS=$(echo "$ALL_FINDINGS" | jq 'length')

# Scoreless (UNKNOWN) findings, split by stdlib vs third-party.
# stdlib UNKNOWNs are report-only (delivered via GOTOOLCHAIN, not a
# real module exposure); third-party UNKNOWNs are blocking.
UNKNOWN_COUNT=$(echo "$ALL_FINDINGS" | jq '[.[] | select(.severity == "UNKNOWN")] | length')
UNKNOWN_STDLIB_COUNT=$(echo "$ALL_FINDINGS" | jq '[.[] | select(.severity == "UNKNOWN" and .is_stdlib)] | length')

# Blocking findings = CVSS >= 7 (any package)
# OR scoreless UNKNOWN on a NON-stdlib package.
# Scoreless stdlib advisories are intentionally excluded from the
# gate (see the note above) -- they still appear in the report.
HIGH_FINDINGS=$(echo "$ALL_FINDINGS" | jq -c '[.[] | select(((.severity | tonumber? // 0) >= 7) or (.severity == "UNKNOWN" and (.is_stdlib | not)))]')
HIGH_COUNT=$(echo "$HIGH_FINDINGS" | jq 'length')

# Guard against empty counts propagating to the numeric gates
# below. If any jq above failed, the var would be "" and
# `[ "$X" -gt 0 ]` errors / `'' != '0'` reads true. Default to 0
# and, since a failed parse should never be silently "clean",
# fail closed if the counts didn't resolve to integers.
TOTAL_FINDINGS=${TOTAL_FINDINGS:-}
HIGH_COUNT=${HIGH_COUNT:-}
UNKNOWN_COUNT=${UNKNOWN_COUNT:-}
if ! [[ "$TOTAL_FINDINGS" =~ ^[0-9]+$ ]] || ! [[ "$HIGH_COUNT" =~ ^[0-9]+$ ]]; then
echo "::error::Could not compute finding counts from OSV output (parse failure). Failing closed."
exit 1
fi

# Persist the full findings list to a file rather than a job
# output -- GitHub Actions outputs are size-capped at 1 MB and
# the formatted email body can be larger than that for big
# finding lists.
echo "$ALL_FINDINGS" > /tmp/all-findings.json

echo "total_findings=$TOTAL_FINDINGS" >> "$GITHUB_OUTPUT"
echo "high_count=$HIGH_COUNT" >> "$GITHUB_OUTPUT"
echo "unknown_count=$UNKNOWN_COUNT" >> "$GITHUB_OUTPUT"
echo "unknown_stdlib_count=$UNKNOWN_STDLIB_COUNT" >> "$GITHUB_OUTPUT"

# Step summary so findings are visible in the GH Actions UI
# without downloading artifacts.
{
echo "## OSV-Scanner Findings"
echo ""
echo "- Total findings (any severity): \`$TOTAL_FINDINGS\`"
echo "- Blocking findings (CVSS >= 7, or unscored non-stdlib; PR-blocking): \`$HIGH_COUNT\`"
echo "- Unscored/UNKNOWN findings: \`$UNKNOWN_COUNT\` total (of which \`$UNKNOWN_STDLIB_COUNT\` are stdlib — report-only, delivered via GOTOOLCHAIN)"
if [ "$TOTAL_FINDINGS" -gt 0 ]; then
echo ""
echo "All findings (sorted by severity desc):"
echo ""
echo "| Severity | Package | IDs |"
echo "|---|---|---|"
echo "$ALL_FINDINGS" | jq -r '.[] | "| \(.severity) | \(.pkg) | \(.ids | join(",")) |"'
fi
} >> "$GITHUB_STEP_SUMMARY"

# Also dump the findings to the job log so they're visible in
# the default "Logs" view, not just the step summary panel.
echo "OSV: $TOTAL_FINDINGS total findings, $HIGH_COUNT at CVSS>=7"
if [ "$TOTAL_FINDINGS" -gt 0 ]; then
echo ""
echo "All findings (sorted by severity desc):"
echo "$ALL_FINDINGS" | jq -r '.[] | " [\(.severity)] \(.pkg) \(.ids | join(", "))"'
fi

# --- Terminal: PR event ---
# Fail the job so the PR's check goes red. No email.
# PR gate is CVSS >= 7 only; MEDIUM/LOW findings show up in the
# step summary but don't block merges.
- name: Fail on findings (PR)
if: github.event_name == 'pull_request' && steps.findings.outputs.high_count != '0'
run: |
set -uo pipefail
# List the actual HIGH findings inline so the author sees what
# needs fixing without clicking through to the step summary
# panel or downloading artifacts.
HIGH_FINDINGS=$(jq -c '[.[] | select(((.severity | tonumber? // 0) >= 7) or (.severity == "UNKNOWN" and (.is_stdlib | not)))]' /tmp/all-findings.json)

echo "::error::${{ steps.findings.outputs.high_count }} unsuppressed blocking finding(s) (CVSS>=7, or unscored non-stdlib) in this PR:"
echo ""
echo "$HIGH_FINDINGS" | jq -r '.[] | " [\(.severity)] \(.pkg) \(.ids | join(", "))"'
echo ""
echo "Fix by either:"
echo " 1. Bumping the affected dependency to a patched version, or"
echo " 2. Adding a documented [[IgnoredVulns]] entry to osv-scanner.toml"
echo " with a clear justification for why the CVE doesn't apply to our usage."
echo ""
echo "Full step summary: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
exit 1

# --- Terminal: scheduled/manual event ---
# Weekly reports ALL findings (not just CVSS >= 7) so emerging risk is
# visible before it crosses the PR gate. PR-time is narrower to avoid
# blocking on MEDIUM/LOW noise; weekly is broader for situational
# awareness.
#
# Notification is intentionally NOT done here: a separate cross-repo
# action collates findings from all driver repos and sends a single
# digest. This job's job is to (a) fail so the scheduled run is red
# when anything is found, and (b) upload the raw osv-out.json artifact
# for the collator to consume.
- name: Fail on findings (scheduled/manual)
if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && steps.findings.outputs.total_findings != '0'
run: |
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."
exit 1

# Always upload the raw scan output so triagers -- and the planned
# cross-repo collation/notification action -- can pull findings
# without rerunning. This is the machine-readable source of truth now
# that per-repo email has been removed.
- name: Upload reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: security-scan-reports
path: |
/tmp/osv-out.json
/tmp/all-findings.json
if-no-files-found: ignore
39 changes: 39 additions & 0 deletions osv-scanner.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# OSV-Scanner suppressions for the databricks-sql-go security gate.
#
# Each entry suppresses a CVE that is a documented ecosystem false
# positive against an artifact we ship. Every entry has a justification.
#
# Trade-off worth noting: [[IgnoredVulns]] entries are CVE-id global --
# they ignore the CVE across all packages OSV reports it against, not
# just the artifact we have in mind. The alternative
# ([[PackageOverrides]] with `vulnerability.ignore = true`) is
# per-package but blanket-ignores ALL vulnerabilities on that package,
# which is much worse. OSV-Scanner v2.3.8 does NOT support an
# intersection ("this CVE on this package only").
#
# See google.github.io/osv-scanner/configuration/ for the schema.
#
# Populate iteratively as scan runs surface real false positives. Do not
# pre-populate with speculative suppressions.
#
# CONVENTION: every [[IgnoredVulns]] entry MUST carry an `ignoreUntil`
# (YYYY-MM-DD, ~6 months out) so no suppression is permanent. When it
# lapses OSV re-reports the finding, forcing a re-review before renewal.
# Keep the entry justified: reachability/no-fix reason, not "make CI green".

[[IgnoredVulns]]
id = "GO-2026-5932"
# golang.org/x/crypto/openpgp is "unmaintained, unsafe by design" -- the
# advisory has NO fixed version (introduced:0, no fix event), so it can
# never be cleared by a dependency bump. We do NOT import openpgp (nor any
# openpgp/* subpackage) anywhere in the driver -- verified via
# `go list -deps ./...` (we use x/crypto's chacha20poly1305 / pbkdf2 /
# cryptobyte, never openpgp). The advisory is flagged only because
# golang.org/x/crypto (which we use for other subpackages) is in the module
# graph; the vulnerable package itself is unreachable in our build.
#
# ignoreUntil forces a periodic re-review rather than a permanent ignore:
# when it lapses, the finding re-surfaces and someone must reconfirm the
# package is still unimported before re-adding the suppression.
ignoreUntil = 2027-01-02
reason = "golang.org/x/crypto/openpgp not imported by this driver (verified via go list -deps); advisory has no fixed version by design (package deprecated). Re-review on expiry: confirm openpgp still unimported before renewing."
Loading