Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ backward compatible.

### Fixed

- **An unparseable CVSS or severity vector is no longer silent.** `vg scan --vulns` (and the offline package manifest) used to drop a vector it could not parse and leave the advisory with no numeric score, the same shape as an advisory that never had one. A failed vector now keeps the score unset and adds a warning (`vibgrate/cvss-vector`) naming the parse failure and what to supply instead. A missing vector stays missing. A valid CVSS v3.0/v3.1 base vector still scores as before, including a real zero when every impact metric is None. The warning does not echo the raw vector.

- **`vg show arch` clipped the map to a fixed viewport.** Columns that ran off the
bottom of the window could not be scrolled or zoomed; the canvas is now a
pannable, zoomable map (scroll or drag, pinch / Ctrl-scroll, + / −).
Expand Down
2 changes: 1 addition & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1133,7 +1133,7 @@ Expected results:

### Vulnerabilities and exposure attribution

`vg scan --vulns` matches your installed dependencies against the public OSV database and records each known vulnerability — advisory id and CVE, severity, CVSS, and the fixing version — in the scan artifact, as findings, and in SARIF. Supply advisories in a `--package-manifest` bundle to run it offline.
`vg scan --vulns` matches your installed dependencies against the public OSV database and records each known vulnerability — advisory id and CVE, severity, CVSS, and the fixing version — in the scan artifact, as findings, and in SARIF. Supply advisories in a `--package-manifest` bundle to run it offline. A CVSS or severity vector that cannot be parsed is reported as a warning (`vibgrate/cvss-vector`) and left without a numeric score: it is not shown as a missing score and it is not shown as zero. An advisory that carries no vector still has no score.

