From ca6a7c66f850d5c76c4237c4f7645712542219fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 15:16:29 +0000 Subject: [PATCH] fix: report unparseable license ids instead of dropping them A declared license string that is not an SPDX id or expression now produces a stable vibgrate/license-unparseable warning on scan and SBOM export. Valid ids, aliases, and fuzzy family matches are unchanged. Fixes #233 Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 7 + src/core-open/formatters/sarif.ts | 5 + src/core-open/licenses/diagnose.test.ts | 149 ++++++++++++++++++++ src/core-open/licenses/diagnose.ts | 88 ++++++++++++ src/core-open/licenses/index.ts | 1 + src/core-open/licenses/normalize.ts | 46 ++++-- src/core-open/scanners/node-scanner.ts | 6 +- src/core-open/scanners/npm-cache.ts | 2 +- src/core-open/scoring/drift-score.ts | 20 +++ src/core-open/types.ts | 8 ++ src/reporting/commands/sbom.test.ts | 62 ++++++++ src/reporting/commands/sbom.ts | 179 +++++++++++++++++++----- src/reporting/types.ts | 14 ++ 13 files changed, 534 insertions(+), 53 deletions(-) create mode 100644 src/core-open/licenses/diagnose.test.ts create mode 100644 src/core-open/licenses/diagnose.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..9b2ee14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,13 @@ backward compatible. ### Fixed +- **An unparseable license id is reported instead of being dropped.** `vg scan` + (text, JSON, SARIF, and Markdown) and `vg sbom export` (CycloneDX and SPDX) + now emit a `vibgrate/license-unparseable` warning when a declared license + string is not an SPDX id or expression. The message names the failed string + and the manifest path, and stays on one short line. A valid SPDX id, alias, + or fuzzy family match is unchanged. + - **`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, + / −). diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index 9825a8d..762c0ec 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -170,6 +170,11 @@ function buildRules(findings: Finding[]) { shortDescription: { text: 'Known vulnerability in an installed dependency' }, helpUri: 'https://vibgrate.com/rules/vulnerability', }, + 'vibgrate/license-unparseable': { + id: 'vibgrate/license-unparseable', + shortDescription: { text: 'Declared license is not a recognized SPDX id or expression' }, + helpUri: 'https://vibgrate.com/rules/license-unparseable', + }, }; return descriptions[id] ?? { id, diff --git a/src/core-open/licenses/diagnose.test.ts b/src/core-open/licenses/diagnose.test.ts new file mode 100644 index 0000000..58f1452 --- /dev/null +++ b/src/core-open/licenses/diagnose.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { generateFindings } from '../scoring/drift-score.js'; +import type { ProjectScan } from '../types.js'; +import { LICENSE_UNPARSEABLE_CODE, diagnoseLicenseParse, displayLicenseText, manifestRelativePath } from './diagnose.js'; +import { normalizeLicense } from './normalize.js'; + +function nodeProject(overrides: Partial = {}): ProjectScan { + return { + type: 'node', + path: '.', + name: 'app', + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + ...overrides, + }; +} + +describe('diagnoseLicenseParse', () => { + it('names an unparseable license id and the manifest path', () => { + const diag = diagnoseLicenseParse('NotARealLicense', 'package.json', 'left-pad'); + expect(diag).not.toBeNull(); + expect(diag!.code).toBe(LICENSE_UNPARSEABLE_CODE); + expect(diag!.path).toBe('package.json'); + expect(diag!.raw).toBe('NotARealLicense'); + expect(diag!.message).toBe( + 'Unparseable license "NotARealLicense" for left-pad at package.json. Use an SPDX identifier or expression such as MIT or Apache-2.0.', + ); + }); + + it('names an unresolved id inside an otherwise valid expression', () => { + const diag = diagnoseLicenseParse('MIT OR NotARealLicense-9.9', 'apps/web/package.json', 'left-pad'); + expect(diag!.code).toBe(LICENSE_UNPARSEABLE_CODE); + expect(diag!.message).toContain('"NotARealLicense-9.9"'); + expect(diag!.message).toContain('apps/web/package.json'); + expect(diag!.message).toContain('left-pad'); + expect(diag!.message).not.toContain('\n'); + }); + + it('emits one diagnostic when several constituent ids fail', () => { + const diag = diagnoseLicenseParse('FooBar-1.0 AND BazQux-2.0', 'package.json'); + expect(diag!.message).toContain('"FooBar-1.0"'); + expect(diag!.message).toContain('"BazQux-2.0"'); + expect(diag!.code).toBe(LICENSE_UNPARSEABLE_CODE); + }); + + it('repeats the same code and text for the same string', () => { + const a = diagnoseLicenseParse('NotARealLicense', 'package.json'); + const b = diagnoseLicenseParse('NotARealLicense', 'package.json'); + expect(a).toEqual(b); + }); + + it('does not dump a license file body or a credential', () => { + const fileBody = `NotARealLicense\n${'unrelated license file text. '.repeat(40)}UNIQUE_FILE_TAIL`; + const fromFile = diagnoseLicenseParse(fileBody, 'package.json'); + expect(fromFile!.raw).toBe('NotARealLicense'); + expect(fromFile!.message).not.toContain('UNIQUE_FILE_TAIL'); + expect(fromFile!.message).not.toContain('unrelated license file'); + + const blob = `BadLicenseToken ${'xxxx '.repeat(80)}UNIQUE_FILE_TAIL`; + const fromBlob = diagnoseLicenseParse(blob, 'package.json'); + expect(fromBlob!.raw).toBe(displayLicenseText(blob)); + expect(fromBlob!.raw.endsWith('...')).toBe(true); + expect(fromBlob!.raw.length).toBeLessThanOrEqual(80); + expect(fromBlob!.message).not.toContain('UNIQUE_FILE_TAIL'); + + const token = 'npm_1234567890abcdefghij'; + const leaked = diagnoseLicenseParse(`NotARealLicense ${token}`, 'package.json'); + expect(leaked!.message).not.toContain(token); + expect(leaked!.message).toContain('[REDACTED]'); + expect(leaked!.raw).not.toContain(token); + }); + + it('points node projects at package.json and keeps other project paths', () => { + expect(manifestRelativePath('.', 'node')).toBe('package.json'); + expect(manifestRelativePath('apps/web', 'node')).toBe('apps/web/package.json'); + expect(manifestRelativePath('services/api', 'go')).toBe('services/api'); + }); + + it('still parses a valid SPDX id, alias, and expression', () => { + expect(diagnoseLicenseParse('MIT', 'package.json')).toBeNull(); + expect(diagnoseLicenseParse('Apache-2.0', 'package.json')).toBeNull(); + expect(diagnoseLicenseParse('MIT License', 'package.json')).toBeNull(); + expect(diagnoseLicenseParse('MIT OR Apache-2.0', 'package.json')).toBeNull(); + expect(diagnoseLicenseParse('NOASSERTION', 'package.json')).toBeNull(); + expect(diagnoseLicenseParse('NONE', 'package.json')).toBeNull(); + expect(diagnoseLicenseParse(' ', 'package.json')).toBeNull(); + expect(diagnoseLicenseParse(null, 'package.json')).toBeNull(); + + expect(normalizeLicense('MIT')).toMatchObject({ + spdxId: 'MIT', + matchStatus: 'exact', + confidence: 1, + expression: 'MIT', + components: ['MIT'], + }); + expect(normalizeLicense('MIT OR Apache-2.0')).toMatchObject({ + spdxId: 'Apache-2.0', + matchStatus: 'expression', + expression: 'MIT OR Apache-2.0', + components: ['MIT', 'Apache-2.0'], + }); + }); + + it('does not treat a fuzzy family match as a parse failure', () => { + expect(normalizeLicense('custom mit license text').matchStatus).toBe('fuzzy'); + expect(diagnoseLicenseParse('custom mit license text', 'package.json')).toBeNull(); + }); +}); + +describe('scan findings for license ids', () => { + it('warns on a bad license string and stays quiet for a valid one', () => { + const findings = generateFindings([ + nodeProject({ + declaredLicense: { raw: 'NotARealLicense', spdxId: null, source: 'manifest', confidence: 0 }, + dependencies: [ + { + package: 'chalk', + section: 'dependencies', + currentSpec: '5.0.0', + resolvedVersion: '5.0.0', + latestStable: '5.0.0', + majorsBehind: 0, + drift: 'current', + license: { raw: 'MIT', spdxId: 'MIT', source: 'registry', confidence: 1 }, + }, + { + package: 'left-pad', + section: 'dependencies', + currentSpec: '1.0.0', + resolvedVersion: '1.0.0', + latestStable: '1.0.0', + majorsBehind: 0, + drift: 'current', + license: { raw: 'MIT OR NotARealLicense-9.9', spdxId: 'MIT', source: 'registry', confidence: 0.5 }, + }, + ], + }), + ]); + const licenseFindings = findings.filter((f) => f.ruleId === LICENSE_UNPARSEABLE_CODE); + expect(licenseFindings.map((f) => f.message)).toEqual([ + 'Unparseable license "NotARealLicense" for app at package.json. Use an SPDX identifier or expression such as MIT or Apache-2.0.', + 'Unparseable license id "NotARealLicense-9.9" in "MIT OR NotARealLicense-9.9" for left-pad at package.json. Use an SPDX identifier or expression such as MIT or Apache-2.0.', + ]); + expect(licenseFindings.every((f) => f.location === 'package.json' && f.level === 'warning')).toBe(true); + expect(findings.some((f) => f.message.includes('chalk'))).toBe(false); + expect(generateFindings([nodeProject()])).toEqual([]); + }); +}); diff --git a/src/core-open/licenses/diagnose.ts b/src/core-open/licenses/diagnose.ts new file mode 100644 index 0000000..95e57d6 --- /dev/null +++ b/src/core-open/licenses/diagnose.ts @@ -0,0 +1,88 @@ +/** + * User-facing diagnostic when a declared license string cannot be parsed as an + * SPDX id or expression. + * + * A recognized id, alias, or fuzzy family match stays quiet — fuzzy is a + * successful family guess, not a parse failure. Explicit no-assertion tokens + * (`NOASSERTION`, `NONE`, `unknown`, `n/a`) are recognized and stay quiet too. + * + * The message names the failed string and the manifest path. It is one line, + * length-capped, and scrubbed of credential-shaped text. It never includes a + * file body. + */ +import { redactSecrets } from '../utils/redact.js'; +import { normalizeLicense, unresolvedConstituentIds } from './normalize.js'; + +/** Stable code for an unparseable license id. Same text on every run. */ +export const LICENSE_UNPARSEABLE_CODE = 'vibgrate/license-unparseable'; + +/** How much of the declared string a message may repeat. */ +const DISPLAY_LIMIT = 80; + +const RECOGNIZED_UNKNOWN = /^(unknown|noassertion|none|n\/a)$/i; + +export interface LicenseParseDiagnostic { + code: typeof LICENSE_UNPARSEABLE_CODE; + /** Truncated, secret-redacted declared string. */ + raw: string; + /** Repo-relative manifest path supplied by the caller. */ + path: string; + message: string; +} + +/** + * Repo-relative path of the manifest that declared the license. + * Node projects point at `package.json`; other ecosystems keep the project + * directory, which is the evidence path the scanner recorded. + */ +export function manifestRelativePath(projectPath: string, projectType: string): string { + const dir = (projectPath || '.').replace(/\\/g, '/').replace(/\/+$/, '') || '.'; + if (projectType !== 'node' && projectType !== 'typescript') return dir; + return dir === '.' ? 'package.json' : `${dir}/package.json`; +} + +/** First line only, credentials removed, capped so a file body cannot spill out. */ +export function displayLicenseText(raw: string): string { + const firstLine = raw.split(/\r?\n/, 1)[0] ?? ''; + const single = redactSecrets(firstLine).replace(/[ \t]+/g, ' ').trim(); + if (single.length <= DISPLAY_LIMIT) return single; + return `${single.slice(0, DISPLAY_LIMIT - 3)}...`; +} + +/** + * One diagnostic when `raw` is non-empty and does not resolve, or when a + * constituent id inside an expression does not resolve. `null` for empty + * input, recognized no-assertion tokens, exact ids, aliases, and fuzzy matches. + */ +export function diagnoseLicenseParse( + raw: string | null | undefined, + manifestPath: string, + subject?: string, +): LicenseParseDiagnostic | null { + const input = (raw ?? '').trim(); + if (!input || RECOGNIZED_UNKNOWN.test(input)) return null; + + const verdict = normalizeLicense(input); + if (verdict.matchStatus === 'fuzzy') return null; + + const unresolved = unresolvedConstituentIds(input); + if (verdict.matchStatus !== 'unknown' && unresolved.length === 0) return null; + + const shown = displayLicenseText(input); + const path = manifestPath.trim() || '.'; + const who = subject ? ` for ${displayLicenseText(subject)}` : ''; + const next = 'Use an SPDX identifier or expression such as MIT or Apache-2.0.'; + const message = + unresolved.length > 0 + ? `Unparseable license ${unresolved.length === 1 ? 'id' : 'ids'} ${unresolved + .map((id) => `"${displayLicenseText(id)}"`) + .join(', ')} in "${shown}"${who} at ${path}. ${next}` + : `Unparseable license "${shown}"${who} at ${path}. ${next}`; + + return { + code: LICENSE_UNPARSEABLE_CODE, + raw: shown, + path, + message, + }; +} diff --git a/src/core-open/licenses/index.ts b/src/core-open/licenses/index.ts index 2704e7b..20219ec 100644 --- a/src/core-open/licenses/index.ts +++ b/src/core-open/licenses/index.ts @@ -12,3 +12,4 @@ export * from './spdx-catalog.js'; export * from './spdx-aliases.js'; export * from './spdx-expression.js'; export * from './normalize.js'; +export * from './diagnose.js'; diff --git a/src/core-open/licenses/normalize.ts b/src/core-open/licenses/normalize.ts index 82ab86c..ae68847 100644 --- a/src/core-open/licenses/normalize.ts +++ b/src/core-open/licenses/normalize.ts @@ -142,26 +142,44 @@ export function normalizeLicense(raw: string | null | undefined): LicenseVerdict return verdictFromRecord(unknownLicenseRecord(input.slice(0, 120)), 'unknown', 0); } +/** Resolve one license id (exact, alias, then fuzzy). Unknown when none match. */ +function resolveSingle(id: string): LicenseVerdict { + const exact = getLicenseRecord(id); + if (exact) return verdictFromRecord(exact, 'exact', 1); + const aliasId = resolveAlias(id); + if (aliasId) { + const rec = getLicenseRecord(aliasId); + if (rec) return verdictFromRecord(rec, 'alias', 0.95); + } + const fuzzy = fuzzyMatch(id); + if (fuzzy) return verdictFromRecord(fuzzy, 'fuzzy', 0.6); + return verdictFromRecord(unknownLicenseRecord(id), 'unknown', 0); +} + +/** + * Constituent ids inside a compound expression that did not resolve. + * Empty for a single id, and empty when every constituent is exact, an alias, + * or a fuzzy family match. Order follows the expression. + */ +export function unresolvedConstituentIds(raw: string | null | undefined): string[] { + const input = (raw ?? '').trim(); + if (!input || !isCompoundExpression(input)) return []; + const parsed = parseLicenseExpression(input); + const ids: string[] = []; + for (const id of parsed.licenseIds) { + if (resolveSingle(id).matchStatus === 'unknown' && !ids.includes(id)) ids.push(id); + } + return ids; +} + function resolveExpression(input: string): LicenseVerdict { const parsed = parseLicenseExpression(input); if (parsed.licenseIds.length === 0) { return verdictFromRecord(unknownLicenseRecord(input.slice(0, 120)), 'unknown', 0); } - // Resolve each constituent id to a verdict (via recursion through the - // single-id path: exact → alias → fuzzy). - const componentVerdicts = parsed.licenseIds.map((id) => { - const exact = getLicenseRecord(id); - if (exact) return verdictFromRecord(exact, 'exact', 1); - const aliasId = resolveAlias(id); - if (aliasId) { - const rec = getLicenseRecord(aliasId); - if (rec) return verdictFromRecord(rec, 'alias', 0.95); - } - const fuzzy = fuzzyMatch(id); - if (fuzzy) return verdictFromRecord(fuzzy, 'fuzzy', 0.6); - return verdictFromRecord(unknownLicenseRecord(id), 'unknown', 0); - }); + // Resolve each constituent id: exact → alias → fuzzy → unknown. + const componentVerdicts = parsed.licenseIds.map((id) => resolveSingle(id)); // For OR the consumer may pick the least-restrictive; for AND all apply, so // the most-restrictive governs. diff --git a/src/core-open/scanners/node-scanner.ts b/src/core-open/scanners/node-scanner.ts index 1f19fc4..d8640b0 100644 --- a/src/core-open/scanners/node-scanner.ts +++ b/src/core-open/scanners/node-scanner.ts @@ -8,7 +8,7 @@ import { findPackageJsonFiles, readJsonFile, readTextFile, pathExists, FileCache import { loadNpmLockIndex, type NpmLockIndex, type LockfileIo } from './npm-lockfile.js'; import { Semaphore } from '../utils/semaphore.js'; import { withTimeout } from '../utils/timeout.js'; -import { NpmCache, isSemverSpec } from './npm-cache.js'; +import { NpmCache, isSemverSpec, parseLicenseField } from './npm-cache.js'; import { buildDependencyLicense } from '../licenses/dependency-license.js'; import { ageDaysBetween, daysToLibyears, aggregateLibyears } from '../scoring/libyear.js'; import { latestLts, runtimeEolStatus, extractCycle, eolDate } from '../runtimes/catalog.js'; @@ -478,6 +478,9 @@ async function scanOnePackageJson( // Ignore file count errors } + const declaredRaw = parseLicenseField(pj.license ?? pj.licenses); + const declaredLicense = declaredRaw ? buildDependencyLicense(declaredRaw, 'manifest') : undefined; + return { type: 'node', path: projectPath, @@ -489,6 +492,7 @@ async function scanOnePackageJson( runtimeEolDate, frameworks, dependencies, + ...(declaredLicense ? { declaredLicense } : {}), dependencyAgeBuckets: buckets, libyears: aggregateLibyears(dependencies.map((d) => d.libyears)) ?? undefined, fileCount, diff --git a/src/core-open/scanners/npm-cache.ts b/src/core-open/scanners/npm-cache.ts index eb3e6b2..358ceb0 100644 --- a/src/core-open/scanners/npm-cache.ts +++ b/src/core-open/scanners/npm-cache.ts @@ -47,7 +47,7 @@ function parseReleaseDates(time: unknown): Record | undefined { * ({ type }) or an array of such objects under `licenses`. Reduce any of these * to a single declared string (an SPDX expression for the array form). */ -function parseLicenseField(value: unknown): string | null { +export function parseLicenseField(value: unknown): string | null { if (!value) return null; if (typeof value === 'string') return value.trim() || null; if (Array.isArray(value)) { diff --git a/src/core-open/scoring/drift-score.ts b/src/core-open/scoring/drift-score.ts index 9ee6bff..b6d54f8 100644 --- a/src/core-open/scoring/drift-score.ts +++ b/src/core-open/scoring/drift-score.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as crypto from 'node:crypto'; import type { ProjectScan, DriftScore, Finding, RiskLevel, VibgrateConfig } from '../types.js'; +import { diagnoseLicenseParse, manifestRelativePath } from '../licenses/diagnose.js'; import { aggregateDependencyDrift } from './dependency-drift-v3.js'; /** @@ -283,6 +284,16 @@ export function computeDriftScore(projects: ProjectScan[]): DriftScore { // ── Findings generation ── +function licenseFinding(diag: { code: string; message: string; path: string; raw: string }): Finding { + return { + ruleId: diag.code, + level: 'warning', + message: diag.message, + location: diag.path, + details: { raw: diag.raw }, + }; +} + export function generateFindings( projects: ProjectScan[], config?: VibgrateConfig, @@ -381,6 +392,15 @@ export function generateFindings( }); } } + + // Unparseable SPDX license ids. Fuzzy matches are not parse failures. + const manifestPath = manifestRelativePath(project.path, project.type); + const declared = diagnoseLicenseParse(project.declaredLicense?.raw, manifestPath, project.name); + if (declared) findings.push(licenseFinding(declared)); + for (const dep of project.dependencies) { + const diag = diagnoseLicenseParse(dep.license?.raw, manifestPath, dep.package); + if (diag) findings.push(licenseFinding(diag)); + } } return findings; diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..8729e3a 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -81,6 +81,9 @@ export interface PackageJson { devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; + /** Declared license: SPDX id/expression, legacy `{ type }`, or a `licenses` array. */ + license?: unknown; + licenses?: unknown; } // ── npm registry metadata ── @@ -211,6 +214,11 @@ export interface ProjectScan { packageManager?: string; frameworks: DetectedFramework[]; dependencies: DependencyRow[]; + /** + * License declared by this project's own manifest (`license` / `licenses`). + * Absent when the manifest does not declare one. + */ + declaredLicense?: DependencyLicense; dependencyAgeBuckets: { current: number; oneBehind: number; diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 4625967..6656138 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -262,6 +262,68 @@ describe('sbom helpers', () => { }); }); + it('keeps a valid SPDX id on the component', () => { + const artifact = makeArtifact('5.3.0', 90); + artifact.projects[0]!.dependencies[0]!.license = { raw: 'MIT', spdxId: 'MIT', source: 'registry', confidence: 1 }; + artifact.projects[0]!.declaredLicense = { raw: 'Apache-2.0', spdxId: 'Apache-2.0' }; + const cyclone = toCycloneDx(artifact) as { + metadata: { component: { licenses?: Array<{ license: { id?: string } }>; properties?: unknown } }; + components: Array<{ licenses?: Array<{ license: { id?: string; name?: string } }>; properties: Array<{ name: string }> }>; + }; + expect(cyclone.metadata.component.licenses).toEqual([{ license: { id: 'Apache-2.0' } }]); + expect(cyclone.metadata.component.properties).toBeUndefined(); + expect(cyclone.components[0]!.licenses).toEqual([{ license: { id: 'MIT' } }]); + expect(cyclone.components[0]!.properties.some((p) => p.name === 'vibgrate:license-diagnostic')).toBe(false); + + const spdx = toSpdx(artifact) as { + packages: Array<{ licenseConcluded?: string; licenseDeclared?: string }>; + annotations?: unknown; + }; + expect(spdx.packages[0]!.licenseConcluded).toBe('MIT'); + expect(spdx.packages[0]!.licenseDeclared).toBeUndefined(); + expect(spdx.annotations).toBeUndefined(); + }); + + it('reports an unparseable license id instead of dropping it', () => { + const artifact = makeArtifact('1.0.0', 90); + const tail = 'UNIQUE_FILE_TAIL'; + artifact.projects[0]!.dependencies[0]!.package = 'left-pad'; + artifact.projects[0]!.dependencies[0]!.license = { + raw: `NotARealLicense\n${'unrelated license file text. '.repeat(20)}${tail}`, + spdxId: null, + source: 'registry', + confidence: 0, + }; + const cyclone = toCycloneDx(artifact) as { + components: Array<{ + name: string; + licenses?: Array<{ license: { name?: string; id?: string } }>; + properties: Array<{ name: string; value: string }>; + }>; + }; + const component = cyclone.components[0]!; + expect(component.licenses).toEqual([{ license: { name: 'NotARealLicense' } }]); + const diagnostic = component.properties.find((p) => p.name === 'vibgrate:license-diagnostic'); + expect(diagnostic?.value).toContain('vibgrate/license-unparseable'); + expect(diagnostic?.value).toContain('NotARealLicense'); + expect(diagnostic?.value).toContain('package.json'); + expect(diagnostic?.value).not.toContain(tail); + expect(diagnostic?.value).not.toContain('unrelated license file'); + + const spdx = toSpdx(artifact) as { + packages: Array<{ + licenseConcluded?: string; + licenseDeclared?: string; + annotations: Array<{ comment: string }>; + }>; + }; + expect(spdx.packages[0]!.licenseConcluded).toBe('NOASSERTION'); + expect(spdx.packages[0]!.licenseDeclared).toBe('NotARealLicense'); + const note = spdx.packages[0]!.annotations.map((a) => a.comment).join('\n'); + expect(note).toContain('vibgrate/license-unparseable'); + expect(note).not.toContain(tail); + }); + it('formats dependency deltas', () => { const base = makeArtifact('5.2.0', 80); const current = makeArtifact('5.3.0', 76); diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index c560d2f..da5aee3 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -5,6 +5,7 @@ import { pathExists, readJsonFile, writeTextFile } from '../utils/fs.js'; import type { DependencyRow, ProjectScan, ScanArtifact } from '../types.js'; import { fullDependencyGraph, type LockfileComponent, type LockfileGraph } from '../../engine/lockfile.js'; import type { Ecosystem } from '../../engine/drift.js'; +import { diagnoseLicenseParse, manifestRelativePath } from '../../core-open/licenses/diagnose.js'; import { vexCommand } from './vex.js'; type SbomFormat = 'cyclonedx' | 'spdx'; @@ -20,6 +21,62 @@ interface FlattenedDependency { scope: 'direct' | 'transitive'; /** Which package registry this dependency resolves against — picks the purl scheme. */ ecosystem: Ecosystem; + /** Repo-relative manifest that declared this dependency. */ + manifestPath: string; + /** Declared license string, when the scan captured one. */ + licenseRaw: string | null; + /** Resolved SPDX id, when parsing succeeded. Null when the string did not resolve. */ + licenseSpdx: string | null; +} + +const LICENSE_DIAGNOSTIC_PROPERTY = 'vibgrate:license-diagnostic'; + +interface SbomLicense { + cycloneDx?: Array<{ license: { id: string } | { name: string } }>; + spdx?: { licenseConcluded: string; licenseDeclared?: string }; + /** Stable diagnostic, set only when the declared string did not parse. */ + diagnostic?: string; +} + +/** + * License fields for one component. A resolved SPDX id is emitted as an id. + * An unparseable string is kept (truncated) and paired with one diagnostic — + * it is not omitted. + */ +function sbomLicense( + raw: string | null | undefined, + spdxId: string | null | undefined, + manifestPath: string, + subject?: string, +): SbomLicense { + const declared = (raw ?? '').trim(); + if (!declared && !spdxId) return {}; + const diag = declared ? diagnoseLicenseParse(declared, manifestPath, subject) : null; + if (diag) { + return { + cycloneDx: [{ license: { name: diag.raw } }], + spdx: { licenseConcluded: 'NOASSERTION', licenseDeclared: diag.raw }, + diagnostic: `${diag.code}: ${diag.message}`, + }; + } + if (spdxId) { + return { + cycloneDx: [{ license: { id: spdxId } }], + spdx: { licenseConcluded: spdxId }, + }; + } + return {}; +} + +function rootProject(artifact: ScanArtifact): ProjectScan | undefined { + return artifact.projects.find((p) => p.path === '.' || p.path === ''); +} + +function rootSbomLicense(artifact: ScanArtifact): SbomLicense { + const project = rootProject(artifact); + const declared = project?.declaredLicense; + if (!project || !declared?.raw) return {}; + return sbomLicense(declared.raw, declared.spdxId, manifestRelativePath(project.path, project.type), project.name); } /** `ProjectScan.type` → the purl-scheme ecosystem for its dependencies. */ @@ -193,7 +250,11 @@ function sbomSerialSeed(format: string, artifact: ScanArtifact, deps: FlattenedD artifact.rootPath ?? '', artifact.timestamp ?? '', artifact.vibgrateVersion ?? '', - ...deps.map((d) => `${d.package}|${d.version}|${d.currentSpec}|${d.project}|${d.drift}|${d.majorsBehind ?? ''}|${d.scope}`), + ...deps.map((d) => `${d.package}|${d.version}|${d.currentSpec}|${d.project}|${d.drift}|${d.majorsBehind ?? ''}|${d.scope}|${d.licenseSpdx ?? ''}|${d.licenseRaw ?? ''}`), + ...(() => { + const root = rootSbomLicense(artifact); + return root.diagnostic || root.spdx ? [`root-license|${root.spdx?.licenseConcluded ?? ''}|${root.spdx?.licenseDeclared ?? ''}|${root.diagnostic ?? ''}`] : []; + })(), ...(graph?.rootDependsOn.length ? [`root>${uniqSorted(graph.rootDependsOn).join(',')}`] : []), ...edgeLines, ].join('\n'); @@ -298,6 +359,9 @@ export function flattenDependencies( majorsBehind: dep.majorsBehind, scope: 'direct', ecosystem, + manifestPath: manifestRelativePath(project.path, project.type), + licenseRaw: dep.license?.raw ?? null, + licenseSpdx: dep.license?.spdxId ?? null, }); } } @@ -314,6 +378,9 @@ export function flattenDependencies( majorsBehind: null, scope: 'transitive', ecosystem: lockfileEcosystem ?? 'npm', + manifestPath: artifact.rootPath || '.', + licenseRaw: null, + licenseSpdx: null, }); } return rows; @@ -349,6 +416,7 @@ export function collectLockfileGraph(artifact: ScanArtifact, root: string): Lock export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Record { const dependencies = flattenDependencies(artifact, graph?.components ?? [], graph?.ecosystem); const dependencyGraph = cycloneDxDependencyGraph(dependencies, graph); + const rootLicense = rootSbomLicense(artifact); return { bomFormat: 'CycloneDX', specVersion: '1.5', @@ -367,22 +435,31 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco type: 'application', 'bom-ref': ROOT_BOM_REF, name: artifact.rootPath, + ...(rootLicense.cycloneDx ? { licenses: rootLicense.cycloneDx } : {}), + ...(rootLicense.diagnostic + ? { properties: [{ name: LICENSE_DIAGNOSTIC_PROPERTY, value: rootLicense.diagnostic }] } + : {}), }, }, - components: dependencies.map((dep) => ({ - type: 'library', - 'bom-ref': purlFor(dep.ecosystem, dep.package, dep.version), - name: dep.package, - version: dep.version, - purl: purlFor(dep.ecosystem, dep.package, dep.version), - properties: [ - { name: 'vibgrate:project', value: dep.project }, - { name: 'vibgrate:currentSpec', value: dep.currentSpec }, - { name: 'vibgrate:drift', value: dep.drift }, - { name: 'vibgrate:majorsBehind', value: String(dep.majorsBehind ?? 'unknown') }, - { name: 'vibgrate:scope', value: dep.scope }, - ], - })), + components: dependencies.map((dep) => { + const license = sbomLicense(dep.licenseRaw, dep.licenseSpdx, dep.manifestPath, dep.package); + return { + type: 'library', + 'bom-ref': purlFor(dep.ecosystem, dep.package, dep.version), + name: dep.package, + version: dep.version, + purl: purlFor(dep.ecosystem, dep.package, dep.version), + ...(license.cycloneDx ? { licenses: license.cycloneDx } : {}), + properties: [ + { name: 'vibgrate:project', value: dep.project }, + { name: 'vibgrate:currentSpec', value: dep.currentSpec }, + { name: 'vibgrate:drift', value: dep.drift }, + { name: 'vibgrate:majorsBehind', value: String(dep.majorsBehind ?? 'unknown') }, + { name: 'vibgrate:scope', value: dep.scope }, + ...(license.diagnostic ? [{ name: LICENSE_DIAGNOSTIC_PROPERTY, value: license.diagnostic }] : []), + ], + }; + }), ...(dependencyGraph ? { dependencies: dependencyGraph } : {}), }; } @@ -390,6 +467,7 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record { const dependencies = flattenDependencies(artifact, graph?.components ?? [], graph?.ecosystem); const relationships = spdxRelationships(dependencies, graph); + const rootLicense = rootSbomLicense(artifact); return { spdxVersion: 'SPDX-2.3', dataLicense: 'CC0-1.0', @@ -400,28 +478,55 @@ export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record ({ - name: dep.package, - SPDXID: `SPDXRef-Package-${i + 1}`, - versionInfo: dep.version, - downloadLocation: 'NOASSERTION', - filesAnalyzed: false, - externalRefs: [ - { - referenceCategory: 'PACKAGE-MANAGER', - referenceType: 'purl', - referenceLocator: purlFor(dep.ecosystem, dep.package, dep.version), - }, - ], - annotations: [ - { - annotationType: 'OTHER', - annotator: 'Tool: @vibgrate/cli', - annotationDate: artifact.timestamp, - comment: `project=${dep.project}; drift=${dep.drift}; majorsBehind=${dep.majorsBehind ?? 'unknown'}; scope=${dep.scope}`, - }, - ], - })), + ...(rootLicense.diagnostic + ? { + annotations: [ + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: rootLicense.diagnostic, + }, + ], + } + : {}), + packages: dependencies.map((dep, i) => { + const license = sbomLicense(dep.licenseRaw, dep.licenseSpdx, dep.manifestPath, dep.package); + return { + name: dep.package, + SPDXID: `SPDXRef-Package-${i + 1}`, + versionInfo: dep.version, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + ...(license.spdx ? { licenseConcluded: license.spdx.licenseConcluded } : {}), + ...(license.spdx?.licenseDeclared ? { licenseDeclared: license.spdx.licenseDeclared } : {}), + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: purlFor(dep.ecosystem, dep.package, dep.version), + }, + ], + annotations: [ + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: `project=${dep.project}; drift=${dep.drift}; majorsBehind=${dep.majorsBehind ?? 'unknown'}; scope=${dep.scope}`, + }, + ...(license.diagnostic + ? [ + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: license.diagnostic, + }, + ] + : []), + ], + }; + }), ...(relationships ? { relationships } : {}), }; } diff --git a/src/reporting/types.ts b/src/reporting/types.ts index 0ac11bf..9de7a28 100644 --- a/src/reporting/types.ts +++ b/src/reporting/types.ts @@ -56,6 +56,8 @@ export interface PackageJson { devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; + license?: unknown; + licenses?: unknown; } // ── npm registry metadata ── @@ -76,6 +78,13 @@ export interface DependencyRow { latestStable: string | null; majorsBehind: number | null; drift: 'current' | 'minor-behind' | 'major-behind' | 'unknown'; + /** Declared license evidence, when the scanner captured it. */ + license?: { + raw: string | null; + spdxId: string | null; + source?: 'manifest' | 'registry' | 'license-file' | 'none'; + confidence?: number; + }; } // ── Detected framework ── @@ -118,6 +127,11 @@ export interface ProjectScan { packageManager?: string; frameworks: DetectedFramework[]; dependencies: DependencyRow[]; + /** License declared by this project's own manifest, when present. */ + declaredLicense?: { + raw: string | null; + spdxId: string | null; + }; dependencyAgeBuckets: { current: number; oneBehind: number;