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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, + / −).
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/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,
Expand Down
149 changes: 149 additions & 0 deletions src/core-open/licenses/diagnose.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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([]);
});
});
88 changes: 88 additions & 0 deletions src/core-open/licenses/diagnose.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
1 change: 1 addition & 0 deletions src/core-open/licenses/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
46 changes: 32 additions & 14 deletions src/core-open/licenses/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/core-open/scanners/node-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -489,6 +492,7 @@ async function scanOnePackageJson(
runtimeEolDate,
frameworks,
dependencies,
...(declaredLicense ? { declaredLicense } : {}),
dependencyAgeBuckets: buckets,
libyears: aggregateLibyears(dependencies.map((d) => d.libyears)) ?? undefined,
fileCount,
Expand Down
2 changes: 1 addition & 1 deletion src/core-open/scanners/npm-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function parseReleaseDates(time: unknown): Record<string, string> | 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)) {
Expand Down
Loading
Loading