Skip to content

Add OSV-Scanner-based security workflow#388

Merged
vikrantpuppala merged 4 commits into
mainfrom
vp/security-scan
Jul 14, 2026
Merged

Add OSV-Scanner-based security workflow#388
vikrantpuppala merged 4 commits into
mainfrom
vp/security-scan

Conversation

@vikrantpuppala

@vikrantpuppala vikrantpuppala commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds .github/workflows/securityScan.yml — single workflow, single job, three triggers (PR / weekly cron / manual). PR runs fail on CVSS ≥ 7 or any unscored (UNKNOWN) finding; weekly/manual runs report all findings, fail closed, and upload the raw scan output as an artifact.
  • Adds osv-scanner.toml — starts empty; documents the suppression convention (every [[IgnoredVulns]] entry sets a ~6-month ignoreUntil so it re-surfaces for re-review).
  • Reuses the existing ./.github/actions/setup-jfrog composite action — no duplicate OIDC-token logic; gated to skip on fork PRs.

Mirrors the merged Go driver workflow (databricks-sql-go), which itself descends from the JDBC driver's workflow (databricks-jdbc#1460), adapted for Node.js: reads package-lock.json natively via OSV-Scanner (no separate SBOM tool needed).

Fail-closed hardening (ported from the Go driver)

The naive version of this gate had two fail-open holes; both are closed here (logic is byte-identical to Go, verified against synthetic OSV JSON):

  1. Scanner errors no longer pass. A blanket || true + zero-byte guard let an errored-but-non-empty output sail through as "clean." Now we capture the exit code, tolerate only 0 (clean) / 1 (findings), reject anything else, and validate the output is well-formed JSON with a .results array (jq -e). Any scanner failure or partial write → job fails closed.
  2. Empty max_severity no longer scores 0. OSV emits max_severity: "" for advisory groups without a CVSS vector (GHSA-only, MAL-* malware). "" | tonumber? // 0 silently scored those 0 → a real HIGH could go green. Severity now resolves group max_severity → else max CVSS across the group's vulnerabilities → else the UNKNOWN sentinel, never 0.

UNKNOWN is blocking. Unlike the Go driver (whose scoreless findings are mostly Go-stdlib advisories delivered via GOTOOLCHAIN and therefore report-only), npm advisories reliably carry a CVSS score — so a scoreless finding here means a GHSA-only or malware advisory on a real dependency and must fail closed. No is_stdlib carve-out. The PR gate is (CVSS ≥ 7) OR UNKNOWN.

Delivery: artifact + cross-repo collator (no per-repo email)

Weekly/manual runs no longer send email directly. They fail closed and upload osv-out.json + all-findings.json; a separate cross-repo action collates findings across all driver repos and sends a single digest. This removes the SMTP_USERNAME / SMTP_PASSWORD / EMAIL_RECIPIENTS secret requirement.

Day-one results

The workflow is not yet wired into branch protection, so its first PR-time runs are advisory. OSV scans both runtime and devDependencies (it treats everything in package-lock.json equally), so many day-one findings are dev-only (the mocha/nyc/eslint toolchain) and don't reach dist/. Triage each into either:

  1. Bump the offending dep (cleanest — usually an npm update away), or
  2. A documented [[IgnoredVulns]] entry with ignoreUntil, justifying why it's dev-only / unreachable.

