periapsis 0.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/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # Periapsis (draft)
2
+
3
+ License compliance radar for Node dependencies. Reads `package-lock.json` + installed `node_modules`, emits a full SBOM JSON, and fails CI when licenses violate your allowlist. Supports exceptions and traces upstream chains for each violation.
4
+
5
+ ## Install / Run
6
+
7
+ Local clone:
8
+
9
+ ```sh
10
+ npm install # only needed if you add dependencies later; currently none
11
+ npx periapsis --allowed ../allowed-licenses.example.json --violations-out ../sbom-violations.json
12
+ ```
13
+
14
+ When published to npm (recommended):
15
+
16
+ ```sh
17
+ npx periapsis --allowed .sbom-allowlist.json --violations-out sbom-violations.json
18
+ ```
19
+
20
+ Initialize an allowlist based on SPDX categories:
21
+
22
+ ```sh
23
+ npx periapsis init --preset strict
24
+ ```
25
+
26
+ ## Options
27
+
28
+ - `--root <path>`: project root (defaults to `cwd`)
29
+ - `--lock <file>`: lockfile (default `package-lock.json`)
30
+ - `--out <file>`: SBOM JSON output (default `sbom-licenses.json`)
31
+ - `--allowed <file>`: allowlist JSON
32
+ - `--violations-out <file>`: write violations JSON (includes upstream chains)
33
+ - `--quiet`: suppress summary output
34
+ - `init --preset <level>`: write `allowedConfig.json` (strict | standard | permissive)
35
+ - `init --force`: overwrite existing `allowedConfig.json`
36
+
37
+ Allowlist file schema:
38
+
39
+ ```json
40
+ {
41
+ "allowedCategories": ["A", "B"],
42
+ "allowedLicenses": ["MIT", "Apache-2.0"],
43
+ "exceptions": ["package-name", "name@1.2.3"]
44
+ }
45
+ ```
46
+
47
+ ## GitHub Action (example)
48
+
49
+ ```yaml
50
+ - uses: actions/checkout@v4
51
+ - uses: actions/setup-node@v4
52
+ with:
53
+ node-version: 20
54
+ cache: npm
55
+ - run: npm ci
56
+ - run: npx periapsis --allowed .sbom-allowlist.json --violations-out sbom-violations.json
57
+ - run: |
58
+ node -e "const fs=require('fs');const p='sbom-violations.json';if(!fs.existsSync(p))process.exit(1);const d=JSON.parse(fs.readFileSync(p,'utf8'));const c=Array.isArray(d)?d.length:Array.isArray(d.violations)?d.violations.length:0;if(c>0){console.error(`Found ${c} license violations`);process.exit(1);}console.log('No license violations');"
59
+ ```
60
+
61
+ ## Publish checklist
62
+
63
+ - Update `package.json` name/version, remove `"private": true` when ready.
64
+ - Add CI (lint/test) and changelog.
65
+ - Tag a release and publish to npm: `npm publish`.
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ function parseArgs(argv) {
10
+ const args = {};
11
+ let i = 0;
12
+ if (argv[0] && !argv[0].startsWith('-')) {
13
+ args.command = argv[0];
14
+ i = 1;
15
+ }
16
+ for (; i < argv.length; i++) {
17
+ const arg = argv[i];
18
+ if (arg === '--help' || arg === '-h') {
19
+ args.help = true;
20
+ } else if (arg.startsWith('--')) {
21
+ const key = arg.slice(2);
22
+ const next = argv[i + 1];
23
+ if (next && !next.startsWith('--')) {
24
+ args[key] = next;
25
+ i++;
26
+ } else {
27
+ args[key] = true;
28
+ }
29
+ }
30
+ }
31
+ return args;
32
+ }
33
+
34
+ function printHelp() {
35
+ console.log(`Usage: periapsis [options]
36
+ periapsis init [options]
37
+
38
+ Options:
39
+ --root <path> Project root (default: cwd)
40
+ --lock <file> Lockfile to read (default: package-lock.json)
41
+ --out <file> SBOM JSON output (default: sbom-licenses.json)
42
+ --allowed <file> JSON file containing allowed licenses and optional exceptions
43
+ --violations-out <file> Where to write violating packages (optional)
44
+ --quiet Suppress summary output
45
+ -h, --help Show this help
46
+
47
+ Init options:
48
+ --preset <level> strict | standard | permissive (default: strict)
49
+ --force Overwrite existing allowedConfig.json
50
+
51
+ Allowed licenses file:
52
+ Can be an array of strings or an object like:
53
+ {
54
+ "allowedCategories": ["A", "B"],
55
+ "allowedLicenses": ["MIT", "Apache-2.0"],
56
+ "exceptions": ["package-name", "other@1.2.3"]
57
+ }
58
+ `);
59
+ }
60
+
61
+ function loadJson(filePath, description) {
62
+ try {
63
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
64
+ } catch (err) {
65
+ console.error(`Failed to read ${description} at ${filePath}: ${err.message}`);
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ function loadAllowedConfig(filePath) {
71
+ const raw = loadJson(filePath, 'allowed licenses file');
72
+ const list = Array.isArray(raw) ? raw : raw?.allowedLicenses || [];
73
+ if (!Array.isArray(list)) {
74
+ console.error(
75
+ 'Allowed licenses file must be an array or contain { "allowedLicenses": [] }'
76
+ );
77
+ process.exit(1);
78
+ }
79
+ const allowedCategoriesRaw = Array.isArray(raw?.allowedCategories)
80
+ ? raw.allowedCategories
81
+ : [];
82
+ if (raw?.allowedCategories && !Array.isArray(raw.allowedCategories)) {
83
+ console.error('allowedCategories must be an array like ["A", "B"]');
84
+ process.exit(1);
85
+ }
86
+ const allowedLicenses = new Set(list.map((l) => String(l).trim()).filter(Boolean));
87
+ const allowedCategories = new Set(
88
+ allowedCategoriesRaw.map((c) => String(c).trim().toUpperCase()).filter(Boolean)
89
+ );
90
+ const exceptions = new Set(
91
+ (raw?.exceptions || []).map((e) => String(e).trim()).filter(Boolean)
92
+ );
93
+ return { allowedLicenses, allowedCategories, exceptions };
94
+ }
95
+
96
+ function extractLicense(pkgJson) {
97
+ if (!pkgJson) return null;
98
+ const lic = pkgJson.license || pkgJson.licenses;
99
+ if (!lic) return null;
100
+ if (typeof lic === 'string') return lic;
101
+ if (Array.isArray(lic)) {
102
+ return lic
103
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.type || entry?.name))
104
+ .filter(Boolean)
105
+ .join(' OR ');
106
+ }
107
+ if (typeof lic === 'object') return lic.type || lic.name || null;
108
+ return null;
109
+ }
110
+
111
+ function licenseTokens(license) {
112
+ if (!license) return ['UNKNOWN'];
113
+ return license
114
+ .split(/\s*(?:\(|\)|\+|\/|\s+OR\s+|\s+AND\s+|,|;|\||\s+with\s+)/i)
115
+ .map((t) => t.trim())
116
+ .filter(Boolean);
117
+ }
118
+
119
+ function loadSpdxCategories() {
120
+ const spdxPath = path.resolve(__dirname, '..', 'spdx_licenses_with_categories.json');
121
+ if (!fs.existsSync(spdxPath)) {
122
+ return new Map();
123
+ }
124
+ const list = loadJson(spdxPath, 'SPDX licenses with categories');
125
+ const map = new Map();
126
+ if (Array.isArray(list)) {
127
+ for (const entry of list) {
128
+ if (!entry) continue;
129
+ const id = entry.identifier ? String(entry.identifier).trim() : '';
130
+ const category = entry.defaultCategory ? String(entry.defaultCategory).trim() : '';
131
+ if (id && category) map.set(id, category.toUpperCase());
132
+ }
133
+ }
134
+ return map;
135
+ }
136
+
137
+ function resolveDepPath(packages, parentKey, depName) {
138
+ const nested = parentKey ? path.posix.join(parentKey, 'node_modules', depName) : `node_modules/${depName}`;
139
+ if (packages[nested]) return nested;
140
+ const top = `node_modules/${depName}`;
141
+ if (packages[top]) return top;
142
+ return null;
143
+ }
144
+
145
+ function buildSbom({ root, lockPath }) {
146
+ if (!fs.existsSync(lockPath)) {
147
+ console.error(`Lockfile not found at ${lockPath}`);
148
+ process.exit(1);
149
+ }
150
+ const lock = loadJson(lockPath, 'lockfile');
151
+ const packages = lock.packages || {};
152
+ const seen = new Set();
153
+ const results = [];
154
+ const pathMap = new Map();
155
+ const reverseDeps = new Map(); // childPath -> Set<parentPath>
156
+
157
+ function addReverse(child, parent) {
158
+ if (!reverseDeps.has(child)) reverseDeps.set(child, new Set());
159
+ reverseDeps.get(child).add(parent);
160
+ }
161
+
162
+ // Build SBOM entries and reverse dependency edges
163
+ for (const [key, meta] of Object.entries(packages)) {
164
+ if (key === '' || !key.startsWith('node_modules')) continue;
165
+ const absPath = path.join(root, key);
166
+ const pkgJsonPath = path.join(absPath, 'package.json');
167
+ let pkgJson = null;
168
+ if (fs.existsSync(pkgJsonPath)) {
169
+ try {
170
+ pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
171
+ } catch {
172
+ // ignore parse errors, fallback to lock data
173
+ }
174
+ }
175
+ const name =
176
+ meta.name ||
177
+ pkgJson?.name ||
178
+ key.replace('node_modules/', '').split('node_modules/').pop();
179
+ const version = meta.version || pkgJson?.version || 'UNKNOWN';
180
+ const id = `${name}@${version}`;
181
+ if (seen.has(id)) continue;
182
+ seen.add(id);
183
+
184
+ const license = extractLicense(pkgJson) || meta.license || 'UNKNOWN';
185
+ const repository = pkgJson?.repository || null;
186
+ const entry = { name, version, license, path: key, repository };
187
+ results.push(entry);
188
+ pathMap.set(key, entry);
189
+
190
+ const deps = meta.dependencies ? Object.keys(meta.dependencies) : [];
191
+ for (const depName of deps) {
192
+ const depPath = resolveDepPath(packages, key, depName);
193
+ if (depPath) addReverse(depPath, key);
194
+ }
195
+ }
196
+
197
+ results.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
198
+ // Add root-level edges
199
+ const rootDeps = packages['']?.dependencies ? Object.keys(packages['']?.dependencies) : [];
200
+ for (const depName of rootDeps) {
201
+ const depPath = resolveDepPath(packages, '', depName);
202
+ if (depPath) addReverse(depPath, '__root__');
203
+ }
204
+
205
+ return { sbom: results, pathMap, reverseDeps };
206
+ }
207
+
208
+ function isLicenseAllowed(token, allowedConfig, spdxCategories) {
209
+ if (!allowedConfig) return true;
210
+ const { allowedLicenses, allowedCategories } = allowedConfig;
211
+ if (allowedLicenses.has(token)) return true;
212
+ if (allowedCategories.size === 0) return false;
213
+ const category = spdxCategories.get(token);
214
+ if (!category) return false;
215
+ return allowedCategories.has(category);
216
+ }
217
+
218
+ function evaluateCompliance(sbom, allowedConfig, spdxCategories) {
219
+ if (!allowedConfig) return { violations: [] };
220
+ if (
221
+ allowedConfig.allowedLicenses.size === 0 &&
222
+ allowedConfig.allowedCategories.size === 0
223
+ ) {
224
+ return { violations: [] };
225
+ }
226
+ const { exceptions } = allowedConfig;
227
+ const violations = [];
228
+ for (const item of sbom) {
229
+ const id = `${item.name}@${item.version}`;
230
+ if (exceptions.has(item.name) || exceptions.has(id)) continue;
231
+ const tokens = licenseTokens(item.license);
232
+ const allowed = tokens.some((t) => isLicenseAllowed(t, allowedConfig, spdxCategories));
233
+ if (!allowed) violations.push(item);
234
+ }
235
+ return { violations };
236
+ }
237
+
238
+ function getUpstreamChains(targetPath, reverseDeps, pathMap, { limit = 30 } = {}) {
239
+ const memo = new Map();
240
+ function dfs(node, trail = []) {
241
+ if (memo.has(node)) return memo.get(node);
242
+ const parents = reverseDeps.get(node);
243
+ if (!parents || parents.size === 0) return [[node]];
244
+ const chains = [];
245
+ for (const parent of parents) {
246
+ if (trail.includes(parent)) continue; // avoid cycles
247
+ const parentChains = dfs(parent, [...trail, node]);
248
+ for (const chain of parentChains) {
249
+ chains.push([...chain, node]);
250
+ if (chains.length >= limit) break;
251
+ }
252
+ if (chains.length >= limit) break;
253
+ }
254
+ memo.set(node, chains);
255
+ return chains;
256
+ }
257
+ const rawChains = dfs(targetPath);
258
+ // Convert to labels and strip root marker
259
+ const toLabel = (p) => {
260
+ if (p === '__root__') return null;
261
+ const entry = pathMap.get(p);
262
+ return entry ? `${entry.name}@${entry.version}` : p;
263
+ };
264
+ return rawChains
265
+ .map((chain) => chain.map(toLabel).filter(Boolean))
266
+ .filter((chain) => chain.length > 0);
267
+ }
268
+
269
+ function writeJson(filePath, data) {
270
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
271
+ }
272
+
273
+ function summarize(sbom, violations) {
274
+ const counts = new Map();
275
+ for (const { license } of sbom) {
276
+ const key = license || 'UNKNOWN';
277
+ counts.set(key, (counts.get(key) || 0) + 1);
278
+ }
279
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
280
+ console.log(`Total packages: ${sbom.length}`);
281
+ console.log('Top licenses:');
282
+ for (const [lic, count] of sorted.slice(0, 8)) {
283
+ console.log(` ${lic}: ${count}`);
284
+ }
285
+ if (violations.length === 0) {
286
+ console.log('All packages comply with allowed licenses.');
287
+ return;
288
+ }
289
+ console.log(`Violations (${violations.length}):`);
290
+ const rows = violations.map((v) => [`${v.name}@${v.version}`, v.license || 'UNKNOWN']);
291
+ const colWidths = [0, 0];
292
+ for (const [pkg, lic] of rows) {
293
+ colWidths[0] = Math.max(colWidths[0], pkg.length);
294
+ colWidths[1] = Math.max(colWidths[1], lic.length);
295
+ }
296
+ const divider = '-'.repeat(colWidths[0] + colWidths[1] + 5);
297
+ console.log(divider);
298
+ console.log(
299
+ `${'Package'.padEnd(colWidths[0])} | ${'License'.padEnd(colWidths[1])}`
300
+ );
301
+ console.log(divider);
302
+ for (const [pkg, lic] of rows) {
303
+ console.log(`${pkg.padEnd(colWidths[0])} | ${lic.padEnd(colWidths[1])}`);
304
+ }
305
+ console.log(divider);
306
+ }
307
+
308
+ function resolveAllowedConfigPath(root, allowedArg) {
309
+ if (allowedArg) {
310
+ return path.resolve(allowedArg.startsWith('.') ? path.join(root, allowedArg) : allowedArg);
311
+ }
312
+ return path.resolve(root, 'allowedConfig.json');
313
+ }
314
+
315
+ function initAllowedConfig({ root, allowedPath, preset, force }) {
316
+ const categories =
317
+ preset === 'standard'
318
+ ? ['A', 'B']
319
+ : preset === 'permissive'
320
+ ? ['A', 'B', 'C']
321
+ : ['A'];
322
+ if (fs.existsSync(allowedPath) && !force) {
323
+ console.error(`Allowed config already exists at ${allowedPath}. Use --force to overwrite.`);
324
+ process.exit(1);
325
+ }
326
+ const payload = {
327
+ allowedCategories: categories,
328
+ allowedLicenses: [],
329
+ exceptions: []
330
+ };
331
+ writeJson(allowedPath, payload);
332
+ console.log(`Wrote allowed config to ${allowedPath}`);
333
+ }
334
+
335
+ function main() {
336
+ const args = parseArgs(process.argv.slice(2));
337
+ if (args.help) {
338
+ printHelp();
339
+ return;
340
+ }
341
+
342
+ const root = args.root ? path.resolve(args.root) : process.cwd();
343
+ if (args.command === 'init') {
344
+ const preset = args.preset ? String(args.preset).toLowerCase() : 'strict';
345
+ if (!['strict', 'standard', 'permissive'].includes(preset)) {
346
+ console.error('Invalid preset. Use strict, standard, or permissive.');
347
+ process.exit(1);
348
+ }
349
+ const allowedPath = resolveAllowedConfigPath(root, args.allowed);
350
+ initAllowedConfig({ root, allowedPath, preset, force: Boolean(args.force) });
351
+ return;
352
+ }
353
+
354
+ const lockPath = path.resolve(root, args.lock || 'package-lock.json');
355
+ const outPath = path.resolve(root, args.out || 'sbom-licenses.json');
356
+ const violationsOut = args['violations-out']
357
+ ? path.resolve(root, args['violations-out'])
358
+ : null;
359
+ const allowedPath = args.allowed
360
+ ? path.resolve(args.allowed.startsWith('.') ? path.join(root, args.allowed) : args.allowed)
361
+ : null;
362
+
363
+ const allowedConfig = allowedPath ? loadAllowedConfig(allowedPath) : null;
364
+ const spdxCategories =
365
+ allowedConfig && allowedConfig.allowedCategories.size > 0 ? loadSpdxCategories() : new Map();
366
+ const { sbom, pathMap, reverseDeps } = buildSbom({ root, lockPath });
367
+
368
+ const { violations } = evaluateCompliance(sbom, allowedConfig, spdxCategories);
369
+ // Decorate violations with upstream chains
370
+ const violationsWithUpstream = violations.map((v) => {
371
+ const upstream = getUpstreamChains(v.path, reverseDeps, pathMap, { limit: 50 });
372
+ return { ...v, upstream };
373
+ });
374
+
375
+ writeJson(outPath, sbom);
376
+ if (violationsOut) {
377
+ writeJson(violationsOut, violationsWithUpstream);
378
+ }
379
+
380
+ if (!args.quiet) {
381
+ console.log(`Wrote SBOM to ${outPath}`);
382
+ if (allowedConfig) {
383
+ if (allowedConfig.allowedCategories.size) {
384
+ console.log(
385
+ `Allowed categories: ${[...allowedConfig.allowedCategories].sort().join(', ')}`
386
+ );
387
+ }
388
+ if (allowedConfig.allowedLicenses.size) {
389
+ console.log(`Allowed licenses: ${[...allowedConfig.allowedLicenses].join(', ')}`);
390
+ }
391
+ if (allowedConfig.exceptions.size) {
392
+ console.log(
393
+ `Exceptions (${allowedConfig.exceptions.size}): ${[...allowedConfig.exceptions].join(', ')}`
394
+ );
395
+ }
396
+ }
397
+ if (violationsOut && violations) {
398
+ console.log(`Wrote violations to ${violationsOut}`);
399
+ }
400
+ if (violationsWithUpstream.length) {
401
+ summarize(sbom, violationsWithUpstream || []);
402
+ // print a brief upstream view
403
+ console.log('Upstream chains (first path per violation):');
404
+ for (const v of violationsWithUpstream) {
405
+ const chain = v.upstream?.[0] || [];
406
+ console.log(
407
+ ` ${v.name}@${v.version}: ${chain.length ? chain.join(' -> ') : 'no upstream (direct)'}`
408
+ );
409
+ }
410
+ } else {
411
+ summarize(sbom, violations || []);
412
+ }
413
+ }
414
+ }
415
+
416
+ main();
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "periapsis",
3
+ "version": "0.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "periapsis": "bin/periapsis.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "description": "Lightweight SBOM/license checker with allowlist, exceptions, and upstream chains.",
13
+ "license": "MIT"
14
+ }