Vulnerability Tracking & Triage
Permalink to "Vulnerability Tracking & Triage"This workflow is part of Supply Chain Auditing & Dependency Verification. Without a structured triage process, automated scanners produce hundreds of daily alerts that overwhelm engineering teams and create alert fatigue — causing genuinely exploitable findings to be buried alongside irrelevant noise. The result is either ignored queues or blanket suppression rules that silence real threats. This page covers the complete pipeline from CVE feed ingestion through CI/CD blocking gates to closed-loop policy refinement, with SRI hash enforcement embedded at the final cryptographic checkpoint before production deployment.
Prerequisites
Permalink to "Prerequisites"Conceptual Foundation
Permalink to "Conceptual Foundation"Vulnerability triage is the decision process that converts raw scanner output — a flat list of CVE identifiers paired with affected package versions — into an ordered remediation queue with clear ownership, SLA deadlines, and documented risk-acceptance decisions. The process is grounded in two complementary scoring systems:
CVSS (Common Vulnerability Scoring System) provides a base score (0–10) derived from attack vector, complexity, privileges required, user interaction, and impact metrics. The W3C SRI specification itself does not define CVSS thresholds; each organisation sets its own. NIST SSDF practice RV.1.1 recommends establishing a repeatable response process against which CVSS thresholds are defined.
EPSS (Exploit Prediction Scoring System) adds a probability estimate (0–1) of exploitation in the wild within 30 days, derived from real-world threat intelligence. A CVE with CVSS 9.1 but EPSS 0.003 (0.3% exploitation probability) is typically lower priority than a CVSS 7.4 finding with EPSS 0.91 (91% probability) in an internet-facing script.
For frontend supply-chain risk the asset exposure dimension is critical: a vulnerable package loaded on every payment page through a CDN <script> tag carries fundamentally different risk than the same package used only in a build-time CLI tool. SRI hashes bound the blast radius — they ensure that even if a vulnerable version is referenced, the exact known artifact is what loads, blocking silent substitution with a patched or malicious build.
Step 1 — Configure CVE Feed Ingestion
Permalink to "Step 1 — Configure CVE Feed Ingestion"Reliable triage begins with authoritative, low-latency feed sources. The three canonical sources for frontend supply-chain work are:
- NVD (National Vulnerability Database) —
https://services.nvd.nist.gov/rest/json/cves/2.0— the authoritative CVSS v3.1/v4.0 source; requires a free API key for sustained polling - OSV (Open Source Vulnerabilities) —
https://api.osv.dev/v1/query— ecosystem-aware (npm, PyPI, Maven, Go) with batch query support; no key required - GitHub Advisory Database —
https://api.github.com/graphql— tightly integrated withnpm auditanddependabot; useful when your CI already runs on GitHub Actions
Configure a scheduled job that polls each feed and normalises results into a shared schema. The following Node.js snippet queries OSV for all vulnerabilities affecting packages listed in an SBOM artifact:
// scripts/poll-osv.mjs
// Queries OSV batch endpoint for every package in a CycloneDX SBOM JSON.
import fs from 'node:fs/promises';
const sbom = JSON.parse(await fs.readFile('./sbom.cdx.json', 'utf8'));
const queries = sbom.components.map(c => ({
package: { name: c.name, version: c.version, ecosystem: 'npm' }
}));
const res = await fetch('https://api.osv.dev/v1/querybatch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queries })
});
const { results } = await res.json();
const findings = results
.flatMap((r, i) => (r.vulns ?? []).map(v => ({
component: queries[i].package.name,
version: queries[i].package.version,
id: v.id,
aliases: v.aliases ?? [],
severity: v.database_specific?.severity ?? 'UNKNOWN'
})));
await fs.writeFile('./vuln-findings.json', JSON.stringify(findings, null, 2));
console.log(`Found ${findings.length} findings across ${sbom.components.length} components`);
Expected output:
Found 3 findings across 47 components
Step 2 — Correlate Findings with the Dependency Graph
Permalink to "Step 2 — Correlate Findings with the Dependency Graph"Raw CVE findings list affected package names and version ranges, but they do not tell you whether a vulnerable package is actually reachable in your runtime. Correlation with the dependency graph is what separates actionable alerts from noise.
Use Lockfile Mapping & Analysis to build a resolved dependency graph from your lockfile, then join it against the findings from Step 1:
// scripts/correlate.mjs
// Joins OSV findings with the resolved dependency graph from package-lock.json
import fs from 'node:fs/promises';
const lock = JSON.parse(await fs.readFile('./package-lock.json', 'utf8'));
const findings = JSON.parse(await fs.readFile('./vuln-findings.json', 'utf8'));
// Build a map: packageName → { version, isDirect, isDevOnly }
const graph = new Map();
for (const [path, meta] of Object.entries(lock.packages ?? {})) {
if (!path) continue; // root entry
const name = path.replace(/^node_modules\//, '').replace(/\/node_modules\//, ' > ');
graph.set(name, {
version: meta.version,
isDirect: !path.includes('/node_modules/'),
isDevOnly: !!meta.dev
});
}
const correlated = findings.map(f => {
const node = graph.get(f.component);
return {
...f,
resolved: !!node,
installedVersion: node?.version,
isDirect: node?.isDirect ?? false,
isDevOnly: node?.isDevOnly ?? true, // unknown packages assumed dev-only
riskNote: node?.isDevOnly ? 'dev-only — lower runtime exposure' : 'runtime dependency'
};
}).filter(f => f.resolved);
await fs.writeFile('./correlated-findings.json', JSON.stringify(correlated, null, 2));
console.log(`${correlated.length} findings map to installed packages`);
Findings for packages that exist only in devDependencies still matter for build-time attacks (malicious postinstall scripts) but carry no browser-runtime exposure — a distinction that affects SLA assignment.
Step 3 — Assign Severity Tiers and SLA Deadlines
Permalink to "Step 3 — Assign Severity Tiers and SLA Deadlines"With correlated findings in hand, apply a contextualised severity model. The following SLA matrix combines CVSS base score, EPSS probability, and asset exposure:
| Tier | CVSS Range | EPSS Threshold | Asset Exposure | Remediation SLA | Escalation Path |
|---|---|---|---|---|---|
| Critical | 9.0–10.0 | any | internet-facing | 24 hours | Security lead → CISO → Compliance officer |
| High | 7.0–8.9 | > 0.10 | internet-facing | 72 hours | Security engineer → DevOps manager |
| High (deprioritised) | 7.0–8.9 | ≤ 0.10 | internal only | 14 days | Security engineer |
| Medium | 4.0–6.9 | > 0.10 | internet-facing | 7 days | Security engineer |
| Low / Informational | 0.1–3.9 | any | any | 30 days | Tracked in backlog |
| Dev-only | any | any | build-time only | Next sprint | Dev team |
Map these tiers to your tooling configuration. For npm audit, the --audit-level flag controls the CI block threshold:
# Fail CI on critical or high; treat medium and below as warnings
npm audit --audit-level=high --json > audit-report.json
For projects using pnpm or yarn, equivalent commands are pnpm audit --audit-level high and yarn npm audit --severity high respectively.
EPSS scores are not surfaced by npm audit natively. To enrich findings with EPSS, query the FIRST API:
# Fetch EPSS score for a single CVE
curl -s "https://api.first.org/data/1.0/epss?cve=CVE-2024-21538" \
| jq '.data[0] | {cve: .cve, epss: .epss, percentile: .percentile}'
# → {"cve": "CVE-2024-21538", "epss": "0.00097", "percentile": "0.39071"}
Step 4 — Enforce CI/CD Blocking Gates
Permalink to "Step 4 — Enforce CI/CD Blocking Gates"Policy-as-code enforcement prevents unpatched critical or high-severity vulnerabilities from reaching staging or production. The gate must run after dependency installation and before any build or test step, and must block the merge if thresholds are breached.
# .github/workflows/vulnerability-gate.yml
name: Vulnerability Triage Gate
on:
pull_request:
branches: [main, staging]
push:
branches: [main]
jobs:
triage-gate:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for uploading SARIF results
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies (no lifecycle scripts)
run: npm ci --ignore-scripts
- name: Run npm audit
id: audit
run: npm audit --json --audit-level=none > audit-report.json || true
- name: Enforce critical threshold
run: |
CRITICAL=$(node -e "
const r = require('./audit-report.json');
const v = r.metadata?.vulnerabilities ?? {};
console.log(v.critical ?? 0);
")
HIGH=$(node -e "
const r = require('./audit-report.json');
const v = r.metadata?.vulnerabilities ?? {};
console.log(v.high ?? 0);
")
echo "Critical: $CRITICAL High: $HIGH"
if [ "$CRITICAL" -gt 0 ]; then
echo "::error::CRITICAL CVE detected. Deployment blocked. File a compliance ticket and obtain security-lead sign-off to override."
exit 1
fi
if [ "$HIGH" -gt 2 ]; then
echo "::error::HIGH CVE count ($HIGH) exceeds threshold of 2. Triage required before merge."
exit 1
fi
- name: Verify SRI hashes have not drifted
run: |
# Compare SRI hashes in the built HTML against the approved allowlist
node scripts/verify-sri-allowlist.mjs
The verify-sri-allowlist.mjs step is what ties vulnerability management to SRI enforcement: any time a dependency update changes the content of a CDN-served script, the hash in the HTML must be updated deliberately, not silently. Hash drift — where a package version changes but the SRI attribute is not updated — is the most common integration failure and surfaces here as a detectable CI signal.
Configuration Reference Table
Permalink to "Configuration Reference Table"| Option | Valid Values | Default | Security Implication |
|---|---|---|---|
npm audit --audit-level |
critical, high, moderate, low, info |
low |
Only critical or high are suitable as merge-blocking thresholds |
--json |
boolean flag | off | Required for machine-parseable output; pipe to jq or Node.js for threshold logic |
--omit=dev |
dev, optional, peer |
none | Omit dev dependencies from audit scope to reduce noise in production gate |
--ignore-scripts on npm ci |
boolean flag | off | Prevents malicious postinstall hooks from running during audit setup |
| EPSS threshold for escalation | 0.0–1.0 | team-defined | Values above 0.10 indicate meaningful exploitation probability |
| SLA override TTL (cached feed) | hours | 24 | Maximum time the build may rely on a stale scan result before requiring a fresh fetch |
Integration with Adjacent Tooling
Permalink to "Integration with Adjacent Tooling"The output of this workflow feeds directly into two adjacent steps:
-
SBOM update — after a successful triage cycle resolves findings, regenerate the SBOM to capture the clean component inventory. The Generating CycloneDX SBOMs for Frontend Assets guide covers the
@cyclonedx/cyclonedx-npmCLI workflow for this. -
CSP header refresh — when a dependency update changes the hash of a CDN-loaded script, the
Content-Security-Policyheader’sscript-srcdirective must be updated to include the new SHA-384 hash. Combine this triage step with a strict Configuring Content-Security-Policy with SRI policy that hard-fails on hash mismatches rather than logging-only. -
Third-party risk scoring — vulnerability findings enrich the risk profile maintained in Third-Party Risk Assessment. A vendor whose packages repeatedly carry unpatched CVEs may breach a risk-acceptance threshold, triggering a sourcing review.
Troubleshooting
Permalink to "Troubleshooting"npm audit exits non-zero but there are no critical findings
The default --audit-level is low, so any severity finding causes a non-zero exit. Add --audit-level=high to your CI command or parse the JSON output explicitly rather than relying on the exit code for threshold logic.
ENOTFOUND registry.npmjs.org during audit in air-gapped CI
The audit command requires network access to the npm registry advisory endpoint. In air-gapped environments, proxy requests through a Verdaccio or Nexus instance that mirrors the npm advisory database, or pre-cache the latest advisory feed and point npm_config_registry at the local mirror.
OSV query returns NOT_FOUND for a known-vulnerable package
OSV uses ecosystem-qualified package identifiers. Ensure ecosystem is set to npm (not node or nodejs) and that version matches the exact string in the lockfile, not a semver range.
CI gate blocks a merge but the CVE is a false positive (no code path reaches the vulnerable function)
Create an explicit .nsprc or npm audit --ignore entry with a linked compliance ticket number and an expiry date. Record the justification in your SBOM’s note field. Blanket suppression without documentation creates a compliance gap under SOC 2 Type II CC7.1.
SRI hash mismatch detected on a CDN asset after a dependency update
The CDN vendor has updated the file content at the same URL. Do not simply remove the integrity attribute — re-generate the SHA-384 hash using openssl dgst -sha384 -binary <file> | openssl base64 -A against the new file and update the allowlist. If the update was not expected, treat it as a potential supply-chain incident and verify the change with the vendor’s release notes before accepting the new hash.
EPSS API rate limit (429) during bulk CVE enrichment
The FIRST EPSS API enforces per-minute rate limits. Batch requests into groups of 30 CVEs per call using the ?cve=CVE-A,CVE-B,... multi-value query parameter, and add an exponential back-off retry loop. Cache results for 24 hours to avoid re-fetching unchanged scores.
Security Boundary Note
Permalink to "Security Boundary Note"Vulnerability tracking and triage addresses known CVEs in dependencies whose version or content you control. It does not protect against:
- Zero-day exploits — CVEs that have not yet been published to NVD or OSV. SRI hash enforcement provides partial mitigation by ensuring only the exact approved artifact loads, but a zero-day in a legitimately approved version is not detected by scanning.
- Malicious packages with no CVE record — typosquatting or dependency confusion attacks where a malicious package has never been reported as vulnerable. Provenance Verification Workflows and Sigstore attestation address this vector.
- Runtime exploitation after load — once a script with a known CVE has been approved and loaded, SRI does not prevent the exploit code from executing. Triage is a deployment gate, not a runtime sandbox.
- Build-time attacks through dev dependencies — a malicious
postinstallscript in a dev dependency can exfiltrate secrets duringnpm installbefore any audit completes. Always runnpm ci --ignore-scriptsin CI and audit before building.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"What is the difference between vulnerability scanning and vulnerability triage?
Scanning produces a raw list of findings from CVE databases. Triage is the human-and-policy layer that filters those findings by actual exploitability, asset exposure, and business context — turning hundreds of scanner alerts into an ordered remediation queue with SLA deadlines.
How does SRI hash validation fit into a vulnerability management workflow?
SRI hashes act as the cryptographic checkpoint that prevents a known-vulnerable version of a third-party script from being silently substituted with a patched or malicious one. Vulnerability triage identifies which versions are affected; SRI enforcement ensures only the exact approved artifact — not any substitute — loads in the browser.
Which compliance frameworks require formal vulnerability triage workflows?
PCI DSS v4.0.1 requirement 6.4.3 mandates an inventory and integrity mechanism for all payment-page scripts. NIST SSDF practice RV.1 requires a repeatable vulnerability-response process. SOC 2 Type II CC7.1 requires monitoring for software vulnerabilities. ISO 27001 Annex A control 8.8 covers management of technical vulnerabilities.
Can I use base CVSS scores alone to set remediation SLAs?
No. Base CVSS scores omit environmental factors such as network exposure, compensating controls, and whether a public exploit exists. A CVSS 9.1 vulnerability in a library called only server-side from an internal service carries far less risk than a CVSS 7.4 flaw in a script loaded on every payment page. Always adjust with EPSS probability scores and asset exposure before assigning an SLA.
What happens if the CVE feed is unreachable during a CI build?
Build pipelines should maintain a locally cached integrity allowlist so that transient feed outages do not halt every deployment. The recommended pattern is to cache the last successful scan result for up to 24 hours, fail open only if the cache is within its TTL, and require a signed compliance ticket for any override beyond the TTL window.
Related
Permalink to "Related"- Lockfile Mapping & Analysis — build the resolved dependency graph that vulnerability correlation depends on
- Automated SBOM Generation — produce the CycloneDX component inventory that feeds OSV batch queries
- Provenance Verification Workflows — verify Sigstore attestations for packages that have no published CVE record
- Dependency Pinning Best Practices — prevent version drift that introduces new CVE exposure between audits