The security-bump PRs (#390 → 2.0.0, and the merged 1.17.0) already drive the lockfile toward 0 OSV findings.

Test plan

  • Fail-closed jq logic verified against synthetic OSV JSON (scored 9.8 / empty-group-but-scored-vuln 7.5 / scoreless malware → TOTAL/HIGH/UNKNOWN correct; clean → 0/0; partial JSON rejected)
  • YAML + TOML validate
  • First CI run on this PR exercises the PR path
  • Manual workflow_dispatch after merge exercises the weekly path + artifact upload

This pull request was AI-assisted by Isaac.

Single workflow, single job, three triggers:
  - pull_request to main: fails on CVSS >= 7 findings only
    (HIGH/CRITICAL block merges; MED/LOW visible but non-blocking)
  - cron weekly (Sunday 00:00 UTC): reports ALL findings via email
  - workflow_dispatch: behaves like cron

Mirrors the JDBC driver's security workflow (databricks-jdbc#1460)
adapted for Node.js:
  - Reads package-lock.json natively via OSV-Scanner --lockfile (no
    separate SBOM tool needed)
  - Reuses the existing ./.github/actions/setup-jfrog composite action
    for parity with main.yml (the workflow functionally doesn't need
    JFrog since OSV reads the lockfile directly, but keeping the
    composite action preserves the established pattern)
  - Suppressions in osv-scanner.toml ([[IgnoredVulns]] schema)

The workflow is not yet wired into branch protection. Day-one scan
against current main surfaces 22 HIGH / 15 MED / 5 LOW (42 total).
Many are in dev dependencies (mocha/nyc/eslint chains). The team can
either bump the offending deps or add documented [[IgnoredVulns]]
entries for dev-only findings that don't reach `dist/`.

Co-authored-by: Isaac
Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com>
@gopalldb

Copy link
Copy Markdown
Collaborator

⚠️ [MAJOR] — File-level | bug | logical_claude_agent

▎ .max_severity // "0" only substitutes when max_severity is null/false, NOT when it is an empty string. OSV-Scanner emits max_severity as "" for advisories lacking a CVSS vector (common for GHSA-only advisories). Such a finding gets severity="" -> tonumber? // 0 -> 0, so a genuinely HIGH vuln never trips the CVSS>=7 PR gate (fail-open). Validator: confirm OSV v2.3.8 emits max_severity="" for scoreless advisories and that the JDBC bouncycastle-style GHSA findings carry no numeric max_severity.

▎ 💡 Suggested Fix: Treat an empty-string max_severity the same as a scoreless finding but do NOT let it silently pass the gate. Either coalesce empty string as well as null (so the finding surfaces with a visible non-numeric marker rather than 0), or explicitly route scoreless advisories (empty max_severity, or ids matching MAL-*/GHSA-only-no-CVSS) into the fail path so malware advisories can't sail through the CVSS>=7 gate. E.g. change the mapping so a missing/empty score becomes a sentinel that the gate counts as blocking, or add a separate scoreless_count that also fails the PR.

⚠️ [MAJOR] — File-level | bug | logical_claude_agent

▎ Collect findings runs set -uo pipefail (no -e). If /tmp/osv-out.json is malformed JSON, the ALL_FINDINGS jq exits 5, ALL_FINDINGS='', then TOTAL_FINDINGS='' and HIGH_COUNT='' are written to GITHUB_OUTPUT as empty strings. Confirmed locally: [ "$TOTAL_FINDINGS" -gt 0 ] on line 142 errors with 'integer expression expected'. Downstream high_count != '0' becomes '' != '0' (true). Validator: trace empty-output propagation to the gate steps.

▎ 💡 Suggested Fix: Validate the jq parse before deriving counts: check the first jq's exit status (or run jq empty /tmp/osv-out.json first) and fail the step explicitly if OSV output is not valid JSON, instead of letting empty strings flow into GITHUB_OUTPUT. Also guard the numeric comparisons by defaulting the counts to 0 when empty (e.g. TOTAL_FINDINGS=${TOTAL_FINDINGS:-0}).

📄 .github/workflows/securityScan.yml

⚠️ [MAJOR] || true masks all osv-scanner failures; only a zero-byte guard backs it, which partial writes defeat
Line 89 | bug | logical_claude_agent

▎ || true swallows every osv-scanner failure, and the only backstop ([ ! -s ]) misses a partial write. Line 89's || true was intended to tolerate osv-scanner's exit-1-on-any-finding, but it also masks genuine failures: a network error reaching OSV.dev, a malformed osv-scanner.toml, a wrong-arch/corrupt binary, or a crash mid-write to --output-file. The sole thing distinguishing those from a clean scan is the line-91 [ ! -s /tmp/osv-out.json ] guard, which only rejects a zero-byte file. A truncated-but-non-empty output file (reproduced: a 140-byte partial JSON) passes the guard, then the Collect-findings jq (line 110) fails to parse it and empty counts propagate to the gates -- an errored scan becomes indistinguishable from a clean one (fail-open on the scheduled path) or emits misleading counts. (Note: the sibling concern that scan source --lockfile --config --format=json --output-file is invalid v2.3.8 CLI is not a bug -- that syntax is valid and was verified against the v2.3.8 binary.)

▎ 💡 Suggested Fix: Capture osv-scanner's exit code instead of || true, and distinguish 'findings present' (osv exits 1) from a real failure (other non-zero exits, e.g. 127/128+). After the scan, validate the output is parseable JSON (jq empty /tmp/osv-out.json) and fail the step on a parse error or unexpected exit code, so masked/partial-scan failures surface instead of flowing into the count logic as garbage.

Brings the Node OSV-Scanner workflow in line with the merged Go driver
workflow (databricks-sql-go). Two fail-open holes closed + delivery model
switched from per-repo email to artifact+cross-repo collator.

Fail-closed hardening (identical logic to Go, verified against synthetic
OSV JSON):
- Scanner errors no longer pass the gate: replace the blanket `|| true`
  with `|| scan_rc=$?`, tolerate only exit 0 (clean) / 1 (findings), and
  reject partial/corrupt output via `jq -e 'has("results") and
  (.results|type=="array")'`. Any other exit or unparseable JSON fails
  the job closed instead of reporting zero findings.
- Empty `max_severity` no longer scores 0: resolve severity as group
  max_severity -> else max CVSS across the group's vulnerabilities'
  .severities[].score -> else the "UNKNOWN" sentinel. jq uses
  `try (x|tonumber) catch null` (not `tonumber?`, which yields EMPTY and
  drops the row inside a binding).
- UNKNOWN is BLOCKING. Unlike Go (whose scoreless findings are mostly
  stdlib advisories delivered via GOTOOLCHAIN, hence report-only), npm
  advisories reliably carry CVSS, so a scoreless finding means a
  GHSA-only / MAL-* malware advisory on a real dep -> fail closed. No
  is_stdlib carve-out. Gate in both Collect and Fail-on-PR steps is
  `(CVSS>=7) OR UNKNOWN`; added an `unknown_count` output.
- Integer-count guard: fail closed if total/high counts don't parse to
  integers (a failed jq must never read as "clean").

Delivery model:
- Drop the SMTP steps (Compose email body + dawidd6/action-send-mail).
  Weekly/manual runs now fail closed and upload osv-out.json +
  all-findings.json; a separate cross-repo action collates artifacts
  across all driver repos and sends one digest. Removes the need for
  SMTP_USERNAME / SMTP_PASSWORD / EMAIL_RECIPIENTS secrets.

osv-scanner.toml: document the suppression convention -- every
[[IgnoredVulns]] must set an ~6-month `ignoreUntil` so suppressions
re-surface for re-review rather than lingering silently.

Co-authored-by: Isaac
Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com>
The workflow was ported from the Go repo, whose prettier config differs;
Node's prettier normalizes the cron entry's comment spacing. No logic change.

Co-authored-by: Isaac
Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com>
Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com>
@vikrantpuppala vikrantpuppala added this pull request to the merge queue Jul 14, 2026
Merged via the queue into main with commit 11d3f59 Jul 14, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants