@mirasen/license-gate 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +157 -0
- package/dist/commands/_shared.d.ts +17 -0
- package/dist/commands/_shared.js +33 -0
- package/dist/commands/check.d.ts +20 -0
- package/dist/commands/check.js +51 -0
- package/dist/commands/collect.d.ts +24 -0
- package/dist/commands/collect.js +31 -0
- package/dist/graph/load.d.ts +17 -0
- package/dist/graph/load.js +35 -0
- package/dist/graph/narrow.d.ts +24 -0
- package/dist/graph/narrow.js +114 -0
- package/dist/graph/record.d.ts +38 -0
- package/dist/graph/record.js +112 -0
- package/dist/graph/scope.d.ts +22 -0
- package/dist/graph/scope.js +88 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +23 -0
- package/dist/policy/allowed-hard.d.ts +17 -0
- package/dist/policy/allowed-hard.js +27 -0
- package/dist/policy/allowed-packages.d.ts +29 -0
- package/dist/policy/allowed-packages.js +137 -0
- package/dist/policy/evaluate.d.ts +30 -0
- package/dist/policy/evaluate.js +151 -0
- package/dist/policy/spdx-shape.d.ts +34 -0
- package/dist/policy/spdx-shape.js +76 -0
- package/dist/report/human.d.ts +12 -0
- package/dist/report/human.js +117 -0
- package/dist/report/json.d.ts +27 -0
- package/dist/report/json.js +53 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +19 -0
- package/package.json +89 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for `licenses/allowed-packages.txt`.
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes the file contents and a path (for error reporting), returns
|
|
5
|
+
* either a list of validated rules or throws a `LicenseGateConfigError` with
|
|
6
|
+
* `kind: 'invalid-package-override-rule'` carrying the offending line.
|
|
7
|
+
*
|
|
8
|
+
* Accepted rule forms (and ONLY these):
|
|
9
|
+
* 1. `@scope/*` trusted internal namespace
|
|
10
|
+
* 2. `package-name@version` exact installed unscoped package version
|
|
11
|
+
* 3. `@scope/package@version` exact installed scoped package version
|
|
12
|
+
* 4. `package-name@*` package-name wildcard (any version),
|
|
13
|
+
* unscoped — narrowly scoped escape hatch
|
|
14
|
+
* for manually reviewed packages whose
|
|
15
|
+
* version bumps should not require
|
|
16
|
+
* re-editing this file.
|
|
17
|
+
* 5. `@scope/package@*` package-name wildcard (any version),
|
|
18
|
+
* scoped — same intent as form 4.
|
|
19
|
+
*
|
|
20
|
+
* Everything else is rejected, including:
|
|
21
|
+
* - bare `package-name` (no version)
|
|
22
|
+
* - bare `@scope/package` (no version, no `*`)
|
|
23
|
+
* - semver ranges: `^1.2.3`, `~1.2.3`, `1.x`, `>=1.0.0`, ...
|
|
24
|
+
* - `@scope/*@*`, `*@*`
|
|
25
|
+
* - regex, generic glob, prefix wildcard, bare `*`
|
|
26
|
+
* - any whitespace inside the rule
|
|
27
|
+
*/
|
|
28
|
+
import { LicenseGateConfigError } from '../types.js';
|
|
29
|
+
// Unscoped package name: lowercase letters, digits, '.', '_', '-', no leading dot or underscore.
|
|
30
|
+
// We are conservative — npm itself allows more, but policy files are author-edited so a tight
|
|
31
|
+
// pattern catches typos rather than letting them through.
|
|
32
|
+
const UNSCOPED_NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
33
|
+
const SCOPE = /^@[a-z0-9][a-z0-9._-]*$/;
|
|
34
|
+
// Strict semver-like version: digits.digits.digits with optional pre-release/build.
|
|
35
|
+
// We do NOT accept ranges, partials, or `*` here — those are rejected.
|
|
36
|
+
const EXACT_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
37
|
+
function rejectWith(path, lineNumber, line, hint) {
|
|
38
|
+
throw new LicenseGateConfigError({ kind: 'invalid-package-override-rule', path, lineNumber, line }, `license-gate: ${path}:${lineNumber}: invalid package override rule "${line}". ${hint} Accepted forms: "@scope/*", "package@version", "@scope/package@version", "package@*", "@scope/package@*".`);
|
|
39
|
+
}
|
|
40
|
+
function parseRule(path, lineNumber, raw) {
|
|
41
|
+
const trimmed = raw.trim();
|
|
42
|
+
// Whitespace inside the rule is invalid.
|
|
43
|
+
if (/\s/.test(trimmed)) {
|
|
44
|
+
rejectWith(path, lineNumber, trimmed, 'rule must not contain internal whitespace.');
|
|
45
|
+
}
|
|
46
|
+
// Form 1: @scope/*
|
|
47
|
+
if (trimmed.endsWith('/*')) {
|
|
48
|
+
const scope = trimmed.slice(0, -2);
|
|
49
|
+
if (!SCOPE.test(scope)) {
|
|
50
|
+
rejectWith(path, lineNumber, trimmed, 'scope rules must be exactly `@scope/*` with a valid scope.');
|
|
51
|
+
}
|
|
52
|
+
return { kind: 'scope', scope, ruleText: trimmed };
|
|
53
|
+
}
|
|
54
|
+
// Forms 4 and 5: package-name wildcard `name@*` / `@scope/name@*`.
|
|
55
|
+
// The `*` is allowed ONLY as the trailing version sentinel; the head must
|
|
56
|
+
// be a valid (scoped or unscoped) package name with no other `*`.
|
|
57
|
+
if (trimmed.endsWith('@*')) {
|
|
58
|
+
const head = trimmed.slice(0, -2);
|
|
59
|
+
// Reject if `*` appears anywhere in the head — covers `@scope/*@*`,
|
|
60
|
+
// `*@*`, `pkg*@*`, etc.
|
|
61
|
+
if (head.length === 0 || head.includes('*')) {
|
|
62
|
+
rejectWith(path, lineNumber, trimmed, 'package-name wildcard must be `package@*` or `@scope/package@*`.');
|
|
63
|
+
}
|
|
64
|
+
if (head.startsWith('@')) {
|
|
65
|
+
const slash = head.indexOf('/');
|
|
66
|
+
if (slash <= 1 || slash === head.length - 1) {
|
|
67
|
+
rejectWith(path, lineNumber, trimmed, 'scoped package-name wildcard must be `@scope/name@*`.');
|
|
68
|
+
}
|
|
69
|
+
const scope = head.slice(0, slash);
|
|
70
|
+
const subname = head.slice(slash + 1);
|
|
71
|
+
if (!SCOPE.test(scope) || !UNSCOPED_NAME.test(subname)) {
|
|
72
|
+
rejectWith(path, lineNumber, trimmed, 'scope or package name is malformed.');
|
|
73
|
+
}
|
|
74
|
+
return { kind: 'scoped-package-name', scope, name: head, ruleText: trimmed };
|
|
75
|
+
}
|
|
76
|
+
if (!UNSCOPED_NAME.test(head)) {
|
|
77
|
+
rejectWith(path, lineNumber, trimmed, 'package name is malformed.');
|
|
78
|
+
}
|
|
79
|
+
return { kind: 'package-name', name: head, ruleText: trimmed };
|
|
80
|
+
}
|
|
81
|
+
// Reject genuinely glob/regex-like characters early. We deliberately do
|
|
82
|
+
// NOT include `+` here because `+` is a valid SemVer build-metadata
|
|
83
|
+
// separator (e.g. `pkg@1.2.3+build.42`); the head/version regexes below
|
|
84
|
+
// still reject `+` if it appears in the package-name part. Likewise `-`
|
|
85
|
+
// is a valid SemVer pre-release separator.
|
|
86
|
+
if (trimmed === '*' || trimmed.includes('*') || /[\\(){}|^$?]/.test(trimmed)) {
|
|
87
|
+
rejectWith(path, lineNumber, trimmed, 'wildcards, regex, and glob syntax are not allowed.');
|
|
88
|
+
}
|
|
89
|
+
// Must contain `@` separating name and version (for the version-bearing forms).
|
|
90
|
+
// For scoped names the version `@` is the LAST `@`.
|
|
91
|
+
const lastAt = trimmed.lastIndexOf('@');
|
|
92
|
+
if (lastAt <= 0) {
|
|
93
|
+
rejectWith(path, lineNumber, trimmed, 'missing version. Override rules must pin an exact version.');
|
|
94
|
+
}
|
|
95
|
+
const head = trimmed.slice(0, lastAt);
|
|
96
|
+
const version = trimmed.slice(lastAt + 1);
|
|
97
|
+
if (!EXACT_VERSION.test(version)) {
|
|
98
|
+
rejectWith(path, lineNumber, trimmed, `version must be an exact semver (got "${version}"). Semver ranges, "*" and partial versions are not allowed.`);
|
|
99
|
+
}
|
|
100
|
+
if (head.startsWith('@')) {
|
|
101
|
+
// Scoped: @scope/name
|
|
102
|
+
const slash = head.indexOf('/');
|
|
103
|
+
if (slash <= 1 || slash === head.length - 1) {
|
|
104
|
+
rejectWith(path, lineNumber, trimmed, 'scoped package must be `@scope/name@version`.');
|
|
105
|
+
}
|
|
106
|
+
const scope = head.slice(0, slash);
|
|
107
|
+
const subname = head.slice(slash + 1);
|
|
108
|
+
if (!SCOPE.test(scope) || !UNSCOPED_NAME.test(subname)) {
|
|
109
|
+
rejectWith(path, lineNumber, trimmed, 'scope or package name is malformed.');
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
kind: 'scoped-package-version',
|
|
113
|
+
scope,
|
|
114
|
+
name: head,
|
|
115
|
+
version,
|
|
116
|
+
ruleText: trimmed
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!UNSCOPED_NAME.test(head)) {
|
|
120
|
+
rejectWith(path, lineNumber, trimmed, 'package name is malformed.');
|
|
121
|
+
}
|
|
122
|
+
return { kind: 'package-version', name: head, version, ruleText: trimmed };
|
|
123
|
+
}
|
|
124
|
+
export function parseAllowedPackages(contents, path) {
|
|
125
|
+
const out = [];
|
|
126
|
+
let lineNumber = 0;
|
|
127
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
128
|
+
lineNumber++;
|
|
129
|
+
const trimmed = rawLine.trim();
|
|
130
|
+
if (trimmed.length === 0)
|
|
131
|
+
continue;
|
|
132
|
+
if (trimmed.startsWith('#'))
|
|
133
|
+
continue;
|
|
134
|
+
out.push(parseRule(path, lineNumber, trimmed));
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy evaluator — pure: maps records + allowlists → decisions.
|
|
3
|
+
*
|
|
4
|
+
* Per design D5/D5a + add-package-wildcard-overrides:
|
|
5
|
+
* 1. license == "could not determine":
|
|
6
|
+
* override matches → allowed-by-{scope|package-version|package-name}-rule
|
|
7
|
+
* else → violation { reason: "package-not-in-allowlist" }
|
|
8
|
+
* 2. literal license string in allowed-hard.txt:
|
|
9
|
+
* → allowed-by-license (override is ignored: license-allow wins)
|
|
10
|
+
* 3. otherwise SPDX evaluation (with WITH-exception leaves reduced to
|
|
11
|
+
* "<id> WITH <exc>" composite literals):
|
|
12
|
+
* satisfied → allowed-by-license
|
|
13
|
+
* unsatisfied + override → allowed-by-{scope|package-version|package-name}-rule
|
|
14
|
+
* unsatisfied + no override → license-not-in-allowlist
|
|
15
|
+
* / spdx-expression-not-satisfied
|
|
16
|
+
* unparseable + override → allowed-by-{scope|package-version|package-name}-rule
|
|
17
|
+
* unparseable + no override → license-not-in-allowlist
|
|
18
|
+
* / literal-not-allowed-and-spdx-unparseable
|
|
19
|
+
*
|
|
20
|
+
* Override precedence when multiple `allowed-packages.txt` rules match the
|
|
21
|
+
* same installed package (most-specific wins, license-allow already short-
|
|
22
|
+
* circuits earlier):
|
|
23
|
+
* (a) exact `package@version` / `@scope/package@version`
|
|
24
|
+
* (b) `package@*` / `@scope/package@*`
|
|
25
|
+
* (c) `@scope/*`
|
|
26
|
+
*/
|
|
27
|
+
import { type AllowedHardList, type AllowedPackages, type Decision, type InstalledPackageRecord } from '../types.js';
|
|
28
|
+
export declare function evaluateRecord(record: InstalledPackageRecord, allowedHard: AllowedHardList, allowedPackages: AllowedPackages): Decision;
|
|
29
|
+
/** Evaluate a list of records, returning all decisions in the same order. */
|
|
30
|
+
export declare function evaluateAll(records: InstalledPackageRecord[], allowedHard: AllowedHardList, allowedPackages: AllowedPackages): Decision[];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy evaluator — pure: maps records + allowlists → decisions.
|
|
3
|
+
*
|
|
4
|
+
* Per design D5/D5a + add-package-wildcard-overrides:
|
|
5
|
+
* 1. license == "could not determine":
|
|
6
|
+
* override matches → allowed-by-{scope|package-version|package-name}-rule
|
|
7
|
+
* else → violation { reason: "package-not-in-allowlist" }
|
|
8
|
+
* 2. literal license string in allowed-hard.txt:
|
|
9
|
+
* → allowed-by-license (override is ignored: license-allow wins)
|
|
10
|
+
* 3. otherwise SPDX evaluation (with WITH-exception leaves reduced to
|
|
11
|
+
* "<id> WITH <exc>" composite literals):
|
|
12
|
+
* satisfied → allowed-by-license
|
|
13
|
+
* unsatisfied + override → allowed-by-{scope|package-version|package-name}-rule
|
|
14
|
+
* unsatisfied + no override → license-not-in-allowlist
|
|
15
|
+
* / spdx-expression-not-satisfied
|
|
16
|
+
* unparseable + override → allowed-by-{scope|package-version|package-name}-rule
|
|
17
|
+
* unparseable + no override → license-not-in-allowlist
|
|
18
|
+
* / literal-not-allowed-and-spdx-unparseable
|
|
19
|
+
*
|
|
20
|
+
* Override precedence when multiple `allowed-packages.txt` rules match the
|
|
21
|
+
* same installed package (most-specific wins, license-allow already short-
|
|
22
|
+
* circuits earlier):
|
|
23
|
+
* (a) exact `package@version` / `@scope/package@version`
|
|
24
|
+
* (b) `package@*` / `@scope/package@*`
|
|
25
|
+
* (c) `@scope/*`
|
|
26
|
+
*/
|
|
27
|
+
import { LICENSE_COULD_NOT_DETERMINE } from '../types.js';
|
|
28
|
+
import { evaluateSpdx } from './spdx-shape.js';
|
|
29
|
+
/** Find the override rule that matches a record. When several match (e.g.
|
|
30
|
+
* both `@scope/*`, `@scope/foo@*`, and `@scope/foo@1.2.3`), apply the
|
|
31
|
+
* precedence: exact version > package-name wildcard > scope wildcard. */
|
|
32
|
+
function findMatchingOverride(record, rules) {
|
|
33
|
+
let scopeMatch = null;
|
|
34
|
+
let nameMatch = null;
|
|
35
|
+
let exactMatch = null;
|
|
36
|
+
for (const rule of rules) {
|
|
37
|
+
switch (rule.kind) {
|
|
38
|
+
case 'package-version':
|
|
39
|
+
if (rule.name === record.name && rule.version === record.version) {
|
|
40
|
+
exactMatch = rule;
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
case 'scoped-package-version':
|
|
44
|
+
if (rule.name === record.name && rule.version === record.version) {
|
|
45
|
+
exactMatch = rule;
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
case 'package-name':
|
|
49
|
+
if (rule.name === record.name) {
|
|
50
|
+
nameMatch = rule;
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
case 'scoped-package-name':
|
|
54
|
+
if (rule.name === record.name) {
|
|
55
|
+
nameMatch = rule;
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
case 'scope':
|
|
59
|
+
if (record.name.startsWith(`${rule.scope}/`)) {
|
|
60
|
+
scopeMatch = rule;
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
default: {
|
|
64
|
+
const _never = rule;
|
|
65
|
+
throw new Error(`unreachable rule: ${JSON.stringify(_never)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return exactMatch ?? nameMatch ?? scopeMatch;
|
|
70
|
+
}
|
|
71
|
+
function overrideToDecision(record, rule) {
|
|
72
|
+
switch (rule.kind) {
|
|
73
|
+
case 'scope':
|
|
74
|
+
return {
|
|
75
|
+
record,
|
|
76
|
+
outcome: 'allowed-by-scope-rule',
|
|
77
|
+
matchedPackageRule: rule.ruleText
|
|
78
|
+
};
|
|
79
|
+
case 'package-version':
|
|
80
|
+
case 'scoped-package-version':
|
|
81
|
+
return {
|
|
82
|
+
record,
|
|
83
|
+
outcome: 'allowed-by-package-version-rule',
|
|
84
|
+
matchedPackageRule: rule.ruleText
|
|
85
|
+
};
|
|
86
|
+
case 'package-name':
|
|
87
|
+
case 'scoped-package-name':
|
|
88
|
+
return {
|
|
89
|
+
record,
|
|
90
|
+
outcome: 'allowed-by-package-name-rule',
|
|
91
|
+
matchedPackageRule: rule.ruleText
|
|
92
|
+
};
|
|
93
|
+
default: {
|
|
94
|
+
const _never = rule;
|
|
95
|
+
throw new Error(`unreachable rule: ${JSON.stringify(_never)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function evaluateRecord(record, allowedHard, allowedPackages) {
|
|
100
|
+
// Step 1 — could not determine.
|
|
101
|
+
if (record.license === LICENSE_COULD_NOT_DETERMINE) {
|
|
102
|
+
const override = findMatchingOverride(record, allowedPackages);
|
|
103
|
+
if (override)
|
|
104
|
+
return overrideToDecision(record, override);
|
|
105
|
+
return {
|
|
106
|
+
record,
|
|
107
|
+
outcome: 'violation',
|
|
108
|
+
reason: { kind: 'package-not-in-allowlist' }
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const licenseString = record.license;
|
|
112
|
+
// Step 2 — literal full-string match wins, ignores any override.
|
|
113
|
+
if (allowedHard.has(licenseString)) {
|
|
114
|
+
return { record, outcome: 'allowed-by-license' };
|
|
115
|
+
}
|
|
116
|
+
// Step 3 — SPDX evaluation.
|
|
117
|
+
const spdx = evaluateSpdx(licenseString, allowedHard);
|
|
118
|
+
if (spdx.kind === 'satisfied') {
|
|
119
|
+
return { record, outcome: 'allowed-by-license' };
|
|
120
|
+
}
|
|
121
|
+
// Both unsatisfied and unparseable can be rescued by an override.
|
|
122
|
+
const override = findMatchingOverride(record, allowedPackages);
|
|
123
|
+
if (override)
|
|
124
|
+
return overrideToDecision(record, override);
|
|
125
|
+
if (spdx.kind === 'unparseable') {
|
|
126
|
+
return {
|
|
127
|
+
record,
|
|
128
|
+
outcome: 'violation',
|
|
129
|
+
reason: {
|
|
130
|
+
kind: 'license-not-in-allowlist',
|
|
131
|
+
raw: licenseString,
|
|
132
|
+
detailCode: 'literal-not-allowed-and-spdx-unparseable'
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// spdx.kind === 'unsatisfied'
|
|
137
|
+
return {
|
|
138
|
+
record,
|
|
139
|
+
outcome: 'violation',
|
|
140
|
+
reason: {
|
|
141
|
+
kind: 'license-not-in-allowlist',
|
|
142
|
+
raw: licenseString,
|
|
143
|
+
detailCode: 'spdx-expression-not-satisfied',
|
|
144
|
+
offendingLeaves: spdx.offendingLeaves
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** Evaluate a list of records, returning all decisions in the same order. */
|
|
149
|
+
export function evaluateAll(records, allowedHard, allowedPackages) {
|
|
150
|
+
return records.map((r) => evaluateRecord(r, allowedHard, allowedPackages));
|
|
151
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX expression evaluation — boolean shape only.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `spdx-expression-parse`. We never use `spdx-correct`, never use
|
|
5
|
+
* `spdx-satisfies`, never normalise license ids or exception ids.
|
|
6
|
+
*
|
|
7
|
+
* AST shapes returned by `spdx-expression-parse`:
|
|
8
|
+
* { license: "MIT" } // bare leaf
|
|
9
|
+
* { license: "GPL-2.0-only", exception: "Classpath-..." } // WITH-leaf
|
|
10
|
+
* { left: <node>, conjunction: "or"|"and", right: <node> } // boolean
|
|
11
|
+
*
|
|
12
|
+
* Per design D5/D5a, a WITH-exception leaf reduces to ONE literal string of
|
|
13
|
+
* the form "<license-id> WITH <exception-id>" joined with a single ASCII
|
|
14
|
+
* space, and that composite is the literal compared against allowed-hard.txt.
|
|
15
|
+
* The bare license id is NOT considered separately.
|
|
16
|
+
*/
|
|
17
|
+
import type { AllowedHardList } from '../types.js';
|
|
18
|
+
type LeafNode = {
|
|
19
|
+
license: string;
|
|
20
|
+
exception?: string;
|
|
21
|
+
};
|
|
22
|
+
/** Render an AST leaf back to the literal string used for allowlist lookup. */
|
|
23
|
+
export declare function renderLeafLiteral(leaf: LeafNode): string;
|
|
24
|
+
export type SpdxOutcome = {
|
|
25
|
+
kind: 'unparseable';
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'satisfied';
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'unsatisfied';
|
|
30
|
+
offendingLeaves: string[];
|
|
31
|
+
};
|
|
32
|
+
/** Evaluate a license string as an SPDX expression against an allowlist. */
|
|
33
|
+
export declare function evaluateSpdx(licenseString: string, allowed: AllowedHardList): SpdxOutcome;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX expression evaluation — boolean shape only.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `spdx-expression-parse`. We never use `spdx-correct`, never use
|
|
5
|
+
* `spdx-satisfies`, never normalise license ids or exception ids.
|
|
6
|
+
*
|
|
7
|
+
* AST shapes returned by `spdx-expression-parse`:
|
|
8
|
+
* { license: "MIT" } // bare leaf
|
|
9
|
+
* { license: "GPL-2.0-only", exception: "Classpath-..." } // WITH-leaf
|
|
10
|
+
* { left: <node>, conjunction: "or"|"and", right: <node> } // boolean
|
|
11
|
+
*
|
|
12
|
+
* Per design D5/D5a, a WITH-exception leaf reduces to ONE literal string of
|
|
13
|
+
* the form "<license-id> WITH <exception-id>" joined with a single ASCII
|
|
14
|
+
* space, and that composite is the literal compared against allowed-hard.txt.
|
|
15
|
+
* The bare license id is NOT considered separately.
|
|
16
|
+
*/
|
|
17
|
+
import spdxParse from 'spdx-expression-parse';
|
|
18
|
+
/** Render an AST leaf back to the literal string used for allowlist lookup. */
|
|
19
|
+
export function renderLeafLiteral(leaf) {
|
|
20
|
+
if (typeof leaf.exception === 'string' && leaf.exception.length > 0) {
|
|
21
|
+
return `${leaf.license} WITH ${leaf.exception}`;
|
|
22
|
+
}
|
|
23
|
+
return leaf.license;
|
|
24
|
+
}
|
|
25
|
+
function isLeaf(node) {
|
|
26
|
+
return typeof node.license === 'string';
|
|
27
|
+
}
|
|
28
|
+
/** Walk an AST; satisfied iff every leaf reachable through the boolean
|
|
29
|
+
* semantics is in the allowlist. */
|
|
30
|
+
function walk(node, allowed, collected) {
|
|
31
|
+
if (isLeaf(node)) {
|
|
32
|
+
const literal = renderLeafLiteral(node);
|
|
33
|
+
if (allowed.has(literal))
|
|
34
|
+
return true;
|
|
35
|
+
collected.add(literal);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const leftCollected = new Set();
|
|
39
|
+
const rightCollected = new Set();
|
|
40
|
+
const leftOk = walk(node.left, allowed, leftCollected);
|
|
41
|
+
const rightOk = walk(node.right, allowed, rightCollected);
|
|
42
|
+
if (node.conjunction === 'and') {
|
|
43
|
+
// Both must satisfy. Bubble up offending leaves from any side that
|
|
44
|
+
// failed (only meaningful when AND is unsatisfied).
|
|
45
|
+
if (leftOk && rightOk)
|
|
46
|
+
return true;
|
|
47
|
+
for (const l of leftCollected)
|
|
48
|
+
collected.add(l);
|
|
49
|
+
for (const l of rightCollected)
|
|
50
|
+
collected.add(l);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
// OR: either side satisfying is enough.
|
|
54
|
+
if (leftOk || rightOk)
|
|
55
|
+
return true;
|
|
56
|
+
for (const l of leftCollected)
|
|
57
|
+
collected.add(l);
|
|
58
|
+
for (const l of rightCollected)
|
|
59
|
+
collected.add(l);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/** Evaluate a license string as an SPDX expression against an allowlist. */
|
|
63
|
+
export function evaluateSpdx(licenseString, allowed) {
|
|
64
|
+
let ast;
|
|
65
|
+
try {
|
|
66
|
+
ast = spdxParse(licenseString);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { kind: 'unparseable' };
|
|
70
|
+
}
|
|
71
|
+
const offending = new Set();
|
|
72
|
+
const ok = walk(ast, allowed, offending);
|
|
73
|
+
if (ok)
|
|
74
|
+
return { kind: 'satisfied' };
|
|
75
|
+
return { kind: 'unsatisfied', offendingLeaves: Array.from(offending) };
|
|
76
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable reporters for `check` and `collect`.
|
|
3
|
+
* Pure functions returning strings; the command layer prints/writes them.
|
|
4
|
+
*/
|
|
5
|
+
import type { CollectedRecord, Decision, InstalledPackageRecord } from '../types.js';
|
|
6
|
+
/** Render the `check` result as readable text. */
|
|
7
|
+
export declare function renderCheckHuman(input: {
|
|
8
|
+
decisions: Decision[];
|
|
9
|
+
skippedProjectRoot: InstalledPackageRecord | null;
|
|
10
|
+
}): string;
|
|
11
|
+
/** Render the `collect` result as readable text. */
|
|
12
|
+
export declare function renderCollectHuman(records: CollectedRecord[]): string;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable reporters for `check` and `collect`.
|
|
3
|
+
* Pure functions returning strings; the command layer prints/writes them.
|
|
4
|
+
*/
|
|
5
|
+
function fmtViolationReason(reason) {
|
|
6
|
+
switch (reason.kind) {
|
|
7
|
+
case 'package-not-in-allowlist':
|
|
8
|
+
return 'package-not-in-allowlist (no usable package.json#license string and no allowed-packages.txt rule matched)';
|
|
9
|
+
case 'license-not-in-allowlist': {
|
|
10
|
+
const head = `license-not-in-allowlist (raw: "${reason.raw}")`;
|
|
11
|
+
if (reason.detailCode === 'literal-not-allowed-and-spdx-unparseable') {
|
|
12
|
+
return `${head} — literal not allowed; SPDX expression unparseable`;
|
|
13
|
+
}
|
|
14
|
+
if (reason.detailCode === 'spdx-expression-not-satisfied') {
|
|
15
|
+
const leaves = reason.offendingLeaves?.join(', ') ?? '';
|
|
16
|
+
return `${head} — SPDX expression parsed but not satisfied; offending leaves: [${leaves}]`;
|
|
17
|
+
}
|
|
18
|
+
return head;
|
|
19
|
+
}
|
|
20
|
+
default: {
|
|
21
|
+
// Exhaustiveness check.
|
|
22
|
+
const _never = reason;
|
|
23
|
+
return `unknown violation (${JSON.stringify(_never)})`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Render the `check` result as readable text. */
|
|
28
|
+
export function renderCheckHuman(input) {
|
|
29
|
+
const { decisions, skippedProjectRoot } = input;
|
|
30
|
+
const lines = [];
|
|
31
|
+
const allowedByLicense = [];
|
|
32
|
+
const allowedByScope = [];
|
|
33
|
+
const allowedByPackageVersion = [];
|
|
34
|
+
const allowedByPackageName = [];
|
|
35
|
+
const violations = [];
|
|
36
|
+
for (const d of decisions) {
|
|
37
|
+
switch (d.outcome) {
|
|
38
|
+
case 'allowed-by-license':
|
|
39
|
+
allowedByLicense.push(d);
|
|
40
|
+
break;
|
|
41
|
+
case 'allowed-by-scope-rule':
|
|
42
|
+
allowedByScope.push(d);
|
|
43
|
+
break;
|
|
44
|
+
case 'allowed-by-package-version-rule':
|
|
45
|
+
allowedByPackageVersion.push(d);
|
|
46
|
+
break;
|
|
47
|
+
case 'allowed-by-package-name-rule':
|
|
48
|
+
allowedByPackageName.push(d);
|
|
49
|
+
break;
|
|
50
|
+
case 'violation':
|
|
51
|
+
violations.push(d);
|
|
52
|
+
break;
|
|
53
|
+
default: {
|
|
54
|
+
const _never = d;
|
|
55
|
+
throw new Error(`unreachable decision: ${JSON.stringify(_never)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
lines.push('license-gate check');
|
|
60
|
+
if (skippedProjectRoot) {
|
|
61
|
+
lines.push(` (skipped: project root) ${skippedProjectRoot.packageId} [${skippedProjectRoot.path}]`);
|
|
62
|
+
}
|
|
63
|
+
lines.push(` evaluated: ${decisions.length}`);
|
|
64
|
+
lines.push(` allowed by license: ${allowedByLicense.length}`);
|
|
65
|
+
lines.push(` allowed by scope rule: ${allowedByScope.length}`);
|
|
66
|
+
lines.push(` allowed by package@version: ${allowedByPackageVersion.length}`);
|
|
67
|
+
lines.push(` allowed by package@*: ${allowedByPackageName.length}`);
|
|
68
|
+
lines.push(` violations: ${violations.length}`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
if (allowedByScope.length > 0 ||
|
|
71
|
+
allowedByPackageVersion.length > 0 ||
|
|
72
|
+
allowedByPackageName.length > 0) {
|
|
73
|
+
lines.push('Package overrides applied:');
|
|
74
|
+
for (const d of [...allowedByPackageVersion, ...allowedByPackageName, ...allowedByScope]) {
|
|
75
|
+
if (d.outcome !== 'allowed-by-scope-rule' &&
|
|
76
|
+
d.outcome !== 'allowed-by-package-version-rule' &&
|
|
77
|
+
d.outcome !== 'allowed-by-package-name-rule')
|
|
78
|
+
continue;
|
|
79
|
+
lines.push(` ✓ ${d.record.packageId} — ${d.matchedPackageRule}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push('');
|
|
82
|
+
}
|
|
83
|
+
if (violations.length > 0) {
|
|
84
|
+
lines.push('Violations:');
|
|
85
|
+
for (const v of violations) {
|
|
86
|
+
lines.push(` ✗ ${v.record.packageId}`);
|
|
87
|
+
lines.push(` ${fmtViolationReason(v.reason)}`);
|
|
88
|
+
lines.push(` path: ${v.record.path}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push('FAILED: at least one license policy violation.');
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
lines.push('PASSED: no license policy violations.');
|
|
95
|
+
}
|
|
96
|
+
return lines.join('\n') + '\n';
|
|
97
|
+
}
|
|
98
|
+
/** Render the `collect` result as readable text. */
|
|
99
|
+
export function renderCollectHuman(records) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
lines.push('license-gate collect');
|
|
102
|
+
lines.push(` records: ${records.length}`);
|
|
103
|
+
lines.push('');
|
|
104
|
+
for (const r of records) {
|
|
105
|
+
const tag = r.isProjectRoot ? ' (project root)' : '';
|
|
106
|
+
lines.push(` ${r.packageId}${tag}`);
|
|
107
|
+
lines.push(` license: ${r.license}`);
|
|
108
|
+
lines.push(` path: ${r.path}`);
|
|
109
|
+
if (r.repository)
|
|
110
|
+
lines.push(` repo: ${r.repository}`);
|
|
111
|
+
if (r.publisher || r.email) {
|
|
112
|
+
const author = [r.publisher, r.email ? `<${r.email}>` : null].filter(Boolean).join(' ');
|
|
113
|
+
lines.push(` author: ${author}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return lines.join('\n') + '\n';
|
|
117
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON reporters for `check` and `collect`. Stable, machine-readable.
|
|
3
|
+
*
|
|
4
|
+
* The shape is intentionally explicit so consumers can rely on it across
|
|
5
|
+
* v1.x without surprises.
|
|
6
|
+
*/
|
|
7
|
+
import type { CollectedRecord, Decision, InstalledPackageRecord } from '../types.js';
|
|
8
|
+
export type CheckJsonReport = {
|
|
9
|
+
skippedProjectRoot: InstalledPackageRecord | null;
|
|
10
|
+
counts: {
|
|
11
|
+
evaluated: number;
|
|
12
|
+
allowedByLicense: number;
|
|
13
|
+
allowedByScopeRule: number;
|
|
14
|
+
allowedByPackageVersionRule: number;
|
|
15
|
+
allowedByPackageNameRule: number;
|
|
16
|
+
violations: number;
|
|
17
|
+
};
|
|
18
|
+
decisions: Decision[];
|
|
19
|
+
};
|
|
20
|
+
export declare function renderCheckJson(input: {
|
|
21
|
+
decisions: Decision[];
|
|
22
|
+
skippedProjectRoot: InstalledPackageRecord | null;
|
|
23
|
+
}): string;
|
|
24
|
+
export type CollectJsonReport = {
|
|
25
|
+
records: CollectedRecord[];
|
|
26
|
+
};
|
|
27
|
+
export declare function renderCollectJson(records: CollectedRecord[]): string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON reporters for `check` and `collect`. Stable, machine-readable.
|
|
3
|
+
*
|
|
4
|
+
* The shape is intentionally explicit so consumers can rely on it across
|
|
5
|
+
* v1.x without surprises.
|
|
6
|
+
*/
|
|
7
|
+
export function renderCheckJson(input) {
|
|
8
|
+
let allowedByLicense = 0;
|
|
9
|
+
let allowedByScopeRule = 0;
|
|
10
|
+
let allowedByPackageVersionRule = 0;
|
|
11
|
+
let allowedByPackageNameRule = 0;
|
|
12
|
+
let violations = 0;
|
|
13
|
+
for (const d of input.decisions) {
|
|
14
|
+
switch (d.outcome) {
|
|
15
|
+
case 'allowed-by-license':
|
|
16
|
+
allowedByLicense++;
|
|
17
|
+
break;
|
|
18
|
+
case 'allowed-by-scope-rule':
|
|
19
|
+
allowedByScopeRule++;
|
|
20
|
+
break;
|
|
21
|
+
case 'allowed-by-package-version-rule':
|
|
22
|
+
allowedByPackageVersionRule++;
|
|
23
|
+
break;
|
|
24
|
+
case 'allowed-by-package-name-rule':
|
|
25
|
+
allowedByPackageNameRule++;
|
|
26
|
+
break;
|
|
27
|
+
case 'violation':
|
|
28
|
+
violations++;
|
|
29
|
+
break;
|
|
30
|
+
default: {
|
|
31
|
+
const _never = d;
|
|
32
|
+
throw new Error(`unreachable decision: ${JSON.stringify(_never)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const report = {
|
|
37
|
+
skippedProjectRoot: input.skippedProjectRoot,
|
|
38
|
+
counts: {
|
|
39
|
+
evaluated: input.decisions.length,
|
|
40
|
+
allowedByLicense,
|
|
41
|
+
allowedByScopeRule,
|
|
42
|
+
allowedByPackageVersionRule,
|
|
43
|
+
allowedByPackageNameRule,
|
|
44
|
+
violations
|
|
45
|
+
},
|
|
46
|
+
decisions: input.decisions
|
|
47
|
+
};
|
|
48
|
+
return JSON.stringify(report, null, 2) + '\n';
|
|
49
|
+
}
|
|
50
|
+
export function renderCollectJson(records) {
|
|
51
|
+
const report = { records };
|
|
52
|
+
return JSON.stringify(report, null, 2) + '\n';
|
|
53
|
+
}
|