diff --git a/.github/actions/sentinel-pr-audit/action.yml b/.github/actions/sentinel-pr-audit/action.yml index 8a6ad6d..46590c2 100644 --- a/.github/actions/sentinel-pr-audit/action.yml +++ b/.github/actions/sentinel-pr-audit/action.yml @@ -8,9 +8,9 @@ branding: inputs: sentinel-version: - description: 'Version of @sentinel/cli to install (npm install -g @sentinel/cli@)' + description: 'Deprecated. The CLI is built and installed from the checked-out repository.' required: false - default: 'latest' + default: 'local' github-token: description: 'GitHub token for API calls (defaults to github.token)' required: false @@ -27,12 +27,12 @@ runs: with: node-version: 20 - - name: Install Sentinel CLI + - name: Install Sentinel CLI from checkout shell: bash - env: - SENTINEL_VERSION: ${{ inputs.sentinel-version }} run: | - npm install -g @sentinel/cli@"$SENTINEL_VERSION" + npm ci + npm run build + npm install -g . - name: Audit PR id: audit @@ -46,7 +46,7 @@ runs: SENTINEL_OUTPUT: /tmp/sentinel-result.json GH_TOKEN: ${{ inputs.github-token }} run: | - sentinel pr-audit + sntnl pr-audit - name: Report failure if: failure() && steps.audit.outcome == 'failure' diff --git a/README.md b/README.md index a933076..4320a0d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,18 @@ Sentinel is a deterministic, local-first security intelligence platform. It scan It also integrates with AI coding agents (Claude, Cursor, Cline, Windsurf, OpenCode, Roo, Gemini, Codex) via platform-specific skills and a Model Context Protocol (MCP) server, but Sentinel works standalone as a CLI security tool for any development workflow. + +## Part of the Sentinel platform + +| Product | Purpose | When to use | +|---------|---------|-------------| +| **[Sentinel CLI](https://github.com/javier20dev25/sentinel-cli)** | Local-first SAST, supply chain audit, build intelligence, skills/MCP for AI agents | Run locally, in CI, or as part of agent workflows. No account required for core scans. | +| **[Sentinel Audit](https://github.com/javier20dev25/sentinel-audit)** | Focused audit workflows (deps/PRs/CI) and reporting | Structured audits with tighter reporting/automation. | +| **[Sentinel Cloud](https://github.com/javier20dev25/sentinel-cloud)** | Web dashboard, team workflows, full-engine scans with pulses | When you need shared intelligence, collaboration, or the full engine. | +| **[Sentinel Oracle](https://github.com/javier20dev25/sentinel-oracle)** | Merge authorization/enforcement server (gate PR merges) | When you need to enforce "authorize before merge" with isolated enforcement. | + +> **Tip:** If Sentinel Cloud is temporarily unreachable or you hit auth/connection issues, use the CLI directly (`sntnl scan`, `sntnl audit-deps`) — it works standalone without Cloud. + --- ## Contents diff --git a/dist/cli/intelligence/lockfile_parser.d.ts b/dist/cli/intelligence/lockfile_parser.d.ts index 53a5c0d..43c0da4 100644 --- a/dist/cli/intelligence/lockfile_parser.d.ts +++ b/dist/cli/intelligence/lockfile_parser.d.ts @@ -10,6 +10,13 @@ export interface LockfileResult { format: 'npm-v6' | 'npm-v7' | 'yarn' | 'unknown'; } export declare class LockfileParser { + /** + * Detects lockfiles that are recognized by filename but NOT parseable by this + * parser (pnpm, Yarn Berry). Returns a human-readable reason, or null when the + * file is either supported or unrecognized. This lets callers report an + * explicit INCOMPLETE status instead of a misleading "no dependencies". + */ + detectUnsupported(filename: string, content: string): string | null; detectFormat(content: string): string; parsePackageLock(content: string): LockfileResult; private parseNpmV7; diff --git a/dist/cli/intelligence/lockfile_parser.js b/dist/cli/intelligence/lockfile_parser.js index 33fc32f..53d02b9 100644 --- a/dist/cli/intelligence/lockfile_parser.js +++ b/dist/cli/intelligence/lockfile_parser.js @@ -36,6 +36,22 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.LockfileParser = void 0; const fs = __importStar(require("fs")); class LockfileParser { + /** + * Detects lockfiles that are recognized by filename but NOT parseable by this + * parser (pnpm, Yarn Berry). Returns a human-readable reason, or null when the + * file is either supported or unrecognized. This lets callers report an + * explicit INCOMPLETE status instead of a misleading "no dependencies". + */ + detectUnsupported(filename, content) { + const base = filename.toLowerCase(); + if (base.endsWith('pnpm-lock.yaml')) { + return 'pnpm-lock.yaml (pnpm) is not supported by the parser'; + } + if (base.endsWith('yarn.lock') && /^__metadata:/m.test(content)) { + return 'Yarn Berry (v2+) lockfile is not supported by the parser'; + } + return null; + } detectFormat(content) { const trimmed = content.trim(); if (!trimmed) diff --git a/dist/cli/intelligence/osv_integrator.d.ts b/dist/cli/intelligence/osv_integrator.d.ts index 3649c2d..d9f3e2f 100644 --- a/dist/cli/intelligence/osv_integrator.d.ts +++ b/dist/cli/intelligence/osv_integrator.d.ts @@ -29,6 +29,7 @@ export interface OSVResult { export declare class OSVIntegrator { private static readonly API_URL; private static readonly BATCH_API_URL; + private static readonly VULN_API_URL; /** * Query OSV.dev for known vulnerabilities affecting a package@version. * Uses the public API (no API key required). @@ -38,15 +39,67 @@ export declare class OSVIntegrator { name: string; version: string; }[]): Promise; + /** + * OSV's /querybatch endpoint returns only minimal vuln stubs (id + modified) + * — no severity, no affected ranges. This hydrates each unique advisory with + * its full /v1/vulns/{id} document so source severity and fixed versions are + * actually available. Hydration is deduplicated by advisory ID and bounded to + * 8 concurrent requests. Advisories that fail to hydrate keep their stub + * (later reported as UNKNOWN, never fabricated). + */ + hydrateResults(results: OSVResult[]): Promise; + private fetchVuln; + /** + * Normalize a published severity label (OSV/GHSA `database_specific.severity`, + * or CVSS scalar strings) to Sentinel's CRITICAL/HIGH/MEDIUM/LOW vocabulary. + * Returns 'UNKNOWN' when the source value is not a recognized severity — it + * must never be silently coerced into MEDIUM. + */ + static normalizeSeverity(value: string): string; + /** + * Extract a numeric CVSS score from an OSV `severity[].score` value. + * The value may be either a bare number ("7.5") or a CVSS **vector** + * ("CVSS:3.1/AV:N/..."). CVSS v3.x vectors are scored; CVSS v4.0 vectors + * return null because their base metric (MacroVector) is not implemented + * here — callers must fall back to the published severity label. + */ + static severityScoreOf(raw: string): number | null; + /** + * Compute the CVSS v3.0 / v3.1 base score from a vector string. + * Implements the official base-score formula (FIRST spec). Returns null on + * malformed vectors. + */ + static parseCvssV3Vector(vector: string): number | null; + /** + * Resolve the **published** severity of an advisory plus its provenance, + * without ever fabricating a level. Priority: + * 1. database_specific.severity (OSV/GitHub published label) + * 2. CVSS v3.x vector → numeric base score + * 3. numeric CVSS score string + * 4. CVSS v4.0 vector is recorded but left UNKNOWN (not scored here) + * 5. otherwise UNKNOWN with source 'none' + */ + static getSourceSeverity(vuln: OSVVuln): { + level: string; + source: string; + cvssType?: string; + cvssVector?: string; + cvssScore?: number; + }; + /** + * Collect the first-fixed versions declared by an advisory's affected ranges. + */ + static getFixedVersions(vuln: OSVVuln): string[]; /** * Get the highest CVSS score from a vulnerability's severity array. + * Accepts both numeric score strings and CVSS v3.x vectors. */ static getMaxSeverity(vuln: OSVVuln): { type: string; score: number; } | null; /** - * Map OSV severity to Sentinel severity + * Map a numeric CVSS score to Sentinel severity. */ static toSentinelSeverity(cvssScore: number): string; } diff --git a/dist/cli/intelligence/osv_integrator.js b/dist/cli/intelligence/osv_integrator.js index d3df5e6..7c083e6 100644 --- a/dist/cli/intelligence/osv_integrator.js +++ b/dist/cli/intelligence/osv_integrator.js @@ -114,21 +114,209 @@ class OSVIntegrator { } }); } + /** + * OSV's /querybatch endpoint returns only minimal vuln stubs (id + modified) + * — no severity, no affected ranges. This hydrates each unique advisory with + * its full /v1/vulns/{id} document so source severity and fixed versions are + * actually available. Hydration is deduplicated by advisory ID and bounded to + * 8 concurrent requests. Advisories that fail to hydrate keep their stub + * (later reported as UNKNOWN, never fabricated). + */ + hydrateResults(results) { + return __awaiter(this, void 0, void 0, function* () { + const ids = new Set(); + for (const r of results) + for (const v of r.vulnerabilities) + if (v.id) + ids.add(v.id); + if (ids.size === 0) + return results; + const list = Array.from(ids); + const cache = new Map(); + const CONCURRENCY = 8; + for (let i = 0; i < list.length; i += CONCURRENCY) { + const slice = list.slice(i, i + CONCURRENCY); + const docs = yield Promise.all(slice.map(id => this.fetchVuln(id))); + docs.forEach((d, idx) => { if (d) + cache.set(slice[idx], d); }); + } + return results.map(r => (Object.assign(Object.assign({}, r), { vulnerabilities: r.vulnerabilities.map(v => cache.get(v.id) || v) }))); + }); + } + fetchVuln(id) { + return __awaiter(this, void 0, void 0, function* () { + try { + const response = yield fetch(`${OSVIntegrator.VULN_API_URL}/${encodeURIComponent(id)}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000) + }); + if (!response.ok) + return null; + const v = (yield response.json()); + return { + id: v.id, + summary: v.summary || '', + aliases: v.aliases || [], + severity: v.severity || [], + published: v.published || '', + modified: v.modified || '', + database_specific: v.database_specific, + affected: v.affected + }; + } + catch (_a) { + return null; + } + }); + } + /** + * Normalize a published severity label (OSV/GHSA `database_specific.severity`, + * or CVSS scalar strings) to Sentinel's CRITICAL/HIGH/MEDIUM/LOW vocabulary. + * Returns 'UNKNOWN' when the source value is not a recognized severity — it + * must never be silently coerced into MEDIUM. + */ + static normalizeSeverity(value) { + const u = String(value || '').trim().toUpperCase(); + if (u === 'MODERATE') + return 'MEDIUM'; + if (u === 'CRITICAL' || u === 'HIGH' || u === 'MEDIUM' || u === 'LOW') + return u; + return 'UNKNOWN'; + } + /** + * Extract a numeric CVSS score from an OSV `severity[].score` value. + * The value may be either a bare number ("7.5") or a CVSS **vector** + * ("CVSS:3.1/AV:N/..."). CVSS v3.x vectors are scored; CVSS v4.0 vectors + * return null because their base metric (MacroVector) is not implemented + * here — callers must fall back to the published severity label. + */ + static severityScoreOf(raw) { + if (!raw) + return null; + const trimmed = raw.trim(); + if (/^[0-9]/.test(trimmed)) { + const n = parseFloat(trimmed); + return isNaN(n) ? null : n; + } + if (trimmed.startsWith('CVSS:3.0/') || trimmed.startsWith('CVSS:3.1/')) { + return OSVIntegrator.parseCvssV3Vector(trimmed); + } + return null; + } + /** + * Compute the CVSS v3.0 / v3.1 base score from a vector string. + * Implements the official base-score formula (FIRST spec). Returns null on + * malformed vectors. + */ + static parseCvssV3Vector(vector) { + try { + if (!/^CVSS:3\.[01]\//.test(vector)) + return null; + const metric = (k) => { + const m = vector.match(new RegExp('(?:^|/)' + k + ':([A-Za-z])')); + return m ? m[1] : ''; + }; + const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[metric('AV')]; + const AC = { L: 0.77, H: 0.44 }[metric('AC')]; + const UI = { N: 0.85, R: 0.62 }[metric('UI')]; + const S = metric('S'); + const CIA = { N: 0.0, L: 0.22, H: 0.56 }; + const C = CIA[metric('C')]; + const I = CIA[metric('I')]; + const A = CIA[metric('A')]; + const prTable = S === 'C' + ? { N: 0.85, L: 0.68, H: 0.5 } + : { N: 0.85, L: 0.62, H: 0.27 }; + const PR = prTable[metric('PR')]; + if ([AV, AC, UI, C, I, A, PR].some(v => v === undefined) || (S !== 'U' && S !== 'C')) { + return null; + } + const iss = 1 - ((1 - C) * (1 - I) * (1 - A)); + const impact = S === 'U' + ? 6.42 * iss + : 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15); + if (impact <= 0) + return 0; + const exploitability = 8.22 * AV * AC * PR * UI; + const roundup = (x) => Math.ceil(x * 10) / 10; + const base = S === 'U' + ? roundup(Math.min(impact + exploitability, 10)) + : roundup(Math.min(1.08 * (impact + exploitability), 10)); + return base; + } + catch (_a) { + return null; + } + } + /** + * Resolve the **published** severity of an advisory plus its provenance, + * without ever fabricating a level. Priority: + * 1. database_specific.severity (OSV/GitHub published label) + * 2. CVSS v3.x vector → numeric base score + * 3. numeric CVSS score string + * 4. CVSS v4.0 vector is recorded but left UNKNOWN (not scored here) + * 5. otherwise UNKNOWN with source 'none' + */ + static getSourceSeverity(vuln) { + const db = vuln.database_specific && vuln.database_specific.severity; + if (typeof db === 'string' && db.trim()) { + return { level: OSVIntegrator.normalizeSeverity(db), source: 'osv.database_specific.severity' }; + } + let best = null; + for (const s of (vuln.severity || [])) { + const num = OSVIntegrator.severityScoreOf(s.score); + if (num !== null && (!best || num > best.score)) { + best = { type: s.type, score: num, vector: s.score }; + } + } + if (best) { + const fromVector = /^CVSS:/.test(best.vector); + return { + level: OSVIntegrator.toSentinelSeverity(best.score), + source: fromVector ? 'osv.severity.cvss_vector' : 'osv.severity.cvss_score', + cvssType: best.type, + cvssVector: best.vector, + cvssScore: best.score + }; + } + const v4 = (vuln.severity || []).find(s => s.type === 'CVSS_V4' || /^CVSS:4\.0\//.test(s.score || '')); + if (v4) { + return { level: 'UNKNOWN', source: 'osv.severity.cvss_v4_unmapped', cvssType: v4.type, cvssVector: v4.score }; + } + return { level: 'UNKNOWN', source: 'none' }; + } + /** + * Collect the first-fixed versions declared by an advisory's affected ranges. + */ + static getFixedVersions(vuln) { + const out = new Set(); + for (const a of (vuln.affected || [])) { + for (const r of (a.ranges || [])) { + for (const e of (r.events || [])) { + if (e && typeof e.fixed === 'string' && e.fixed) + out.add(e.fixed); + } + } + } + return Array.from(out).sort(); + } /** * Get the highest CVSS score from a vulnerability's severity array. + * Accepts both numeric score strings and CVSS v3.x vectors. */ static getMaxSeverity(vuln) { let max = null; for (const s of vuln.severity) { - const score = parseFloat(s.score); - if (!isNaN(score) && (!max || score > max.score)) { + const score = OSVIntegrator.severityScoreOf(s.score); + if (score !== null && (!max || score > max.score)) { max = { type: s.type, score }; } } return max; } /** - * Map OSV severity to Sentinel severity + * Map a numeric CVSS score to Sentinel severity. */ static toSentinelSeverity(cvssScore) { if (cvssScore >= 9.0) @@ -143,3 +331,4 @@ class OSVIntegrator { exports.OSVIntegrator = OSVIntegrator; OSVIntegrator.API_URL = 'https://api.osv.dev/v1/query'; OSVIntegrator.BATCH_API_URL = 'https://api.osv.dev/v1/querybatch'; +OSVIntegrator.VULN_API_URL = 'https://api.osv.dev/v1/vulns'; diff --git a/dist/cli/main.js b/dist/cli/main.js index a207537..ee24317 100644 --- a/dist/cli/main.js +++ b/dist/cli/main.js @@ -2034,11 +2034,43 @@ program const { ProvenanceVerifier } = yield Promise.resolve().then(() => __importStar(require('./intelligence/provenance_verifier'))); const { QuarantineManager } = yield Promise.resolve().then(() => __importStar(require('./intelligence/quarantine'))); const { NpmAuditParser } = yield Promise.resolve().then(() => __importStar(require('./intelligence/npm_audit_parser'))); + const { summarizeFindings } = yield Promise.resolve().then(() => __importStar(require('./intelligence/audit_deps_report'))); const cwd = process.cwd(); + const jsonOut = !!options.json; + // Emit an explicit INCOMPLETE result. Dependencies were NOT analyzed, so + // this must never be reported as "no findings". With --ci it exits 2 so + // automated pipelines can detect the state; otherwise it exits 0. + const emitIncomplete = (reason, lockfile) => { + if (jsonOut) { + console.log(JSON.stringify({ + status: 'INCOMPLETE', + coverageReason: reason, + lockfile: lockfile || null, + packages: 0, + format: 'unknown', + instances: 0, + pkgVersionGhsa: 0, + uniqueGhsa: 0, + vulnerabilities: 0, + bySeverity: {}, + bySourceSeverity: {}, + sentinelBySeverity: {}, + suspiciousPackages: 0, + provenance: 0, + npmAudit: null, + durationMs: 0 + }, null, 2)); + } + else { + console.log(pc.yellow(`\n ⚠️ INCOMPLETE — ${reason}`)); + console.log(pc.dim(' Dependencies were NOT analyzed. This is not a clean result.')); + } + process.exit(options.ci ? 2 : 0); + }; // 1. Detect and parse lockfile let lockfilePath = options.lockfile; if (!lockfilePath) { - const candidates = ['package-lock.json', 'yarn.lock']; + const candidates = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']; for (const c of candidates) { const testPath = path.join(cwd, c); if (fs.existsSync(testPath)) { @@ -2048,14 +2080,21 @@ program } } if (!lockfilePath || !fs.existsSync(lockfilePath)) { - console.error(pc.red('No lockfile found. Run npm install first, or specify --lockfile.')); - return; + emitIncomplete('no lockfile found (looked for package-lock.json, yarn.lock, pnpm-lock.yaml)', null); } const parser = new LockfileParser(); + let lockfileContent = ''; + try { + lockfileContent = fs.readFileSync(lockfilePath, 'utf8'); + } + catch (_b) { } + const unsupported = parser.detectUnsupported(lockfilePath, lockfileContent); + if (unsupported) { + emitIncomplete(unsupported, lockfilePath); + } const parsed = parser.parse(lockfilePath); if (parsed.entries.length === 0) { - console.log(pc.yellow('No dependencies found in lockfile.')); - return; + emitIncomplete(`parser returned 0 dependencies (format: ${parsed.format})`, lockfilePath); } console.log(pc.cyan(`\n📋 Audit: ${parsed.entries.length} dependencies (${parsed.format})`)); console.log(pc.dim(` Lockfile: ${lockfilePath}`)); @@ -2066,7 +2105,9 @@ program // 2. Batch OSV query const osv = new osv_integrator_1.OSVIntegrator(); const osvPackages = parsed.entries.map(e => ({ name: e.name, version: e.version })); - const osvResults = yield osv.queryBatch(osvPackages); + const osvRaw = yield osv.queryBatch(osvPackages); + live.update('Fetching advisory details from OSV...'); + const osvResults = yield osv.hydrateResults(osvRaw); live.update('Checking registry reputation...'); // 3. Registry reputation const rep = new RegistryReputation(); @@ -2076,7 +2117,7 @@ program const s = yield rep.score(entry.name); repResults.push(s); } - catch (_b) { } + catch (_c) { } } // 4. Provenance (if --provenance) let provResults = []; @@ -2090,7 +2131,7 @@ program const r = yield prov.verify(entry.name, entry.version); provResults.push(r); } - catch (_c) { } + catch (_d) { } } } } @@ -2102,20 +2143,14 @@ program const nap = new NpmAuditParser(); npmAuditResult = yield nap.runAudit(); } - catch (_d) { } + catch (_e) { } } live.stop(); - // Compile report - const vulnsBySeverity = new Map(); - let totalVulns = 0; - for (const r of osvResults) { - for (const v of r.vulnerabilities) { - totalVulns++; - const maxS = osv_integrator_1.OSVIntegrator.getMaxSeverity(v); - const sev = maxS ? osv_integrator_1.OSVIntegrator.toSentinelSeverity(maxS.score) : 'MEDIUM'; - vulnsBySeverity.set(sev, (vulnsBySeverity.get(sev) || 0) + 1); - } - } + // Compile report (severity preserved from source; counts deduplicated) + const summary = summarizeFindings(osvResults); + const totalVulns = summary.instances; + const vulnsBySeverity = new Map(Object.entries(summary.bySourceSeverity)); + const sentinelBySeverity = new Map(Object.entries(summary.bySentinelSeverity)); const suspiciousRep = repResults.filter((r) => r.label === 'SUSPICIOUS' || r.label === 'MALICIOUS'); const verifiedProv = provResults.filter(r => r.verified); if (totalVulns > 0 || suspiciousRep.length > 0) @@ -2128,8 +2163,9 @@ program console.log(pc.white(` Lockfile format: ${pc.cyan(parsed.format)}`)); // OSV if (totalVulns > 0) { - console.log(pc.red(`\n ⚠️ Known Vulnerabilities: ${pc.bold(String(totalVulns))}`)); - const order = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; + console.log(pc.red(`\n ⚠️ Known Vulnerabilities: ${pc.bold(String(totalVulns))} instance(s)`)); + console.log(pc.dim(` unique advisories (GHSA/CVE): ${summary.uniqueGhsa} package@version×advisory: ${summary.pkgVersionGhsa}`)); + const order = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']; for (const s of order) { const c = vulnsBySeverity.get(s); if (c) { @@ -2137,22 +2173,24 @@ program console.log(` ${pc.bgRed(pc.white(` ${s.padEnd(8)} `))} ${'■'.repeat(Math.min(c, 20))} ${c}`); } else { - const color = s === 'HIGH' ? pc.red : s === 'MEDIUM' ? pc.yellow : pc.dim; + const color = s === 'HIGH' ? pc.red : s === 'MEDIUM' ? pc.yellow : s === 'LOW' ? pc.dim : pc.magenta; console.log(` ${color(` ${s.padEnd(8)} `)} ${'■'.repeat(Math.min(c, 20))} ${c}`); } } } - const sorted = osvResults - .filter(r => r.vulnerabilities.length > 0) - .sort((a, b) => b.vulnerabilities.length - a.vulnerabilities.length) - .slice(0, 5); - for (const r of sorted) { - const worst = r.vulnerabilities.slice(0, 3); - for (const v of worst) { - const maxS = osv_integrator_1.OSVIntegrator.getMaxSeverity(v); - const sStr = maxS ? ` (${maxS.score})` : ''; - console.log(pc.dim(` ${r.packageName}@${r.version}: [${v.id}] ${v.summary.substring(0, 70)}${sStr}`)); - } + console.log(pc.dim(` (severity = published by source, counted over unique advisories)`)); + const shownAdvisories = new Set(); + for (const f of summary.findings) { + if (shownAdvisories.has(f.ghsa)) + continue; + shownAdvisories.add(f.ghsa); + if (shownAdvisories.size > 15) + break; + const score = f.cvssScore !== undefined ? ` CVSS:${f.cvssScore}` : ''; + const src = f.sourceSeveritySource !== 'osv.database_specific.severity' && f.sourceSeveritySource !== 'none' + ? ` (${f.sourceSeveritySource})` : ''; + const fix = f.fixedVersions.length ? ` fix: ${f.fixedVersions.join(', ')}` : ''; + console.log(pc.dim(` ${f.package}@${f.version}: [${f.ghsa}] ${f.sourceSeverity}${score}${src}${fix}`)); } } else { @@ -2219,7 +2257,7 @@ program console.log(pc.red(` 🔒 Quarantined: ${pkg.packageName}@${pkg.version}`)); } } - catch (_e) { } + catch (_f) { } } } else { @@ -2228,17 +2266,31 @@ program } if (options.json) { console.log(JSON.stringify({ + status: 'COMPLETE', packages: parsed.entries.length, format: parsed.format, vulnerabilities: totalVulns, + instances: summary.instances, + pkgVersionGhsa: summary.pkgVersionGhsa, + uniqueGhsa: summary.uniqueGhsa, bySeverity: Object.fromEntries(vulnsBySeverity), + bySourceSeverity: summary.bySourceSeverity, + sentinelBySeverity: summary.bySentinelSeverity, + severityMapping: { + sourceSeverity: 'published severity (OSV database_specific.severity, else CVSS vector/score); never fabricated', + sentinelSeverity: 'derived from CVSS base score (>=9 CRITICAL, >=7 HIGH, >=4 MEDIUM, else LOW); falls back to the published severity when no score is available, else UNKNOWN', + counts: 'instances = raw advisory occurrences; pkgVersionGhsa = unique package@version:advisory; uniqueGhsa = unique advisory IDs', + cvssSource: 'OSV.dev / GitHub Advisory Database', + cvssVersion: 'CVSS v3.0/v3.1 scored locally; v4.0 recorded but not scored' + }, + findings: summary.findings, suspiciousPackages: suspiciousRep.length, provenance: verifiedProv.length, npmAudit: (npmAuditResult === null || npmAuditResult === void 0 ? void 0 : npmAuditResult.metadata) || null, durationMs: elapsed }, null, 2)); } - // Exit code: --ci fails on any finding, default fails only on CRITICAL + // Exit code: --ci fails on any finding, default fails only on real findings const shouldFail = options.ci ? anyFindings : totalVulns > 0; if (shouldFail) process.exit(1); diff --git a/dist/mcp/server.js b/dist/mcp/server.js index 66a55d3..c9fe58c 100644 --- a/dist/mcp/server.js +++ b/dist/mcp/server.js @@ -264,7 +264,7 @@ function runA2Tool(name, args) { const cwd = process.cwd(); let lockfilePath = args.lockfile || ''; if (!lockfilePath) { - const candidates = ['package-lock.json', 'yarn.lock']; + const candidates = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']; for (const c of candidates) { const testPath = path.join(cwd, c); if (fs.existsSync(testPath)) { @@ -274,17 +274,27 @@ function runA2Tool(name, args) { } } if (!lockfilePath || !fs.existsSync(lockfilePath)) { - return JSON.stringify({ error: 'No lockfile found. Run npm install first, or specify --lockfile.' }); + return JSON.stringify({ status: 'INCOMPLETE', coverageReason: 'no lockfile found (looked for package-lock.json, yarn.lock, pnpm-lock.yaml)', packages: 0, format: 'unknown', vulnerabilities: 0, bySeverity: {}, suspiciousPackages: 0, provenance: 0, durationMs: 0 }); } const parser = new LockfileParser(); + let lockfileContent = ''; + try { + lockfileContent = fs.readFileSync(lockfilePath, 'utf8'); + } + catch (_b) { } + const unsupported = parser.detectUnsupported(lockfilePath, lockfileContent); + if (unsupported) { + return JSON.stringify({ status: 'INCOMPLETE', coverageReason: unsupported, lockfile: lockfilePath, packages: 0, format: 'unknown', vulnerabilities: 0, bySeverity: {}, suspiciousPackages: 0, provenance: 0, durationMs: 0 }); + } const parsed = parser.parse(lockfilePath); if (parsed.entries.length === 0) { - return JSON.stringify({ packages: 0, format: parsed.format, vulnerabilities: 0, bySeverity: {}, suspiciousPackages: 0, provenance: 0, durationMs: 0 }); + return JSON.stringify({ status: 'INCOMPLETE', coverageReason: `parser returned 0 dependencies (format: ${parsed.format})`, lockfile: lockfilePath, packages: 0, format: parsed.format, vulnerabilities: 0, bySeverity: {}, suspiciousPackages: 0, provenance: 0, durationMs: 0 }); } const startTime = Date.now(); const osv = new OSVIntegrator(); const osvPackages = parsed.entries.map(e => ({ name: e.name, version: e.version })); - const osvResults = yield osv.queryBatch(osvPackages); + const osvRaw = yield osv.queryBatch(osvPackages); + const osvResults = yield osv.hydrateResults(osvRaw); const rep = new RegistryReputation(); const repResults = []; for (const entry of parsed.entries.slice(0, 50)) { @@ -292,7 +302,7 @@ function runA2Tool(name, args) { const s = yield rep.score(entry.name); repResults.push(s); } - catch (_b) { } + catch (_c) { } } let provResults = []; const checkProvenance = args.provenance === 'true' || args.provenance === '1'; @@ -305,7 +315,7 @@ function runA2Tool(name, args) { const r = yield prov.verify(entry.name, entry.version); provResults.push(r); } - catch (_c) { } + catch (_d) { } } } } @@ -324,27 +334,26 @@ function runA2Tool(name, args) { qm.quarantinePackage(pkg.packageName, pkg.version, `Critical CVE: ${((_a = pkg.vulnerabilities[0]) === null || _a === void 0 ? void 0 : _a.id) || 'unknown'}`, 'CRITICAL'); } } - catch (_d) { } + catch (_e) { } } } } - const vulnsBySeverity = new Map(); - let totalVulns = 0; - for (const r of osvResults) { - for (const v of r.vulnerabilities) { - totalVulns++; - const maxS = OSVIntegrator.getMaxSeverity(v); - const sev = maxS ? OSVIntegrator.toSentinelSeverity(maxS.score) : 'MEDIUM'; - vulnsBySeverity.set(sev, (vulnsBySeverity.get(sev) || 0) + 1); - } - } + const { summarizeFindings } = yield Promise.resolve().then(() => __importStar(require('../cli/intelligence/audit_deps_report'))); + const summary = summarizeFindings(osvResults); const suspiciousRep = repResults.filter(r => r.label === 'SUSPICIOUS' || r.label === 'MALICIOUS'); const verifiedProv = provResults.filter(r => r.verified); return JSON.stringify({ + status: 'COMPLETE', packages: parsed.entries.length, format: parsed.format, - vulnerabilities: totalVulns, - bySeverity: Object.fromEntries(vulnsBySeverity), + vulnerabilities: summary.instances, + instances: summary.instances, + pkgVersionGhsa: summary.pkgVersionGhsa, + uniqueGhsa: summary.uniqueGhsa, + bySeverity: summary.bySourceSeverity, + bySourceSeverity: summary.bySourceSeverity, + sentinelBySeverity: summary.bySentinelSeverity, + findings: summary.findings, suspiciousPackages: suspiciousRep.length, provenance: verifiedProv.length, durationMs: Date.now() - startTime @@ -421,7 +430,7 @@ function runA2Tool(name, args) { try { policies = JSON.parse(fs.readFileSync(policyFile, 'utf8')); } - catch (_e) { } + catch (_f) { } const action = args.action || 'list'; if (action === 'list') { return JSON.stringify({ action: 'list', policies }); diff --git a/src/cli/intelligence/audit_deps_report.ts b/src/cli/intelligence/audit_deps_report.ts new file mode 100644 index 0000000..c29301a --- /dev/null +++ b/src/cli/intelligence/audit_deps_report.ts @@ -0,0 +1,108 @@ +import { OSVIntegrator, OSVResult } from './osv_integrator'; + +/** + * Result of an audit-deps run. `INCOMPLETE` means the tool could NOT analyze + * dependencies (unsupported lockfile format, missing lockfile, or a parser that + * returned zero entries). It must never be conflated with "no findings". + */ +export type AuditStatus = 'COMPLETE' | 'INCOMPLETE'; + +export interface Finding { + package: string; + version: string; + /** Advisory identifier (e.g. GHSA-xxxx). */ + ghsa: string; + aliases: string[]; + /** Severity as published by the source (OSV/GHSA), never fabricated. */ + sourceSeverity: string; + /** Where `sourceSeverity` came from (provenance). */ + sourceSeveritySource: string; + cvssType?: string; + cvssVector?: string; + cvssScore?: number; + /** Sentinel's own classification (derived from CVSS when available). */ + sentinelSeverity: string; + /** First-fixed versions declared by the advisory. */ + fixedVersions: string[]; +} + +export interface AuditSummary { + /** Raw advisory instances as returned by OSV (may repeat a GHSA). */ + instances: number; + /** Unique package@version:GHSA combinations. */ + pkgVersionGhsa: number; + /** Unique advisory IDs (GHSA/CVE), regardless of package/version. */ + uniqueGhsa: number; + findings: Finding[]; + /** Distribution of unique advisories by published severity. */ + bySourceSeverity: Record; + /** Distribution of unique advisories by Sentinel severity. */ + bySentinelSeverity: Record; +} + +/** + * Build the corrected audit summary from raw OSV results. + * + * Key guarantees: + * - `instances` counts every advisory occurrence (dedupe not applied). + * - `pkgVersionGhsa` and `uniqueGhsa` expose the deduplicated view so a single + * advisory repeated across versions is NOT counted as independent findings. + * - Severity distribution is computed over UNIQUE advisories and preserves the + * published (source) severity; Sentinel's own severity is reported separately. + */ +export function summarizeFindings(osvResults: OSVResult[]): AuditSummary { + const findings: Finding[] = []; + const seenPkgVerGhsa = new Set(); + const seenGhsa = new Set(); + const bySourceSeverity: Record = {}; + const bySentinelSeverity: Record = {}; + let instances = 0; + let pkgVersionGhsa = 0; + + for (const r of osvResults) { + for (const v of r.vulnerabilities) { + instances++; + + const src = OSVIntegrator.getSourceSeverity(v); + const sentinelSeverity = src.cvssScore !== undefined + ? OSVIntegrator.toSentinelSeverity(src.cvssScore) + : src.level; + + findings.push({ + package: r.packageName, + version: r.version, + ghsa: v.id, + aliases: v.aliases || [], + sourceSeverity: src.level, + sourceSeveritySource: src.source, + cvssType: src.cvssType, + cvssVector: src.cvssVector, + cvssScore: src.cvssScore, + sentinelSeverity, + fixedVersions: OSVIntegrator.getFixedVersions(v), + }); + + const pkgVerGhsaKey = `${r.packageName}@${r.version}:${v.id}`; + if (!seenPkgVerGhsa.has(pkgVerGhsaKey)) { + seenPkgVerGhsa.add(pkgVerGhsaKey); + pkgVersionGhsa++; + } + } + } + + for (const f of findings) { + if (seenGhsa.has(f.ghsa)) continue; + seenGhsa.add(f.ghsa); + bySourceSeverity[f.sourceSeverity] = (bySourceSeverity[f.sourceSeverity] || 0) + 1; + bySentinelSeverity[f.sentinelSeverity] = (bySentinelSeverity[f.sentinelSeverity] || 0) + 1; + } + + return { + instances, + pkgVersionGhsa, + uniqueGhsa: seenGhsa.size, + findings, + bySourceSeverity, + bySentinelSeverity, + }; +} diff --git a/src/cli/intelligence/lockfile_parser.ts b/src/cli/intelligence/lockfile_parser.ts index d6e6083..f665f21 100644 --- a/src/cli/intelligence/lockfile_parser.ts +++ b/src/cli/intelligence/lockfile_parser.ts @@ -10,10 +10,33 @@ export interface LockfileEntry { export interface LockfileResult { entries: LockfileEntry[] - format: 'npm-v6' | 'npm-v7' | 'yarn' | 'unknown' + format: 'npm-v6' | 'npm-v7' | 'yarn' | 'pnpm' | 'yarn-berry' | 'unknown' } export class LockfileParser { + /** + * Detects lockfiles that are recognized by filename but NOT parseable by this + * parser. Returns a human-readable reason, or null when the file is either + * supported or unrecognized. This lets callers report an explicit INCOMPLETE + * status instead of a misleading "no dependencies". + */ + detectUnsupported(filename: string, content: string): string | null { + const base = filename.toLowerCase() + if (base.endsWith('pnpm-lock.yaml')) { + if (this.detectFormat(content) !== 'pnpm') { + return 'pnpm-lock.yaml could not be parsed (unexpected or unsupported version)' + } + return null + } + if (base.endsWith('yarn.lock') && /^__metadata:/m.test(content)) { + if (this.detectFormat(content) !== 'yarn-berry') { + return 'Yarn Berry (v2+) lockfile could not be parsed (unexpected structure)' + } + return null + } + return null + } + detectFormat(content: string): string { const trimmed = content.trim() if (!trimmed) return 'unknown' @@ -21,9 +44,16 @@ export class LockfileParser { const firstLine = trimmed.split('\n')[0].trim() if (firstLine.startsWith('#')) { + // Yarn classic starts with a comment too; Berry is identified by __metadata + if (/^__metadata:/m.test(content)) return 'yarn-berry' return 'yarn' } + // pnpm uses YAML with a top-level lockfileVersion and importers/packages sections + if (/^lockfileVersion:/m.test(content) || /^importers:/m.test(content)) { + return 'pnpm' + } + if (trimmed.startsWith('{')) { try { const parsed = JSON.parse(trimmed) @@ -205,6 +235,173 @@ export class LockfileParser { return { entries, format: 'yarn' } } + /** + * pnpm lockfiles (lockfileVersion 5.x, 6.x, 9.x). Resolved packages live under + * the `packages:` mapping; each key encodes `name@version` (optionally with a + * leading slash and/or a peer-dependency suffix). + */ + parsePnpmLock(content: string): LockfileResult { + const entries: LockfileEntry[] = [] + const seen = new Set() + const lines = content.split(/\r?\n/) + + let inPackages = false + let inImporters = false + let currentPkgName = '' + + for (const line of lines) { + if (/^packages:/.test(line) || /^snapshots:/.test(line)) { inPackages = true; inImporters = false; continue } + if (/^importers:/.test(line)) { inImporters = true; inPackages = false; continue } + if (/^[A-Za-z_][A-Za-z0-9_-]*:/.test(line) && !/^ {2,}/.test(line)) { inPackages = false; inImporters = false; continue } + + if (inPackages) { + const keyMatch = line.match(/^ {2}['"]?([^'"#\s].*?)['"]?\s*:\s*$/) + if (!keyMatch) continue + const rawKey = keyMatch[1].trim() + if (!rawKey) continue + if (/^resolution|^dependencies:|^optionalDependencies:|^transitivePeerDependencies:|^version:|^specifier:/.test(rawKey)) continue + const parsed = this.parsePnpmKey(rawKey) + if (!parsed) continue + const id = `${parsed.name}@${parsed.version}` + if (seen.has(id)) continue + seen.add(id) + entries.push({ name: parsed.name, version: parsed.version, resolved: '', integrity: '', dependencies: [] }) + continue + } + + if (inImporters) { + // Workspace importers like " .:" or " packages/foo:" + const importerMatch = line.match(/^ {2}(.+?):\s*$/) + if (importerMatch && !/^ {4}/.test(line)) { + currentPkgName = '' + continue + } + // Dependency names at indent level 4 under dependencies/spec/devDependencies + const depNameMatch = line.match(/^ {6}([@A-Za-z0-9][^:\s]*?):\s*$/) + if (depNameMatch) { + currentPkgName = depNameMatch[1] + continue + } + if (currentPkgName) { + const verMatch = line.match(/^\s+version:\s*['"]?([0-9][^'"\s]*)['"]?\s*$/) + if (verMatch) { + const id = `${currentPkgName}@${verMatch[1]}` + if (!seen.has(id)) { + seen.add(id) + entries.push({ name: currentPkgName, version: verMatch[1], resolved: '', integrity: '', dependencies: [] }) + } + currentPkgName = '' + continue + } + } + } + } + + return { entries, format: 'pnpm' } + } + + private parsePnpmKey(rawKey: string): { name: string; version: string } | null { + let key = rawKey.trim().replace(/^['"]|['"]$/g, '') + if (!key) return null + key = key.replace(/^\//, '') + while (/\([^()]*\)/.test(key)) { key = key.replace(/\([^()]*\)/, '') } + key = key.split(/#/, 1)[0] + key = key.split(/,/)[0].trim() + if (key.includes('/node_modules/')) return null + if (!key.includes('@')) return null + + const at = key.lastIndexOf('@') + if (at <= 0) return null + const name = key.slice(0, at) + const version = key.slice(at + 1) + if (!name || !version) return null + if (!/^[0-9]/.test(version)) return null + return { name, version } + } + + /** + * Yarn Berry (v2+) lockfiles with __metadata. + */ + parseYarnBerry(content: string): LockfileResult { + const entries: LockfileEntry[] = [] + const lines = content.split(/\r?\n/) + + let currentName = '' + let currentVersion = '' + let currentResolution = '' + let currentDeps: string[] = [] + let inDeps = false + + const flush = () => { + if (!currentName || !currentVersion) return + entries.push({ name: currentName, version: currentVersion, resolved: currentResolution, integrity: '', dependencies: currentDeps }) + } + + for (const line of lines) { + if (line.startsWith('__metadata:')) continue + if (/^\s*#/.test(line)) continue + + const headerMatch = line.match(/^("?)([^\s].*?)\1:\s*$/) + if (headerMatch) { + flush() + currentName = '' + currentVersion = '' + currentResolution = '' + currentDeps = [] + inDeps = false + + const header = headerMatch[2].replace(/"/g, '') + const descriptors = header.split(/,\s+/) + currentName = this.descriptorToName(descriptors[0]) + continue + } + + const versionMatch = line.match(/^\s+version:\s*"?([^"\s]+)"?\s*$/) + if (versionMatch && !inDeps) { currentVersion = versionMatch[1]; continue } + + const resolutionMatch = line.match(/^\s+resolution:\s*"?([^"\s]+)"?\s*$/) + if (resolutionMatch) { + currentResolution = resolutionMatch[1] + const parsed = this.parseBerryResolution(resolutionMatch[1]) + if (parsed) { currentName = parsed.name; currentVersion = parsed.version } + continue + } + + if (/^\s+dependencies:\s*$/.test(line)) { inDeps = true; continue } + if (/^[^ \t]/.test(line) && !line.trim().startsWith('#')) { inDeps = false } + + if (inDeps) { + const depMatch = line.match(/^\s{2,}([^\s:]+):\s*"?([^"\s]+)"?\s*$/) + if (depMatch) { currentDeps.push(this.normalizeDepName(depMatch[1])); continue } + } + } + + flush() + return { entries, format: 'yarn-berry' } + } + + private descriptorToName(descriptor: string): string { + let d = descriptor.replace(/^"|"$/g, '') + const at = d.lastIndexOf('@') + const name = at > 0 ? d.slice(0, at) : d + return name + } + + private parseBerryResolution(resolution: string): { name: string; version: string } | null { + const cleaned = resolution.replace(/^"|"$/g, '') + const m = cleaned.match(/^(.+)@(?:npm|workspace|patch|file|link):(.+)$/) + if (!m) return null + const name = m[1] + let version = m[2] + version = version.split(/[,(]/)[0].trim() + if (!name || !/^[0-9]/.test(version)) return null + return { name, version } + } + + private normalizeDepName(name: string): string { + return name.replace(/^"|"$/g, '').replace(/,\s*$/, '') + } + parse(path: string): LockfileResult { let content: string try { @@ -221,6 +418,10 @@ export class LockfileParser { return this.parsePackageLock(content) case 'yarn': return this.parseYarnLock(content) + case 'pnpm': + return this.parsePnpmLock(content) + case 'yarn-berry': + return this.parseYarnBerry(content) default: return { entries: [], format: 'unknown' } } diff --git a/src/cli/intelligence/osv_integrator.ts b/src/cli/intelligence/osv_integrator.ts index 3fca8ba..f698c55 100644 --- a/src/cli/intelligence/osv_integrator.ts +++ b/src/cli/intelligence/osv_integrator.ts @@ -29,6 +29,7 @@ export interface OSVResult { export class OSVIntegrator { private static readonly API_URL = 'https://api.osv.dev/v1/query'; private static readonly BATCH_API_URL = 'https://api.osv.dev/v1/querybatch'; + private static readonly VULN_API_URL = 'https://api.osv.dev/v1/vulns'; /** * Query OSV.dev for known vulnerabilities affecting a package@version. @@ -138,14 +139,208 @@ export class OSVIntegrator { } } + /** + * OSV's /querybatch endpoint returns only minimal vuln stubs (id + modified) + * — no severity, no affected ranges. This hydrates each unique advisory with + * its full /v1/vulns/{id} document so source severity and fixed versions are + * actually available. Hydration is deduplicated by advisory ID and bounded to + * 8 concurrent requests. Advisories that fail to hydrate keep their stub + * (later reported as UNKNOWN, never fabricated). + */ + public async hydrateResults(results: OSVResult[]): Promise { + const ids = new Set() + for (const r of results) for (const v of r.vulnerabilities) if (v.id) ids.add(v.id) + if (ids.size === 0) return results + + const list = Array.from(ids) + const cache = new Map() + const CONCURRENCY = 8 + for (let i = 0; i < list.length; i += CONCURRENCY) { + const slice = list.slice(i, i + CONCURRENCY) + const docs = await Promise.all(slice.map(id => this.fetchVuln(id))) + docs.forEach((d, idx) => { if (d) cache.set(slice[idx], d) }) + } + + return results.map(r => ({ + ...r, + vulnerabilities: r.vulnerabilities.map(v => cache.get(v.id) || v) + })) + } + + private async fetchVuln(id: string): Promise { + try { + const response = await fetch(`${OSVIntegrator.VULN_API_URL}/${encodeURIComponent(id)}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000) + }) + if (!response.ok) return null + const v = (await response.json()) as any + return { + id: v.id, + summary: v.summary || '', + aliases: v.aliases || [], + severity: v.severity || [], + published: v.published || '', + modified: v.modified || '', + database_specific: v.database_specific, + affected: v.affected + } + } catch { + return null + } + } + + /** + * Normalize a published severity label (OSV/GHSA `database_specific.severity`, + * or CVSS scalar strings) to Sentinel's CRITICAL/HIGH/MEDIUM/LOW vocabulary. + * Returns 'UNKNOWN' when the source value is not a recognized severity — it + * must never be silently coerced into MEDIUM. + */ + public static normalizeSeverity(value: string): string { + const u = String(value || '').trim().toUpperCase(); + if (u === 'MODERATE') return 'MEDIUM'; + if (u === 'CRITICAL' || u === 'HIGH' || u === 'MEDIUM' || u === 'LOW') return u; + return 'UNKNOWN'; + } + + /** + * Extract a numeric CVSS score from an OSV `severity[].score` value. + * The value may be either a bare number ("7.5") or a CVSS **vector** + * ("CVSS:3.1/AV:N/..."). CVSS v3.x vectors are scored; CVSS v4.0 vectors + * return null because their base metric (MacroVector) is not implemented + * here — callers must fall back to the published severity label. + */ + public static severityScoreOf(raw: string): number | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (/^[0-9]/.test(trimmed)) { + const n = parseFloat(trimmed); + return isNaN(n) ? null : n; + } + if (trimmed.startsWith('CVSS:3.0/') || trimmed.startsWith('CVSS:3.1/')) { + return OSVIntegrator.parseCvssV3Vector(trimmed); + } + return null; + } + + /** + * Compute the CVSS v3.0 / v3.1 base score from a vector string. + * Implements the official base-score formula (FIRST spec). Returns null on + * malformed vectors. + */ + public static parseCvssV3Vector(vector: string): number | null { + try { + if (!/^CVSS:3\.[01]\//.test(vector)) return null; + const metric = (k: string): string => { + const m = vector.match(new RegExp('(?:^|/)' + k + ':([A-Za-z])')); + return m ? m[1] : ''; + }; + const AV = ({ N: 0.85, A: 0.62, L: 0.55, P: 0.2 } as Record)[metric('AV')]; + const AC = ({ L: 0.77, H: 0.44 } as Record)[metric('AC')]; + const UI = ({ N: 0.85, R: 0.62 } as Record)[metric('UI')]; + const S = metric('S'); + const CIA = ({ N: 0.0, L: 0.22, H: 0.56 } as Record); + const C = CIA[metric('C')]; + const I = CIA[metric('I')]; + const A = CIA[metric('A')]; + const prTable = S === 'C' + ? ({ N: 0.85, L: 0.68, H: 0.5 } as Record) + : ({ N: 0.85, L: 0.62, H: 0.27 } as Record); + const PR = prTable[metric('PR')]; + + if ([AV, AC, UI, C, I, A, PR].some(v => v === undefined) || (S !== 'U' && S !== 'C')) { + return null; + } + + const iss = 1 - ((1 - C) * (1 - I) * (1 - A)); + const impact = S === 'U' + ? 6.42 * iss + : 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15); + if (impact <= 0) return 0; + + const exploitability = 8.22 * AV * AC * PR * UI; + const roundup = (x: number): number => Math.ceil(x * 10) / 10; + const base = S === 'U' + ? roundup(Math.min(impact + exploitability, 10)) + : roundup(Math.min(1.08 * (impact + exploitability), 10)); + return base; + } catch { + return null; + } + } + + /** + * Resolve the **published** severity of an advisory plus its provenance, + * without ever fabricating a level. Priority: + * 1. database_specific.severity (OSV/GitHub published label) + * 2. CVSS v3.x vector → numeric base score + * 3. numeric CVSS score string + * 4. CVSS v4.0 vector is recorded but left UNKNOWN (not scored here) + * 5. otherwise UNKNOWN with source 'none' + */ + public static getSourceSeverity(vuln: OSVVuln): { + level: string; + source: string; + cvssType?: string; + cvssVector?: string; + cvssScore?: number; + } { + const db = vuln.database_specific && (vuln.database_specific as any).severity; + if (typeof db === 'string' && db.trim()) { + return { level: OSVIntegrator.normalizeSeverity(db), source: 'osv.database_specific.severity' }; + } + + let best: { type: string; score: number; vector: string } | null = null; + for (const s of (vuln.severity || [])) { + const num = OSVIntegrator.severityScoreOf(s.score); + if (num !== null && (!best || num > best.score)) { + best = { type: s.type, score: num, vector: s.score }; + } + } + if (best) { + const fromVector = /^CVSS:/.test(best.vector); + return { + level: OSVIntegrator.toSentinelSeverity(best.score), + source: fromVector ? 'osv.severity.cvss_vector' : 'osv.severity.cvss_score', + cvssType: best.type, + cvssVector: best.vector, + cvssScore: best.score + }; + } + + const v4 = (vuln.severity || []).find(s => s.type === 'CVSS_V4' || /^CVSS:4\.0\//.test(s.score || '')); + if (v4) { + return { level: 'UNKNOWN', source: 'osv.severity.cvss_v4_unmapped', cvssType: v4.type, cvssVector: v4.score }; + } + + return { level: 'UNKNOWN', source: 'none' }; + } + + /** + * Collect the first-fixed versions declared by an advisory's affected ranges. + */ + public static getFixedVersions(vuln: OSVVuln): string[] { + const out = new Set(); + for (const a of (vuln.affected || [])) { + for (const r of (a.ranges || [])) { + for (const e of (r.events || [])) { + if (e && typeof e.fixed === 'string' && e.fixed) out.add(e.fixed); + } + } + } + return Array.from(out).sort(); + } + /** * Get the highest CVSS score from a vulnerability's severity array. + * Accepts both numeric score strings and CVSS v3.x vectors. */ public static getMaxSeverity(vuln: OSVVuln): { type: string; score: number } | null { let max: { type: string; score: number } | null = null; for (const s of vuln.severity) { - const score = parseFloat(s.score); - if (!isNaN(score) && (!max || score > max.score)) { + const score = OSVIntegrator.severityScoreOf(s.score); + if (score !== null && (!max || score > max.score)) { max = { type: s.type, score }; } } @@ -153,7 +348,7 @@ export class OSVIntegrator { } /** - * Map OSV severity to Sentinel severity + * Map a numeric CVSS score to Sentinel severity. */ public static toSentinelSeverity(cvssScore: number): string { if (cvssScore >= 9.0) return 'CRITICAL'; diff --git a/src/cli/intelligence/supply_chain_a2.test.ts b/src/cli/intelligence/supply_chain_a2.test.ts index a3ba8a8..eafe58b 100644 --- a/src/cli/intelligence/supply_chain_a2.test.ts +++ b/src/cli/intelligence/supply_chain_a2.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { OSVIntegrator } from './osv_integrator'; +import { summarizeFindings } from './audit_deps_report'; import { TyposquatDetector } from './typosquat_detector'; import { TrustCache } from './trust_cache'; import { DepsScanner } from './deps_scanner'; @@ -42,6 +43,112 @@ describe('OSVIntegrator', () => { it('maps 4.0-6.9 to MEDIUM', () => { expect(OSVIntegrator.toSentinelSeverity(5.5)).toBe('MEDIUM'); }); it('maps <4.0 to LOW', () => { expect(OSVIntegrator.toSentinelSeverity(3.9)).toBe('LOW'); }); }); + + describe('getMaxSeverity with CVSS vectors', () => { + it('scores a CVSS v3.1 vector instead of NaN', () => { + const result = OSVIntegrator.getMaxSeverity({ + id: 'GHSA-v', summary: '', aliases: [], + severity: [{ type: 'CVSS_V3', score: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N' }], + published: '', modified: '' + }); + expect(result).toEqual({ type: 'CVSS_V3', score: 7.5 }); + }); + + it('does not score CVSS v4.0 vectors (returns null)', () => { + expect(OSVIntegrator.parseCvssV3Vector('CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N')).toBeNull(); + }); + }); + + describe('getSourceSeverity', () => { + it('prefers the published database_specific.severity', () => { + const r = OSVIntegrator.getSourceSeverity({ + id: 'GHSA-1', summary: '', aliases: [], severity: [], published: '', modified: '', + database_specific: { severity: 'HIGH' } + }); + expect(r.level).toBe('HIGH'); + expect(r.source).toBe('osv.database_specific.severity'); + }); + + it('normalizes MODERATE to MEDIUM', () => { + const r = OSVIntegrator.getSourceSeverity({ + id: 'GHSA-2', summary: '', aliases: [], severity: [], published: '', modified: '', + database_specific: { severity: 'MODERATE' } + }); + expect(r.level).toBe('MEDIUM'); + }); + + it('derives severity from a CVSS v3 vector when no published label exists', () => { + const r = OSVIntegrator.getSourceSeverity({ + id: 'GHSA-3', summary: '', aliases: [], published: '', modified: '', + severity: [{ type: 'CVSS_V3', score: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N' }] + }); + expect(r.level).toBe('HIGH'); + expect(r.cvssScore).toBe(7.5); + expect(r.source).toBe('osv.severity.cvss_vector'); + }); + + it('returns UNKNOWN (never MEDIUM) when no severity is available', () => { + const r = OSVIntegrator.getSourceSeverity({ id: 'GHSA-4', summary: '', aliases: [], severity: [], published: '', modified: '' }); + expect(r.level).toBe('UNKNOWN'); + expect(r.source).toBe('none'); + }); + + it('records a CVSS v4.0 vector as unmapped UNKNOWN', () => { + const r = OSVIntegrator.getSourceSeverity({ + id: 'GHSA-5', summary: '', aliases: [], published: '', modified: '', + severity: [{ type: 'CVSS_V4', score: 'CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N' }] + }); + expect(r.level).toBe('UNKNOWN'); + expect(r.source).toBe('osv.severity.cvss_v4_unmapped'); + }); + }); + + describe('getFixedVersions', () => { + it('collects first-fixed versions from affected ranges', () => { + const fixes = OSVIntegrator.getFixedVersions({ + id: 'GHSA-f', summary: '', aliases: [], severity: [], published: '', modified: '', + affected: [{ ranges: [{ events: [{ introduced: '5.0.0' }, { fixed: '5.0.7' }] }] }] + }); + expect(fixes).toEqual(['5.0.7']); + }); + }); +}); + +// ─── audit-deps summary ────────────────────────────────────────────────────── + +describe('summarizeFindings', () => { + const mkVuln = (id: string, severity: string) => ({ + id, summary: '', aliases: [], severity: [], published: '', modified: '', + database_specific: { severity } + }); + + it('separates instances, package@version×advisory and unique advisories', () => { + const results: any[] = [ + { + packageName: 'brace-expansion', version: '1.1.12', queryTimeMs: 0, + vulnerabilities: [mkVuln('GHSA-x', 'HIGH'), mkVuln('GHSA-y', 'MODERATE')] + }, + { + packageName: 'brace-expansion', version: '5.0.3', queryTimeMs: 0, + vulnerabilities: [mkVuln('GHSA-x', 'HIGH')] // same GHSA, different version + }, + ]; + const s = summarizeFindings(results); + expect(s.instances).toBe(3); + expect(s.pkgVersionGhsa).toBe(3); + expect(s.uniqueGhsa).toBe(2); + expect(s.bySourceSeverity.HIGH).toBe(1); // GHSA-x counted once + expect(s.bySourceSeverity.MEDIUM).toBe(1); // MODERATE normalized + }); + + it('counts unscored advisories as UNKNOWN, never MEDIUM', () => { + const results: any[] = [ + { packageName: 'p', version: '1.0.0', queryTimeMs: 0, vulnerabilities: [mkVuln('GHSA-z', undefined as any)] } + ]; + const s = summarizeFindings(results); + expect(s.bySourceSeverity.UNKNOWN).toBe(1); + expect(s.bySourceSeverity.MEDIUM).toBeUndefined(); + }); }); // ─── Typosquat Detector ───────────────────────────────────────────────────── diff --git a/src/cli/main.ts b/src/cli/main.ts index 1a972b0..b3a9f60 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -2078,28 +2078,64 @@ program const { ProvenanceVerifier } = await import('./intelligence/provenance_verifier'); const { QuarantineManager } = await import('./intelligence/quarantine'); const { NpmAuditParser } = await import('./intelligence/npm_audit_parser'); + const { summarizeFindings } = await import('./intelligence/audit_deps_report'); const cwd = process.cwd(); + const jsonOut = !!options.json; + + // Emit an explicit INCOMPLETE result. Dependencies were NOT analyzed, so + // this must never be reported as "no findings". With --ci it exits 2 so + // automated pipelines can detect the state; otherwise it exits 0. + const emitIncomplete = (reason: string, lockfile: string | null): never => { + if (jsonOut) { + console.log(JSON.stringify({ + status: 'INCOMPLETE', + coverageReason: reason, + lockfile: lockfile || null, + packages: 0, + format: 'unknown', + instances: 0, + pkgVersionGhsa: 0, + uniqueGhsa: 0, + vulnerabilities: 0, + bySeverity: {}, + bySourceSeverity: {}, + sentinelBySeverity: {}, + suspiciousPackages: 0, + provenance: 0, + npmAudit: null, + durationMs: 0 + }, null, 2)); + } else { + console.log(pc.yellow(`\n ⚠️ INCOMPLETE — ${reason}`)); + console.log(pc.dim(' Dependencies were NOT analyzed. This is not a clean result.')); + } + process.exit(options.ci ? 2 : 0); + }; // 1. Detect and parse lockfile let lockfilePath = options.lockfile; if (!lockfilePath) { - const candidates = ['package-lock.json', 'yarn.lock']; + const candidates = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']; for (const c of candidates) { const testPath = path.join(cwd, c); if (fs.existsSync(testPath)) { lockfilePath = testPath; break; } } } if (!lockfilePath || !fs.existsSync(lockfilePath)) { - console.error(pc.red('No lockfile found. Run npm install first, or specify --lockfile.')); - return; + emitIncomplete('no lockfile found (looked for package-lock.json, yarn.lock, pnpm-lock.yaml)', null); } const parser = new LockfileParser(); - const parsed = parser.parse(lockfilePath); + let lockfileContent = ''; + try { lockfileContent = fs.readFileSync(lockfilePath!, 'utf8'); } catch {} + const unsupported = parser.detectUnsupported(lockfilePath!, lockfileContent); + if (unsupported) { + emitIncomplete(unsupported, lockfilePath!); + } + const parsed = parser.parse(lockfilePath!); if (parsed.entries.length === 0) { - console.log(pc.yellow('No dependencies found in lockfile.')); - return; + emitIncomplete(`parser returned 0 dependencies (format: ${parsed.format})`, lockfilePath!); } console.log(pc.cyan(`\n📋 Audit: ${parsed.entries.length} dependencies (${parsed.format})`)); @@ -2113,7 +2149,9 @@ program // 2. Batch OSV query const osv = new OSVIntegrator(); const osvPackages = parsed.entries.map(e => ({ name: e.name, version: e.version })); - const osvResults = await osv.queryBatch(osvPackages); + const osvRaw = await osv.queryBatch(osvPackages); + live.update('Fetching advisory details from OSV...'); + const osvResults = await osv.hydrateResults(osvRaw); live.update('Checking registry reputation...'); @@ -2155,17 +2193,11 @@ program live.stop(); - // Compile report - const vulnsBySeverity = new Map(); - let totalVulns = 0; - for (const r of osvResults) { - for (const v of r.vulnerabilities) { - totalVulns++; - const maxS = OSVIntegrator.getMaxSeverity(v); - const sev = maxS ? OSVIntegrator.toSentinelSeverity(maxS.score) : 'MEDIUM'; - vulnsBySeverity.set(sev, (vulnsBySeverity.get(sev) || 0) + 1); - } - } + // Compile report (severity preserved from source; counts deduplicated) + const summary = summarizeFindings(osvResults); + const totalVulns = summary.instances; + const vulnsBySeverity = new Map(Object.entries(summary.bySourceSeverity)); + const sentinelBySeverity = new Map(Object.entries(summary.bySentinelSeverity)); const suspiciousRep = repResults.filter((r: any) => r.label === 'SUSPICIOUS' || r.label === 'MALICIOUS'); const verifiedProv = provResults.filter(r => r.verified); @@ -2181,30 +2213,31 @@ program // OSV if (totalVulns > 0) { - console.log(pc.red(`\n ⚠️ Known Vulnerabilities: ${pc.bold(String(totalVulns))}`)); - const order = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; + console.log(pc.red(`\n ⚠️ Known Vulnerabilities: ${pc.bold(String(totalVulns))} instance(s)`)); + console.log(pc.dim(` unique advisories (GHSA/CVE): ${summary.uniqueGhsa} package@version×advisory: ${summary.pkgVersionGhsa}`)); + const order = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']; for (const s of order) { const c = vulnsBySeverity.get(s); if (c) { if (s === 'CRITICAL') { console.log(` ${pc.bgRed(pc.white(` ${s.padEnd(8)} `))} ${'■'.repeat(Math.min(c, 20))} ${c}`); } else { - const color = s === 'HIGH' ? pc.red : s === 'MEDIUM' ? pc.yellow : pc.dim; + const color = s === 'HIGH' ? pc.red : s === 'MEDIUM' ? pc.yellow : s === 'LOW' ? pc.dim : pc.magenta; console.log(` ${color(` ${s.padEnd(8)} `)} ${'■'.repeat(Math.min(c, 20))} ${c}`); } } } - const sorted = osvResults - .filter(r => r.vulnerabilities.length > 0) - .sort((a, b) => b.vulnerabilities.length - a.vulnerabilities.length) - .slice(0, 5); - for (const r of sorted) { - const worst = r.vulnerabilities.slice(0, 3); - for (const v of worst) { - const maxS = OSVIntegrator.getMaxSeverity(v); - const sStr = maxS ? ` (${maxS.score})` : ''; - console.log(pc.dim(` ${r.packageName}@${r.version}: [${v.id}] ${v.summary.substring(0, 70)}${sStr}`)); - } + console.log(pc.dim(` (severity = published by source, counted over unique advisories)`)); + const shownAdvisories = new Set(); + for (const f of summary.findings) { + if (shownAdvisories.has(f.ghsa)) continue; + shownAdvisories.add(f.ghsa); + if (shownAdvisories.size > 15) break; + const score = f.cvssScore !== undefined ? ` CVSS:${f.cvssScore}` : ''; + const src = f.sourceSeveritySource !== 'osv.database_specific.severity' && f.sourceSeveritySource !== 'none' + ? ` (${f.sourceSeveritySource})` : ''; + const fix = f.fixedVersions.length ? ` fix: ${f.fixedVersions.join(', ')}` : ''; + console.log(pc.dim(` ${f.package}@${f.version}: [${f.ghsa}] ${f.sourceSeverity}${score}${src}${fix}`)); } } else { console.log(pc.green(`\n CVEs: None known`)); @@ -2277,10 +2310,24 @@ program if (options.json) { console.log(JSON.stringify({ + status: 'COMPLETE', packages: parsed.entries.length, format: parsed.format, vulnerabilities: totalVulns, + instances: summary.instances, + pkgVersionGhsa: summary.pkgVersionGhsa, + uniqueGhsa: summary.uniqueGhsa, bySeverity: Object.fromEntries(vulnsBySeverity), + bySourceSeverity: summary.bySourceSeverity, + sentinelBySeverity: summary.bySentinelSeverity, + severityMapping: { + sourceSeverity: 'published severity (OSV database_specific.severity, else CVSS vector/score); never fabricated', + sentinelSeverity: 'derived from CVSS base score (>=9 CRITICAL, >=7 HIGH, >=4 MEDIUM, else LOW); falls back to the published severity when no score is available, else UNKNOWN', + counts: 'instances = raw advisory occurrences; pkgVersionGhsa = unique package@version:advisory; uniqueGhsa = unique advisory IDs', + cvssSource: 'OSV.dev / GitHub Advisory Database', + cvssVersion: 'CVSS v3.0/v3.1 scored locally; v4.0 recorded but not scored' + }, + findings: summary.findings, suspiciousPackages: suspiciousRep.length, provenance: verifiedProv.length, npmAudit: npmAuditResult?.metadata || null, @@ -2288,7 +2335,7 @@ program }, null, 2)); } - // Exit code: --ci fails on any finding, default fails only on CRITICAL + // Exit code: --ci fails on any finding, default fails only on real findings const shouldFail = options.ci ? anyFindings : totalVulns > 0; if (shouldFail) process.exit(1); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fbd1bfa..9baf7d3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -244,27 +244,34 @@ async function runA2Tool(name: string, args: Record): Promise ({ name: e.name, version: e.version })) - const osvResults = await osv.queryBatch(osvPackages) + const osvRaw = await osv.queryBatch(osvPackages) + const osvResults = await osv.hydrateResults(osvRaw) const rep = new RegistryReputation() const repResults: any[] = [] @@ -305,25 +312,24 @@ async function runA2Tool(name: string, args: Record): Promise() - let totalVulns = 0 - for (const r of osvResults) { - for (const v of r.vulnerabilities) { - totalVulns++ - const maxS = OSVIntegrator.getMaxSeverity(v) - const sev = maxS ? OSVIntegrator.toSentinelSeverity(maxS.score) : 'MEDIUM' - vulnsBySeverity.set(sev, (vulnsBySeverity.get(sev) || 0) + 1) - } - } + const { summarizeFindings } = await import('../cli/intelligence/audit_deps_report') + const summary = summarizeFindings(osvResults) const suspiciousRep = repResults.filter(r => r.label === 'SUSPICIOUS' || r.label === 'MALICIOUS') const verifiedProv = provResults.filter(r => r.verified) return JSON.stringify({ + status: 'COMPLETE', packages: parsed.entries.length, format: parsed.format, - vulnerabilities: totalVulns, - bySeverity: Object.fromEntries(vulnsBySeverity), + vulnerabilities: summary.instances, + instances: summary.instances, + pkgVersionGhsa: summary.pkgVersionGhsa, + uniqueGhsa: summary.uniqueGhsa, + bySeverity: summary.bySourceSeverity, + bySourceSeverity: summary.bySourceSeverity, + sentinelBySeverity: summary.bySentinelSeverity, + findings: summary.findings, suspiciousPackages: suspiciousRep.length, provenance: verifiedProv.length, durationMs: Date.now() - startTime