From 48bc6c0a1276c63ec528edd8dac57db6c0fd8948 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 22:06:46 +0000 Subject: [PATCH] fix: include source path on unparseable license findings When a local license declaration cannot be parsed as SPDX, vg scan now emits vibgrate/license-unparseable with the repo-relative path in JSON and SARIF instead of dropping the file. Registry licenses without a local file stay on the dependency row. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- DOCS.md | 2 + src/core-open/formatters/sarif.ts | 5 + src/core-open/licenses/dependency-license.ts | 6 + src/core-open/licenses/evidence.test.ts | 247 +++++++++++++++ src/core-open/licenses/evidence.ts | 316 +++++++++++++++++++ src/core-open/run-core-scan.ts | 6 +- src/core-open/types.ts | 7 + 7 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 src/core-open/licenses/evidence.test.ts create mode 100644 src/core-open/licenses/evidence.ts diff --git a/DOCS.md b/DOCS.md index 6ad775e..8a62504 100644 --- a/DOCS.md +++ b/DOCS.md @@ -2858,6 +2858,8 @@ The full scan artifact in JSON format. Contains all raw data, scores, findings, [Static Analysis Results Interchange Format](https://sarifweb.azurewebsites.net/) — compatible with GitHub Code Scanning and Azure DevOps. Contains findings only (not all metrics). Ideal for integrating drift findings directly into your PR review workflow. +License declarations that do not parse as SPDX are findings when the scan has a file to name. For each project directory, `vg scan` reads a `package.json` `license` / `licenses` field when that file is present, and the fixed names `LICENSE`, `LICENCE`, `COPYING`, and `NOTICE` (plus `.md` / `.txt`) in the same directory. A declaration that does not resolve to a known SPDX id is reported as `vibgrate/license-unparseable`. The JSON finding sets `location` and `details.path` to the repo-relative path (`LICENSE`, `packages/foo/package.json`); SARIF uses that same path as `physicalLocation.artifactLocation.uri` and `properties.path`. The license body is not copied into the artifact. Registry license strings have no local file, so they stay on the dependency row and do not get a path. Findings are sorted by path. + ### Markdown A clean Markdown report suitable for PRs, wikis, or documentation. diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index 9825a8d..0f09ae2 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: 'License text could not be parsed as SPDX' }, + helpUri: 'https://vibgrate.com/rules/license-unparseable', + }, }; return descriptions[id] ?? { id, diff --git a/src/core-open/licenses/dependency-license.ts b/src/core-open/licenses/dependency-license.ts index cc73407..2008428 100644 --- a/src/core-open/licenses/dependency-license.ts +++ b/src/core-open/licenses/dependency-license.ts @@ -8,6 +8,12 @@ * The scanner records the raw declared string plus a best-effort canonical * SPDX id; full classification (category / obligations / risk) and the growing * library lookup happen during API enrichment. + * + * Registry metadata has no local evidence file, so an unidentified string is + * kept here (`spdxId: null`) and is not emitted as a scan finding — there is + * no path to attach. Local manifest, LICENSE, and NOTICE declarations are + * handled by `evidence.ts`, which reports `vibgrate/license-unparseable` with + * the repo-relative path instead of dropping them. */ import type { DependencyLicense } from '../types.js'; import { normalizeLicense } from './normalize.js'; diff --git a/src/core-open/licenses/evidence.test.ts b/src/core-open/licenses/evidence.test.ts new file mode 100644 index 0000000..e2ba3a4 --- /dev/null +++ b/src/core-open/licenses/evidence.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { formatSarif } from '../formatters/sarif.js'; +import { runCoreScan } from '../run-core-scan.js'; +import type { ScanArtifact } from '../types.js'; +import { + LICENSE_UNPARSEABLE_RULE_ID, + collectLicenseFindings, + type LicenseEvidenceSource, +} from './evidence.js'; + +const SECRET = 'DO-NOT-LEAK-TOKEN'; + +function findingPaths(findings: Array<{ location: string; details?: Record }>): string[] { + return findings.map((finding) => finding.location); +} + +describe('collectLicenseFindings', () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + roots.length = 0; + }); + + function makeRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-license-evidence-')); + roots.push(root); + return root; + } + + function write(root: string, rel: string, body: string): void { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + } + + it('reports an unparseable license file at a stable repo-relative path', async () => { + const root = makeRoot(); + write(root, 'package.json', JSON.stringify({ name: 'fixture', license: 'Apache-2.0' })); + write( + root, + 'LICENSE', + ['Example Internal Terms', SECRET, 'Use requires a separate agreement.'].join('\n'), + ); + + const first = await collectLicenseFindings(root, [{ path: '.' }]); + const second = await collectLicenseFindings(root, [{ path: '.' }, { path: '.' }]); + + expect(findingPaths(first)).toEqual(['LICENSE']); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(JSON.stringify(first)).not.toContain(SECRET); + expect(first[0]).toEqual({ + ruleId: LICENSE_UNPARSEABLE_RULE_ID, + level: 'warning', + message: 'Unparseable license text at LICENSE.', + location: 'LICENSE', + details: { path: 'LICENSE', source: 'license-file' satisfies LicenseEvidenceSource }, + }); + expect(path.isAbsolute(first[0]!.location)).toBe(false); + expect(first[0]!.location.includes(root)).toBe(false); + }); + + it('points an unparseable package.json license field at the manifest', async () => { + const root = makeRoot(); + write(root, 'package.json', JSON.stringify({ + name: 'fixture', + license: { url: `https://example.invalid/${SECRET}` }, + })); + + const findings = await collectLicenseFindings(root, [{ path: '.' }]); + expect(findingPaths(findings)).toEqual(['package.json']); + expect(findings[0]?.details).toEqual({ path: 'package.json', source: 'manifest' }); + expect(JSON.stringify(findings)).not.toContain(SECRET); + expect(JSON.stringify(findings)).not.toContain('example.invalid'); + }); + + it('keeps a failed SPDX tag from falling through to a later title', async () => { + const root = makeRoot(); + write(root, 'package.json', JSON.stringify({ name: 'fixture' })); + write(root, 'LICENSE', ['SPDX-License-Identifier: NotAReal-License-9.9', 'MIT License', SECRET].join('\n')); + + const findings = await collectLicenseFindings(root, [{ path: '.' }]); + expect(findingPaths(findings)).toEqual(['LICENSE']); + expect(JSON.stringify(findings)).not.toContain(SECRET); + expect(JSON.stringify(findings)).not.toContain('NotAReal'); + }); + + it('does not flag standard license titles or an identified manifest', async () => { + const root = makeRoot(); + write(root, 'package.json', JSON.stringify({ name: 'fixture', license: 'Apache-2.0' })); + write(root, 'LICENSE', 'Apache License\nVersion 2.0, January 2004\n'); + write(root, 'COPYING', 'GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n'); + write(root, 'NOTICE', 'Licensed under the Apache License, Version 2.0 (the "License").\n'); + + const findings = await collectLicenseFindings(root, [{ path: '.' }]); + expect(findings).toEqual([]); + }); + + it('sorts paths and scans a nested project directory once', async () => { + const root = makeRoot(); + write(root, 'pkg/package.json', JSON.stringify({ name: 'pkg', license: 'NotAReal-License-9.9' })); + write(root, 'pkg/NOTICE', 'Custom notice terms without a recognizable license.\n'); + write(root, 'pkg/LICENSE', 'Example Internal Terms\n'); + + const findings = await collectLicenseFindings(root, [ + { path: 'pkg' }, + { path: 'pkg' }, + ]); + expect(findings.map((finding) => `${finding.location}:${String(finding.details?.source)}`)).toEqual([ + 'pkg/LICENSE:license-file', + 'pkg/NOTICE:notice', + 'pkg/package.json:manifest', + ]); + }); + + it('does not follow a license symlink outside the repository', async () => { + const root = makeRoot(); + const outside = path.join(os.tmpdir(), `vg-license-outside-${process.pid}`); + fs.writeFileSync(outside, `${SECRET}\n`); + roots.push(outside); + write(root, 'package.json', JSON.stringify({ name: 'fixture', license: 'MIT' })); + fs.symlinkSync(outside, path.join(root, 'LICENSE')); + + const findings = await collectLicenseFindings(root, [{ path: '.' }]); + expect(findings).toEqual([]); + expect(JSON.stringify(findings)).not.toContain(SECRET); + }); + + it('puts the same path on SARIF artifact location and properties', async () => { + const root = makeRoot(); + write(root, 'package.json', JSON.stringify({ name: 'fixture', license: 'MIT' })); + write(root, 'LICENSE', 'Example Internal Terms\n'); + + const findings = await collectLicenseFindings(root, [{ path: '.' }]); + const sarif = formatSarif({ + schemaVersion: '1.0', + timestamp: '2026-01-01T00:00:00.000Z', + vibgrateVersion: 'test', + rootPath: 'fixture', + projects: [], + drift: { + score: 0, + riskLevel: 'low', + components: { + runtimeScore: 0, + frameworkScore: 0, + dependencyScore: 0, + eolScore: 0, + }, + measured: [], + methodologyVersion: 'test', + }, + findings, + } as ScanArtifact) as { + runs: Array<{ + tool: { driver: { rules: Array<{ id: string; shortDescription: { text: string } }> } }; + results: Array<{ + ruleId: string; + message: { text: string }; + locations: Array<{ physicalLocation: { artifactLocation: { uri: string } } }>; + properties: { path: string; source: string }; + }>; + }>; + }; + + const result = sarif.runs[0]!.results[0]!; + expect(result.ruleId).toBe(LICENSE_UNPARSEABLE_RULE_ID); + expect(result.message.text).toBe('Unparseable license text at LICENSE.'); + expect(result.locations[0]!.physicalLocation.artifactLocation.uri).toBe('LICENSE'); + expect(result.properties.path).toBe('LICENSE'); + expect(sarif.runs[0]!.tool.driver.rules[0]).toMatchObject({ + id: LICENSE_UNPARSEABLE_RULE_ID, + shortDescription: { text: 'License text could not be parsed as SPDX' }, + }); + expect(JSON.stringify(sarif)).not.toContain(root); + }); +}); + +describe('repository license files', () => { + it('does not flag this repository root (Apache-2.0 manifest, LICENSE, and NOTICE)', async () => { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + const findings = await collectLicenseFindings(repoRoot, [{ path: '.' }]); + expect(findings).toEqual([]); + }); +}); + +describe('vg scan license findings', () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + roots.length = 0; + }); + + it('includes the license file path in scan JSON and SARIF', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-license-scan-')); + roots.push(root); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ name: 'license-path-fixture', version: '1.0.0', license: 'Apache-2.0' }), + ); + fs.writeFileSync( + path.join(root, 'LICENSE'), + ['Example Internal Terms', SECRET, 'Contact the project authors for the terms.'].join('\n'), + ); + + const out = path.join(root, 'scan.json'); + const artifact = await runCoreScan(root, { + format: 'json', + out, + concurrency: 2, + offline: true, + quiet: true, + noLocalArtifacts: true, + vibgrateVersion: 'test', + }); + + const report = JSON.parse(fs.readFileSync(out, 'utf8')) as { + findings: Array<{ ruleId?: string; location?: string; message?: string; details?: { path?: string } }>; + }; + const jsonFinding = report.findings.find((finding) => finding.ruleId === LICENSE_UNPARSEABLE_RULE_ID); + expect(jsonFinding).toMatchObject({ + location: 'LICENSE', + message: 'Unparseable license text at LICENSE.', + details: { path: 'LICENSE', source: 'license-file' }, + }); + expect(JSON.stringify(report)).not.toContain(SECRET); + + const sarif = formatSarif(artifact) as { + runs: Array<{ + results: Array<{ + ruleId: string; + locations: Array<{ physicalLocation: { artifactLocation: { uri: string } } }>; + properties?: { path?: string }; + }>; + }>; + }; + const sarifResult = sarif.runs.flatMap((run) => run.results).find((result) => result.ruleId === LICENSE_UNPARSEABLE_RULE_ID); + expect(sarifResult?.locations[0]?.physicalLocation.artifactLocation.uri).toBe('LICENSE'); + expect(sarifResult?.properties?.path).toBe('LICENSE'); + expect(JSON.stringify(sarif)).not.toContain(SECRET); + }); +}); diff --git a/src/core-open/licenses/evidence.ts b/src/core-open/licenses/evidence.ts new file mode 100644 index 0000000..7747404 --- /dev/null +++ b/src/core-open/licenses/evidence.ts @@ -0,0 +1,316 @@ +// VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by +// scripts/vendor-core-open.mjs. Do not edit here — change the source package +// and re-run the vendor script. Apache-2.0. +/** + * Local license evidence for scan findings. + * + * Dependency licenses that come from a registry response have no file in the + * repo. Those stay on the dependency row (`license.raw`, `license.spdxId`) + * and are not turned into findings — there is no path to point a human at. + * + * When the scan already has a project directory, this module reads the + * declaration the pipeline can actually name: + * + * - `package.json` `license` / `licenses`, when that manifest is present + * - a fixed set of license and NOTICE filenames in the same directory + * + * A declaration that does not resolve to a known SPDX id becomes a + * `vibgrate/license-unparseable` finding whose `location` and `details.path` + * are the repo-relative POSIX path of that file. The license body is never + * copied into the artifact. Paths are sorted; directory enumeration order is + * not used. + */ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { Finding } from '../types.js'; +import { stripBom } from '../utils/fs.js'; +import { normalizeLicense } from './normalize.js'; + +export const LICENSE_UNPARSEABLE_RULE_ID = 'vibgrate/license-unparseable'; + +/** Bytes read from a license file. Enough for an SPDX tag or a title. */ +const PREFIX_BYTES = 4096; +/** Title lines longer than this are prose, not an SPDX declaration. */ +const TITLE_LINE_MAX = 120; +/** How many leading non-empty lines may identify a standard license text. */ +const HEADER_LINE_LIMIT = 15; + +const EVIDENCE_FILES: ReadonlyArray<{ name: string; source: LicenseEvidenceSource }> = [ + { name: 'COPYING', source: 'license-file' }, + { name: 'COPYING.md', source: 'license-file' }, + { name: 'COPYING.txt', source: 'license-file' }, + { name: 'LICENCE', source: 'license-file' }, + { name: 'LICENCE.md', source: 'license-file' }, + { name: 'LICENCE.txt', source: 'license-file' }, + { name: 'LICENSE', source: 'license-file' }, + { name: 'LICENSE.md', source: 'license-file' }, + { name: 'LICENSE.txt', source: 'license-file' }, + { name: 'NOTICE', source: 'notice' }, + { name: 'NOTICE.md', source: 'notice' }, + { name: 'NOTICE.txt', source: 'notice' }, +]; + +/** + * Title patterns for the well-known license texts that ship without an + * SPDX-License-Identifier line. More specific GNU titles come first. + * Matched only against the bounded prefix, never against the whole file. + */ +const STANDARD_HEADERS: readonly RegExp[] = [ + /\bgnu affero general public license\b/i, + /\bgnu lesser general public license\b/i, + /\bgnu library general public license\b/i, + /\bgnu general public license\b/i, + /\bapache license\b/i, + /\bmit license\b/i, + /\bmozilla public license\b/i, + /\bbsd license\b/i, + /\bisc license\b/i, + /\bthe unlicense\b/i, +]; + +export type LicenseEvidenceSource = 'manifest' | 'license-file' | 'notice'; + +export interface LicenseEvidence { + /** Repo-relative POSIX path. Never absolute. */ + path: string; + source: LicenseEvidenceSource; +} + +function cmp(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +/** True when normalization resolved a real SPDX id (including a fuzzy manifest match). */ +export function isIdentifiedDeclaration(raw: string): boolean { + const verdict = normalizeLicense(raw); + return verdict.matchStatus !== 'unknown' && verdict.spdxId !== 'NOASSERTION'; +} + +/** + * Exact, alias, or expression match. Fuzzy hits are rejected so a prose line + * that merely mentions "MIT" is not treated as a parsed license file. + */ +function isStrictDeclaration(raw: string): boolean { + const verdict = normalizeLicense(raw); + if (verdict.matchStatus === 'unknown' || verdict.matchStatus === 'fuzzy') return false; + return verdict.spdxId !== 'NOASSERTION'; +} + +/** + * Reduce a manifest `license` / `licenses` value to one string. + * `null` means the value was present but not a license string (object without + * `type`/`spdx`, a number, …) — that is an unparseable declaration. + * `''` means blank, which is absence rather than a failed parse. + */ +export function declaredLicenseText(value: unknown): string | null { + if (typeof value === 'string') return value.trim(); + if (Array.isArray(value)) { + const parts: string[] = []; + for (const item of value) { + const part = declaredLicenseText(item); + if (part === null) return null; + if (part) parts.push(part); + } + if (parts.length === 0) return ''; + return parts.length === 1 ? parts[0]! : `(${parts.join(' OR ')})`; + } + if (value && typeof value === 'object') { + const obj = value as Record; + if (typeof obj.type === 'string') return obj.type.trim(); + if (typeof obj.spdx === 'string') return obj.spdx.trim(); + return null; + } + return null; +} + +function manifestStatus(value: unknown): 'absent' | 'identified' | 'unparseable' { + if (value === undefined || value === null) return 'absent'; + const text = declaredLicenseText(value); + if (text === null) return 'unparseable'; + if (!text) return 'absent'; + return isIdentifiedDeclaration(text) ? 'identified' : 'unparseable'; +} + +function firstSpdxDeclaration(prefix: string): string | null { + for (const line of prefix.split(/\r?\n/)) { + const match = /SPDX-License-Identifier:\s*(.+)$/i.exec(line); + if (!match?.[1]) continue; + const value = match[1].replace(/\*\/\s*$/, '').replace(/-->\s*$/, '').trim(); + if (value) return value.slice(0, 200); + } + return null; +} + +/** `identified` when the prefix names a known license; otherwise `unparseable`. */ +export function classifyLicensePrefix(prefix: string): 'identified' | 'unparseable' { + const text = stripBom(prefix).replace(/\u0000/g, ''); + const spdx = firstSpdxDeclaration(text); + if (spdx !== null) { + return isIdentifiedDeclaration(spdx) ? 'identified' : 'unparseable'; + } + + let seen = 0; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + seen += 1; + if (STANDARD_HEADERS.some((pattern) => pattern.test(trimmed))) return 'identified'; + if (trimmed.length <= TITLE_LINE_MAX && isStrictDeclaration(trimmed)) return 'identified'; + if (seen >= HEADER_LINE_LIMIT) break; + } + return 'unparseable'; +} + +/** Repo-relative POSIX path, or null when `filePath` escapes `rootDir`. */ +export function repoRelativePosix(rootDir: string, filePath: string): string | null { + const rel = path.relative(rootDir, filePath); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; + const posix = rel.split(/[/\\]/).join('/'); + if (!posix || posix.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) { + return null; + } + return posix; +} + +type ReadResult = + | { kind: 'text'; text: string } + | { kind: 'unreadable' } + | { kind: 'skip' }; + +async function readEvidenceFile(rootDir: string, filePath: string): Promise { + let realPath = filePath; + try { + const st = await fs.lstat(filePath); + if (st.isSymbolicLink()) { + realPath = await fs.realpath(filePath); + if (repoRelativePosix(rootDir, realPath) === null) return { kind: 'skip' }; + const target = await fs.stat(realPath); + if (!target.isFile()) return { kind: 'skip' }; + } else if (!st.isFile()) { + return { kind: 'skip' }; + } + } catch { + return { kind: 'skip' }; + } + + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(realPath, 'r'); + const buf = Buffer.alloc(PREFIX_BYTES); + const { bytesRead } = await handle.read(buf, 0, PREFIX_BYTES, 0); + return { kind: 'text', text: buf.subarray(0, bytesRead).toString('utf8') }; + } catch { + return { kind: 'unreadable' }; + } finally { + await handle?.close(); + } +} + +async function manifestLicenseValue(manifestPath: string): Promise { + try { + const raw = await fs.readFile(manifestPath, 'utf8'); + const parsed: unknown = JSON.parse(stripBom(raw)); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + const record = parsed as Record; + if (Object.prototype.hasOwnProperty.call(record, 'license') && record.license != null) { + return record.license; + } + if (Object.prototype.hasOwnProperty.call(record, 'licenses') && record.licenses != null) { + return record.licenses; + } + return undefined; + } catch { + return undefined; + } +} + +async function evidenceForDirectory(rootDir: string, projectDir: string): Promise { + const found: LicenseEvidence[] = []; + + const manifestPath = path.join(projectDir, 'package.json'); + const manifestRel = repoRelativePosix(rootDir, manifestPath); + if (manifestRel) { + let exists = false; + try { + exists = (await fs.lstat(manifestPath)).isFile(); + } catch { + exists = false; + } + if (exists && manifestStatus(await manifestLicenseValue(manifestPath)) === 'unparseable') { + found.push({ path: manifestRel, source: 'manifest' }); + } + } + + for (const file of EVIDENCE_FILES) { + const abs = path.join(projectDir, file.name); + const rel = repoRelativePosix(rootDir, abs); + if (!rel) continue; + const read = await readEvidenceFile(rootDir, abs); + if (read.kind === 'skip') continue; + if (read.kind === 'unreadable' || classifyLicensePrefix(read.text) === 'unparseable') { + found.push({ path: rel, source: file.source }); + } + } + + return found; +} + +function uniqueProjectDirs(rootDir: string, projects: ReadonlyArray<{ path: string }>): string[] { + const byKey = new Map(); + for (const project of projects) { + const raw = project.path && project.path !== '.' ? project.path : '.'; + const abs = path.resolve(rootDir, raw); + const rel = path.relative(rootDir, abs); + if (!rel) { + if (!byKey.has('.')) byKey.set('.', abs); + continue; + } + if (rel.startsWith('..') || path.isAbsolute(rel)) continue; + const key = rel.split(/[/\\]/).filter(Boolean).join('/'); + if (!key || key.split('/').some((segment) => segment === '..')) continue; + if (!byKey.has(key)) byKey.set(key, abs); + } + return [...byKey.entries()].sort((a, b) => cmp(a[0], b[0])).map(([, abs]) => abs); +} + +function toFinding(evidence: LicenseEvidence): Finding { + return { + ruleId: LICENSE_UNPARSEABLE_RULE_ID, + level: 'warning', + message: `Unparseable license text at ${evidence.path}.`, + location: evidence.path, + details: { + path: evidence.path, + source: evidence.source, + }, + }; +} + +/** + * Findings for local license declarations that failed SPDX identification. + * One finding per evidence path. Sorted by path, then source. Duplicate + * project directories (a Dockerfile beside a package) are scanned once. + */ +export async function collectLicenseFindings( + rootDir: string, + projects: ReadonlyArray<{ path: string }>, +): Promise { + const dirs = uniqueProjectDirs(rootDir, projects); + const evidence: LicenseEvidence[] = []; + for (const dir of dirs) { + evidence.push(...await evidenceForDirectory(rootDir, dir)); + } + evidence.sort((a, b) => cmp(a.path, b.path) || cmp(a.source, b.source)); + + const findings: Finding[] = []; + let previous = ''; + for (const item of evidence) { + const key = `${item.path}\0${item.source}`; + if (key === previous) continue; + previous = key; + findings.push(toFinding(item)); + } + return findings; +} diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index 5375ff9..add0909 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -45,6 +45,7 @@ import { loadPackageVersionManifest } from './package-version-manifest.js'; import { generateWorkspaceRelationshipMermaid, generateProjectRelationshipMermaid, generateSolutionRelationshipMermaid } from './utils/mermaid.js'; import { classifyProject, summarizeBilling } from './scanners/project-classification.js'; import { collectVulnTargets, scanVulnerabilities, generateVulnerabilityFindings } from './scanners/vulnerability-scanner.js'; +import { collectLicenseFindings } from './licenses/evidence.js'; import { attributeVulnerabilities } from './scoring/vuln-attribution.js'; import { gitHistoryAvailable, workingTreeDirty } from './utils/git-history.js'; import { buildVersionTimelines } from './utils/version-timeline.js'; @@ -722,7 +723,10 @@ export async function runCoreScan( // ── Step: Findings ── progress.startStep('findings'); - const findings = [...generateFindings(allProjects, config), ...vulnFindings]; + // Local license declarations that failed SPDX parsing, with the manifest / + // LICENSE / NOTICE path. Registry licenses have no file and are not listed. + const licenseFindings = await collectLicenseFindings(rootDir, allProjects); + const findings = [...generateFindings(allProjects, config), ...licenseFindings, ...vulnFindings]; const warnCount = findings.filter((f) => f.level === 'warning').length; const errCount = findings.filter((f) => f.level === 'error').length; const noteCount = findings.filter((f) => f.level === 'note').length; diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..bf2b1a1 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -81,6 +81,13 @@ export interface PackageJson { devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; + /** + * Declared license (SPDX id, expression, or legacy `{ type }` object). + * Read for local license findings; see `licenses/evidence.ts`. + */ + license?: string | { type?: string; url?: string }; + /** Legacy npm form used when `license` is absent. */ + licenses?: Array; } // ── npm registry metadata ──