In a git repository the scan also attributes each finding: the commit, author, and date that introduced the vulnerable version, and how long you have been exposed. These exposure windows aggregate into remediation metrics framed around the [EU Cyber Resilience Act (CRA)](https://vibgrate.com/compliance/cra): open counts by severity, mean and maximum time exposed, and per-severity SLA breaches (defaults: critical 7 days, high 30, moderate 90, low 180). The metrics are descriptive — they show whether remediation keeps pace; they are not a compliance certification.

Expand Down
1 change: 1 addition & 0 deletions src/commands/why.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function registerWhy(program: Command): void {
const cvss = adv.cvss != null ? ` cvss ${adv.cvss}` : '';
const fixed = adv.fixedVersions.length ? ` — fixed in ${adv.fixedVersions.join(', ')}` : ' — no fix available';
info(` ${severityTag(adv.severity)} ${idLabel}${c.dim(cvss)}${c.dim(fixed)}`);
if (adv.cvssDiagnostic) info(c.yellow(` ${adv.cvssDiagnostic}`));
if (adv.introduced) {
const exposure = adv.exposureDays != null ? `, ${adv.exposureDays}d exposed` : '';
info(
Expand Down
5 changes: 5 additions & 0 deletions src/core-open/formatters/sarif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ function buildRules(findings: Finding[]) {
shortDescription: { text: 'Known vulnerability in an installed dependency' },
helpUri: 'https://vibgrate.com/rules/vulnerability',
},
'vibgrate/cvss-vector': {
id: 'vibgrate/cvss-vector',
shortDescription: { text: 'CVSS or severity vector could not be parsed' },
helpUri: 'https://vibgrate.com/rules/cvss-vector',
},
};
return descriptions[id] ?? {
id,
Expand Down
4 changes: 3 additions & 1 deletion src/core-open/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,11 @@ export {
parseOsvAdvisory,
projectTypeToVulnEcosystem,
VULN_RULE_ID,
CVSS_VECTOR_RULE_ID,
type VulnTarget,
} from './scanners/vulnerability-scanner.js';
export { cvssV3BaseScore, severityFromCvss, severityRank, normalizeSeverityLabel } from './scoring/cvss.js';
export { cvssV3BaseScore, parseCvssV3, severityFromCvss, severityRank, normalizeSeverityLabel } from './scoring/cvss.js';
export type { CvssParseResult } from './scoring/cvss.js';
export { computeUpgradeImpact, analyzeUsage, computeVersionJump } from './scanners/upgrade-impact.js';
export {
getChangelogSignals,
Expand Down
141 changes: 108 additions & 33 deletions src/core-open/scanners/vulnerability-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type ManifestEcosystem,
type PackageVersionManifest,
} from '../package-version-manifest.js';
import { cvssV3BaseScore, normalizeSeverityLabel, severityFromCvss, severityRank } from '../scoring/cvss.js';
import { parseCvssV3, normalizeSeverityLabel, severityFromCvss, severityRank } from '../scoring/cvss.js';
import type {
AffectedRange,
Finding,
Expand Down Expand Up @@ -173,7 +173,7 @@ export function isVersionAffected(

interface RawOsvSeverity {
type?: string;
score?: string;
score?: unknown;
}
interface RawOsvRangeEvent {
introduced?: string;
Expand All @@ -199,10 +199,59 @@ interface RawOsvVuln {
database_specific?: { severity?: string };
}

/** A severity score that is present (blank strings count as absent). */
function severityScorePresent(score: unknown): boolean {
if (score == null) return false;
if (typeof score === 'string') return score.trim().length > 0;
return true;
}

/**
* The CVSS / severity vector to score.
*
* A CVSS v3 entry wins, matching the previous selection. When the advisory
* carries only some other CVSS vector (v2, v4, …), that string is still
* returned so a failed parse is visible instead of looking like no score.
* No severity vector at all returns null.
*/
function advisorySeverityVector(raw: RawOsvVuln): unknown {
const entries = raw.severity ?? [];
const v3 = entries.find(
(s) => (s.type ?? '').toUpperCase().startsWith('CVSS_V3') && severityScorePresent(s.score),
);
if (v3) return v3.score;
const other = entries.find(
(s) => (s.type ?? '').toUpperCase().startsWith('CVSS') && severityScorePresent(s.score),
);
return other ? other.score : null;
}

/**
* Keep a short, single-line vector on the advisory. A value with line breaks
* or an unreasonable length is omitted so a vector field that actually holds
* unrelated text is not copied into the report.
*/
function reportVector(value: unknown): string | null {
if (typeof value !== 'string') return null;
if (/[\r\n]/.test(value)) return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 180) return null;
return value;
}

function withDiagnostic<T extends { cvss: number | null; cvssVector: string | null }>(
advisory: T,
diagnostic: string | undefined,
): T & { cvssDiagnostic?: string } {
return diagnostic ? { ...advisory, cvssDiagnostic: diagnostic } : advisory;
}

/** Parse a raw OSV advisory into our shape, scoped to a specific package name. */
export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): VulnerabilityAdvisory {
const cvssVector = raw.severity?.find((s) => (s.type ?? '').toUpperCase().startsWith('CVSS_V3'))?.score ?? null;
const cvss = cvssV3BaseScore(cvssVector);
const selected = advisorySeverityVector(raw);
const parsed = parseCvssV3(selected);
const cvss = parsed.score;
const cvssVector = reportVector(selected);

// Qualitative fallback severity (GHSA): top-level, then the matching affected entry.
const lowerName = packageName.toLowerCase();
Expand Down Expand Up @@ -238,43 +287,50 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab
for (const v of affected.versions ?? []) if (!affectedVersions.includes(v)) affectedVersions.push(v);
}

return {
id: raw.id ?? 'UNKNOWN',
aliases: Array.isArray(raw.aliases) ? raw.aliases : [],
summary: raw.summary ?? null,
severity,
cvss,
cvssVector,
fixedVersions,
published: raw.published ?? null,
withdrawn: raw.withdrawn ?? null,
references: (raw.references ?? []).map((r) => r.url).filter((u): u is string => Boolean(u)),
...(affectedRanges.length ? { affectedRanges } : {}),
...(affectedVersions.length ? { affectedVersions } : {}),
};
return withDiagnostic(
{
id: raw.id ?? 'UNKNOWN',
aliases: Array.isArray(raw.aliases) ? raw.aliases : [],
summary: raw.summary ?? null,
severity,
cvss,
cvssVector,
fixedVersions,
published: raw.published ?? null,
withdrawn: raw.withdrawn ?? null,
references: (raw.references ?? []).map((r) => r.url).filter((u): u is string => Boolean(u)),
...(affectedRanges.length ? { affectedRanges } : {}),
...(affectedVersions.length ? { affectedVersions } : {}),
},
parsed.diagnostic,
);
}

export function manifestAdvisoryToAdvisory(m: ManifestAdvisory): VulnerabilityAdvisory {
const cvss = typeof m.cvss === 'number' ? m.cvss : cvssV3BaseScore(m.cvssVector ?? null);
const parsed = parseCvssV3(m.cvssVector ?? null);
const cvss = typeof m.cvss === 'number' ? m.cvss : parsed.score;
const severity: VulnSeverity = m.severity
? normalizeSeverityLabel(m.severity)
: cvss != null
? severityFromCvss(cvss)
: 'unknown';
return {
id: m.id,
aliases: m.aliases ?? [],
summary: m.summary ?? null,
severity,
cvss,
cvssVector: m.cvssVector ?? null,
fixedVersions: (m.ranges ?? []).map((r) => r.fixed).filter((f): f is string => Boolean(f)),
published: m.published ?? null,
withdrawn: m.withdrawn ?? null,
references: m.references ?? [],
...(m.ranges?.length ? { affectedRanges: m.ranges } : {}),
...(m.versions?.length ? { affectedVersions: m.versions } : {}),
};
return withDiagnostic(
{
id: m.id,
aliases: m.aliases ?? [],
summary: m.summary ?? null,
severity,
cvss,
cvssVector: reportVector(m.cvssVector ?? null),
fixedVersions: (m.ranges ?? []).map((r) => r.fixed).filter((f): f is string => Boolean(f)),
published: m.published ?? null,
withdrawn: m.withdrawn ?? null,
references: m.references ?? [],
...(m.ranges?.length ? { affectedRanges: m.ranges } : {}),
...(m.versions?.length ? { affectedVersions: m.versions } : {}),
},
parsed.diagnostic,
);
}

// ── OSV HTTP (thin, best-effort) ─────────────────────────────────────────────
Expand Down Expand Up @@ -478,6 +534,9 @@ function scanFromManifest(targets: VulnTarget[], manifest?: PackageVersionManife
/** SARIF/text rule id for vulnerability findings. */
export const VULN_RULE_ID = 'vibgrate/vulnerability';

/** SARIF/text rule id when a CVSS or severity vector could not be parsed. */
export const CVSS_VECTOR_RULE_ID = 'vibgrate/cvss-vector';

function levelForSeverity(severity: VulnSeverity): Finding['level'] {
switch (severity) {
case 'critical':
Expand Down Expand Up @@ -517,6 +576,7 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult):
severity: adv.severity,
cvss: adv.cvss,
fixedVersions: adv.fixedVersions,
...(adv.cvssDiagnostic ? { cvssDiagnostic: adv.cvssDiagnostic } : {}),
...(adv.introduced
? {
introducedBy: adv.introduced.authorName,
Expand All @@ -527,6 +587,21 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult):
: {}),
},
});
if (adv.cvssDiagnostic) {
findings.push({
ruleId: CVSS_VECTOR_RULE_ID,
level: 'warning',
message: `${pkg.package}@${pkg.version}: ${idLabel}: ${adv.cvssDiagnostic}`,
location: pkg.package,
details: {
ecosystem: pkg.ecosystem,
package: pkg.package,
installedVersion: pkg.version,
advisoryId: adv.id,
cvssDiagnostic: adv.cvssDiagnostic,
},
});
}
}
}
return findings;
Expand Down
Loading
Loading