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, + }; +}