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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions src/core-open/formatters/sarif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion src/core-open/licenses/dependency-license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,39 @@
* 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 {
raw: trimmed.slice(0, 200),
spdxId: verdict.matchStatus === 'unknown' ? null : verdict.spdxId,
source,
confidence: verdict.confidence,
...(path ? { path } : {}),
};
}
229 changes: 229 additions & 0 deletions src/core-open/licenses/project-license.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
readText(absPath: string): Promise<string>;
readJson(absPath: string): Promise<unknown>;
}

/** 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<string, unknown>;
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<DependencyLicense | undefined> {
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<string, unknown>).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<void> {
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<string>();
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;
}
6 changes: 6 additions & 0 deletions src/core-open/run-core-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/core-open/scoring/drift-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -383,7 +384,7 @@ export function generateFindings(
}
}

return findings;
return [...findings, ...licenseFindingsForProjects(projects)];
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/core-open/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading