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
2 changes: 2 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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: 'License text could not be parsed as SPDX' },
helpUri: 'https://vibgrate.com/rules/license-unparseable',
},
};
return descriptions[id] ?? {
id,
Expand Down
6 changes: 6 additions & 0 deletions src/core-open/licenses/dependency-license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
247 changes: 247 additions & 0 deletions src/core-open/licenses/evidence.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown> }>): 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);
});
});
Loading
Loading