From 14b08bdabbcc86bddb4abca13490d5847dd529de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 22:18:22 +0000 Subject: [PATCH] fix(scan): include a source path on license findings Declared licenses from a manifest or a LICENSE/NOTICE/COPYING file now carry a stable repo-relative path in scan JSON and SARIF. Text that does not parse as SPDX is reported at that path instead of being dropped, and the file body is not copied into the artifact. Fixes #213 Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- README.md | 1 + src/core-open/formatters/sarif.ts | 10 + src/core-open/licenses/dependency-license.ts | 18 +- src/core-open/licenses/project-license.ts | 229 +++++++++++++++++++ src/core-open/run-core-scan.ts | 6 + src/core-open/scoring/drift-score.ts | 3 +- src/core-open/types.ts | 12 + test/license-findings.test.ts | 171 ++++++++++++++ 8 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 src/core-open/licenses/project-license.ts create mode 100644 test/license-findings.test.ts diff --git a/README.md b/README.md index 3346e40..81f4085 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,7 @@ One scan gives you: - **Score breakdown** — runtime, frameworks, dependencies, EOL - **Per-project detail** across Node.js/TypeScript, .NET, Python, and Java - **Actionable findings** ranked by likely impact +- **License evidence paths** in JSON and SARIF (`projects[].license.path` and finding `location`). A manifest `license` field, or a LICENSE / NOTICE / COPYING file, is named when one exists. Text that does not parse as SPDX is reported at that path instead of being dropped; the file body is not copied into the result - **[SBOM](https://vibgrate.com/glossary/sbom) export** (CycloneDX / SPDX) - **Known vulnerabilities** (opt in with `--vulns`) — severity, CVSS, the fixing version, and, in a git repo, who introduced them diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index 9825a8d..017f5f8 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -170,6 +170,16 @@ function buildRules(findings: Finding[]) { shortDescription: { text: 'Known vulnerability in an installed dependency' }, helpUri: 'https://vibgrate.com/rules/vulnerability', }, + 'vibgrate/license': { + id: 'vibgrate/license', + shortDescription: { text: 'Declared license with a source path' }, + helpUri: 'https://vibgrate.com/rules/license', + }, + 'vibgrate/unparseable-license': { + id: 'vibgrate/unparseable-license', + shortDescription: { text: 'License text could not be parsed as SPDX' }, + helpUri: 'https://vibgrate.com/rules/unparseable-license', + }, }; return descriptions[id] ?? { id, diff --git a/src/core-open/licenses/dependency-license.ts b/src/core-open/licenses/dependency-license.ts index cc73407..5cc7e27 100644 --- a/src/core-open/licenses/dependency-license.ts +++ b/src/core-open/licenses/dependency-license.ts @@ -8,17 +8,32 @@ * 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. + * + * A registry signal has no local evidence file, so `path` stays omitted and an + * unparseable registry string is kept on the dependency row (`raw` retained, + * `spdxId` null) rather than promoted to a finding. When the caller already + * has a manifest or license-file path, pass it — an unknown SPDX id is still + * returned (not dropped) so the scan can point at that file. */ import type { DependencyLicense } from '../types.js'; import { normalizeLicense } from './normalize.js'; +/** Repo-relative evidence path, or undefined when the caller has none. */ +export function licenseEvidencePath(input: string | null | undefined): string | undefined { + if (!input) return undefined; + const norm = input.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''); + return norm || undefined; +} + export function buildDependencyLicense( raw: string | null | undefined, source: DependencyLicense['source'], + evidencePath?: string | null, ): DependencyLicense { + const path = licenseEvidencePath(evidencePath); const trimmed = (raw ?? '').trim(); if (!trimmed) { - return { raw: null, spdxId: null, source: 'none', confidence: 0 }; + return { raw: null, spdxId: null, source: 'none', confidence: 0, ...(path ? { path } : {}) }; } const verdict = normalizeLicense(trimmed); return { @@ -26,5 +41,6 @@ export function buildDependencyLicense( spdxId: verdict.matchStatus === 'unknown' ? null : verdict.spdxId, source, confidence: verdict.confidence, + ...(path ? { path } : {}), }; } diff --git a/src/core-open/licenses/project-license.ts b/src/core-open/licenses/project-license.ts new file mode 100644 index 0000000..eca36c3 --- /dev/null +++ b/src/core-open/licenses/project-license.ts @@ -0,0 +1,229 @@ +/** + * Project license evidence for scan JSON and SARIF. + * + * The scan already opens each project's manifest. When that manifest declares + * a license, or a LICENSE / NOTICE / COPYING file sits beside it, the evidence + * path is recorded on the project and emitted as a finding. Text that does not + * parse as SPDX becomes a warning at that path instead of being dropped. The + * file body is not copied into the artifact. + * + * Registry-only dependency licenses have no local file; they stay on the + * dependency row and are not promoted to a finding. + */ +import * as path from 'node:path'; +import type { DependencyLicense, Finding, ProjectScan } from '../types.js'; +import { buildDependencyLicense, licenseEvidencePath } from './dependency-license.js'; +import { normalizeLicense } from './normalize.js'; + +/** Evidence filenames, most specific first. First file that exists wins. */ +export const LICENSE_EVIDENCE_FILES = [ + 'LICENSE', + 'LICENSE.md', + 'LICENSE.txt', + 'LICENCE', + 'LICENCE.md', + 'LICENCE.txt', + 'COPYING', + 'COPYING.txt', + 'NOTICE', + 'NOTICE.md', + 'NOTICE.txt', +] as const; + +export interface LicenseIo { + exists(absPath: string): Promise; + readText(absPath: string): Promise; + readJson(absPath: string): Promise; +} + +/** A manifest `license` value reduced to a declared string, if it has one. */ +export function licenseRawFromManifest(value: unknown): { present: boolean; raw: string | null } { + if (value == null) return { present: false, raw: null }; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? { present: true, raw: trimmed } : { present: false, raw: null }; + } + if (Array.isArray(value)) { + const parts: string[] = []; + for (const item of value) { + const part = licenseRawFromManifest(item); + if (part.raw) parts.push(part.raw); + else if (part.present) return { present: true, raw: null }; + } + if (parts.length === 0) return { present: value.length > 0, raw: null }; + return { present: true, raw: parts.length === 1 ? parts[0]! : `(${parts.join(' OR ')})` }; + } + if (typeof value === 'object') { + const obj = value as Record; + if (typeof obj.type === 'string' && obj.type.trim()) return { present: true, raw: obj.type.trim() }; + if (typeof obj.spdx === 'string' && obj.spdx.trim()) return { present: true, raw: obj.spdx.trim() }; + return { present: true, raw: null }; + } + return { present: true, raw: null }; +} + +function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +/** + * Classify a short excerpt of a license file. Returns an SPDX id when the + * SPDX tag or the first non-empty line resolves; otherwise raw stays null so + * the file body is not stored. + */ +export function classifyLicenseExcerpt(text: string): { raw: string | null; spdxId: string | null; confidence: number } { + const excerpt = stripBom(text).slice(0, 1024); + const tagged = /SPDX-License-Identifier:\s*([A-Za-z0-9.\-+]+)/.exec(excerpt); + const candidate = tagged?.[1] ?? firstNonEmptyLine(excerpt); + if (!candidate) return { raw: null, spdxId: null, confidence: 0 }; + const verdict = normalizeLicense(candidate); + if (verdict.matchStatus === 'unknown') return { raw: null, spdxId: null, confidence: 0 }; + return { + raw: candidate.slice(0, 200), + spdxId: verdict.spdxId, + confidence: verdict.confidence, + }; +} + +function firstNonEmptyLine(text: string): string | null { + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed) return trimmed.slice(0, 120); + } + return null; +} + +function repoPath(projectPath: string, name: string): string { + const base = projectPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''); + if (!base || base === '.') return name; + return `${base}/${name}`; +} + +function absUnder(rootDir: string, relPosix: string): string { + return path.join(rootDir, ...relPosix.split('/')); +} + +/** + * Detect one project's declared license. Manifest `license` wins over a + * sibling license file. Returns undefined when nothing was declared. + */ +export async function detectProjectLicense( + rootDir: string, + projectPath: string, + io: LicenseIo, +): Promise { + const manifestRel = repoPath(projectPath, 'package.json'); + const manifestAbs = absUnder(rootDir, manifestRel); + if (await io.exists(manifestAbs)) { + try { + const json = await io.readJson(manifestAbs); + const field = json && typeof json === 'object' ? (json as Record).license : undefined; + const parsed = licenseRawFromManifest(field); + if (parsed.present) { + if (parsed.raw) return buildDependencyLicense(parsed.raw, 'manifest', manifestRel); + return { raw: null, spdxId: null, source: 'manifest', confidence: 0, path: manifestRel }; + } + } catch { + // Unreadable manifest — a sibling license file can still be evidence. + } + } + + for (const name of LICENSE_EVIDENCE_FILES) { + const rel = repoPath(projectPath, name); + const abs = absUnder(rootDir, rel); + if (!(await io.exists(abs))) continue; + let text = ''; + try { + text = await io.readText(abs); + } catch { + return { raw: null, spdxId: null, source: 'license-file', confidence: 0, path: rel }; + } + const classified = classifyLicenseExcerpt(text); + return { + raw: classified.raw, + spdxId: classified.spdxId, + source: 'license-file', + confidence: classified.confidence, + path: rel, + }; + } + return undefined; +} + +/** Attach {@link ProjectScan.license} for every project that has evidence. */ +export async function attachProjectLicenses( + projects: readonly ProjectScan[], + rootDir: string, + io: LicenseIo, +): Promise { + await Promise.all(projects.map(async (project) => { + const license = await detectProjectLicense(rootDir, project.path, io); + if (license) project.license = license; + })); +} + +/** + * A finding for a license that has a source path. Parseable licenses are + * notes; unparseable ones are warnings that name the file. No path → no + * finding (registry signals stay on the dependency row). + */ +export function findingForLicense(license: DependencyLicense): Finding | null { + const evidence = licenseEvidencePath(license.path); + if (!evidence || license.source === 'none') return null; + if (license.spdxId) { + return { + ruleId: 'vibgrate/license', + level: 'note', + message: `Declared license ${license.spdxId} at ${evidence}.`, + location: evidence, + details: { + source: license.source, + path: evidence, + spdxId: license.spdxId, + }, + }; + } + return { + ruleId: 'vibgrate/unparseable-license', + level: 'warning', + message: `Unparseable license text at ${evidence}.`, + location: evidence, + details: { + source: license.source, + path: evidence, + ...(license.raw ? { raw: license.raw } : {}), + }, + }; +} + +/** + * License findings for a scan, sorted by path then rule so output does not + * follow project discovery order. One finding per path and rule. + */ +export function licenseFindingsForProjects(projects: readonly ProjectScan[]): Finding[] { + const out: Finding[] = []; + const seen = new Set(); + const ordered = [...projects].sort( + (a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name) || a.type.localeCompare(b.type), + ); + for (const project of ordered) { + const candidates: DependencyLicense[] = []; + if (project.license?.path) candidates.push(project.license); + const deps = [...project.dependencies].sort( + (a, b) => a.package.localeCompare(b.package) || a.section.localeCompare(b.section), + ); + for (const dep of deps) { + if (dep.license?.path) candidates.push(dep.license); + } + for (const license of candidates) { + const finding = findingForLicense(license); + if (!finding) continue; + const key = `${finding.ruleId}\0${finding.location}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(finding); + } + } + out.sort((a, b) => a.location.localeCompare(b.location) || a.ruleId.localeCompare(b.ruleId)); + return out; +} diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index 5375ff9..f1d77a4 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -31,6 +31,7 @@ import { ComposerCache } from './scanners/composer-cache.js'; import { PubCache } from './scanners/pub-cache.js'; import { Semaphore } from './utils/semaphore.js'; import { computeDriftScore, generateFindings, computeProjectId, computeSolutionId } from './scoring/drift-score.js'; +import { attachProjectLicenses } from './licenses/project-license.js'; import { formatText } from './formatters/text.js'; import { formatSarif } from './formatters/sarif.js'; import { formatMarkdown } from './formatters/markdown.js'; @@ -722,6 +723,11 @@ export async function runCoreScan( // ── Step: Findings ── progress.startStep('findings'); + await attachProjectLicenses(allProjects, rootDir, { + exists: (p) => fileCache.pathExists(p), + readText: (p) => fileCache.readTextFile(p), + readJson: (p) => fileCache.readJsonFile(p), + }); const findings = [...generateFindings(allProjects, config), ...vulnFindings]; const warnCount = findings.filter((f) => f.level === 'warning').length; const errCount = findings.filter((f) => f.level === 'error').length; diff --git a/src/core-open/scoring/drift-score.ts b/src/core-open/scoring/drift-score.ts index 9ee6bff..a503b6e 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 { licenseFindingsForProjects } from '../licenses/project-license.js'; import { aggregateDependencyDrift } from './dependency-drift-v3.js'; /** @@ -383,7 +384,7 @@ export function generateFindings( } } - return findings; + return [...findings, ...licenseFindingsForProjects(projects)]; } /** diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..2a2144f 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -116,6 +116,12 @@ export interface DependencyLicense { source: 'manifest' | 'registry' | 'license-file' | 'none'; /** 0–1 confidence in the captured signal. */ confidence: number; + /** + * Repo-relative path (forward slashes) of the evidence file — the manifest, + * NOTICE, or declared license file — when the scan already had one. + * Omitted for registry-only signals, which have no local file to open. + */ + path?: string; } // ── Per-dependency analysis row ── @@ -211,6 +217,12 @@ export interface ProjectScan { packageManager?: string; frameworks: DetectedFramework[]; dependencies: DependencyRow[]; + /** + * License declared by this project, when a manifest `license` field or a + * LICENSE / NOTICE / COPYING file sits beside it. `path` is the evidence + * file. Absent when nothing was declared — not the same as an empty license. + */ + license?: DependencyLicense; dependencyAgeBuckets: { current: number; oneBehind: number; diff --git a/test/license-findings.test.ts b/test/license-findings.test.ts new file mode 100644 index 0000000..e392dd0 --- /dev/null +++ b/test/license-findings.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runCoreScan, formatSarif } from '../src/core-open/index.js'; +import { buildDependencyLicense } from '../src/core-open/licenses/dependency-license.js'; +import { + detectProjectLicense, + findingForLicense, + licenseFindingsForProjects, + type LicenseIo, +} from '../src/core-open/licenses/project-license.js'; +import type { ProjectScan } from '../src/core-open/types.js'; + +const GARBAGE = 'NOT-A-REAL-LICENSE-BLOB-7f3a'; + +function ioFor(root: string): LicenseIo { + return { + exists: async (abs) => { + try { + await fs.promises.access(abs); + return true; + } catch { + return false; + } + }, + readText: (abs) => fs.promises.readFile(abs, 'utf8'), + readJson: async (abs) => JSON.parse(await fs.promises.readFile(abs, 'utf8')) as unknown, + }; +} + +function write(root: string, rel: string, contents: string): void { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, contents); +} + +describe('license finding source path', () => { + const dirs: string[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + function temp(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-license-')); + dirs.push(dir); + return dir; + } + + it('keeps an unparseable registry license on the row and does not invent a path', () => { + const license = buildDependencyLicense(GARBAGE, 'registry'); + expect(license).toEqual({ raw: GARBAGE, spdxId: null, source: 'registry', confidence: 0 }); + expect(license.path).toBeUndefined(); + expect(findingForLicense(license)).toBeNull(); + }); + + it('points an unparseable manifest license at the manifest instead of dropping it', async () => { + const root = temp(); + write(root, 'pkg/package.json', JSON.stringify({ name: 'pkg', license: GARBAGE })); + const first = await detectProjectLicense(root, 'pkg', ioFor(root)); + const second = await detectProjectLicense(root, 'pkg', ioFor(root)); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(first).toMatchObject({ + raw: GARBAGE, + spdxId: null, + source: 'manifest', + path: 'pkg/package.json', + }); + if (!first) throw new Error('expected a manifest license'); + const finding = findingForLicense(first); + expect(finding).toMatchObject({ + ruleId: 'vibgrate/unparseable-license', + level: 'warning', + location: 'pkg/package.json', + message: 'Unparseable license text at pkg/package.json.', + details: { source: 'manifest', path: 'pkg/package.json', raw: GARBAGE }, + }); + }); + + it('prefers the manifest path, and otherwise names the license file without copying its body', async () => { + const root = temp(); + write(root, 'pkg/package.json', JSON.stringify({ name: 'pkg', license: 'MIT' })); + write(root, 'pkg/LICENSE', `${GARBAGE}\n`); + write(root, 'pkg/NOTICE', 'custom terms\n'); + const declared = await detectProjectLicense(root, 'pkg', ioFor(root)); + expect(declared).toMatchObject({ spdxId: 'MIT', source: 'manifest', path: 'pkg/package.json' }); + expect(JSON.stringify(declared)).not.toContain(GARBAGE); + + write(root, 'pkg/package.json', JSON.stringify({ name: 'pkg' })); + const fromFile = await detectProjectLicense(root, 'pkg', ioFor(root)); + expect(fromFile).toMatchObject({ + raw: null, + spdxId: null, + source: 'license-file', + path: 'pkg/LICENSE', + }); + expect(JSON.stringify(fromFile)).not.toContain(GARBAGE); + + write(root, 'other/package.json', JSON.stringify({ name: 'other' })); + write(root, 'other/NOTICE', 'SPDX-License-Identifier: Apache-2.0\n'); + const notice = await detectProjectLicense(root, 'other', ioFor(root)); + expect(notice).toMatchObject({ spdxId: 'Apache-2.0', source: 'license-file', path: 'other/NOTICE' }); + }); + + it('sorts license findings by path and emits one per evidence file', () => { + const projects = [ + project('b', { raw: null, spdxId: null, source: 'license-file', confidence: 0, path: 'b/LICENSE' }), + project('a', { raw: 'MIT', spdxId: 'MIT', source: 'manifest', confidence: 1, path: 'a/package.json' }), + ]; + const first = licenseFindingsForProjects(projects); + const second = licenseFindingsForProjects([...projects].reverse()); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(first.map((f) => f.location)).toEqual(['a/package.json', 'b/LICENSE']); + expect(first.map((f) => f.ruleId)).toEqual(['vibgrate/license', 'vibgrate/unparseable-license']); + }); + + it('includes the license file path in scan JSON and SARIF', async () => { + const root = temp(); + write(root, 'pkg/package.json', JSON.stringify({ name: 'license-fixture', version: '1.0.0' })); + write(root, 'pkg/LICENSE', `${GARBAGE}\nCopyright example only.\n`); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubEnv('VIBGRATE_DSN', ''); + + const artifact = await runCoreScan(root, { + format: 'json', + concurrency: 2, + offline: true, + noLocalArtifacts: true, + vibgrateVersion: 'test', + }); + + const project = artifact.projects.find((p) => p.path === 'pkg' || p.path === 'pkg/'); + expect(project?.license).toMatchObject({ + raw: null, + spdxId: null, + source: 'license-file', + path: 'pkg/LICENSE', + }); + const finding = artifact.findings.find((f) => f.ruleId === 'vibgrate/unparseable-license'); + expect(finding).toMatchObject({ + level: 'warning', + location: 'pkg/LICENSE', + message: 'Unparseable license text at pkg/LICENSE.', + }); + + const json = JSON.stringify(artifact); + expect(json).toContain('"path":"pkg/LICENSE"'); + expect(json).not.toContain(GARBAGE); + + const sarif = formatSarif(artifact) as { + runs: Array<{ results: Array<{ ruleId: string; locations: Array<{ physicalLocation: { artifactLocation: { uri: string } } }> }> }>; + }; + const result = sarif.runs[0]?.results.find((r) => r.ruleId === 'vibgrate/unparseable-license'); + expect(result?.locations[0]?.physicalLocation.artifactLocation.uri).toBe('pkg/LICENSE'); + }); +}); + +function project(projectPath: string, license: ProjectScan['license']): ProjectScan { + return { + type: 'node', + path: projectPath, + name: projectPath, + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + license, + }; +}