cbom-tools 0.2.0 → 0.2.2
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 +17 -6
- package/algo-metadata.json +3 -3
- package/cbom-diff.js +45 -5
- package/cbom-scan.cjs +341 -174
- package/cli.cjs +69 -0
- package/package.json +25 -3
package/README.md
CHANGED
|
@@ -8,7 +8,14 @@ detect algorithm additions, removals, and metadata changes.
|
|
|
8
8
|
|
|
9
9
|
Zero external dependencies. Pure Node.js.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Try it without installing
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npx cbom-tools scan --dir ./my-project --out cbom.json
|
|
15
|
+
npx cbom-tools diff cbom-before.json cbom-after.json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Global install (optional):
|
|
12
19
|
|
|
13
20
|
```
|
|
14
21
|
npm install -g cbom-tools
|
|
@@ -19,9 +26,11 @@ npm install -g cbom-tools
|
|
|
19
26
|
### 1. Scan a project
|
|
20
27
|
|
|
21
28
|
```
|
|
22
|
-
cbom-scan --dir ./my-project --out cbom.json
|
|
29
|
+
npx cbom-tools scan --dir ./my-project --out cbom.json
|
|
23
30
|
```
|
|
24
31
|
|
|
32
|
+
`cbom-scan` is still installed as a standalone alias.
|
|
33
|
+
|
|
25
34
|
Scans:
|
|
26
35
|
- `package.json` dependencies (declarative rule table)
|
|
27
36
|
- Source files (`.js/.cjs/.mjs/.ts/.tsx/.jsx`) via regex patterns
|
|
@@ -33,14 +42,16 @@ paths + line numbers, capped at 50 per algorithm, deduplicated).
|
|
|
33
42
|
### 2. Diff two CBOMs
|
|
34
43
|
|
|
35
44
|
```
|
|
36
|
-
cbom-diff cbom-before.json cbom-after.json
|
|
45
|
+
npx cbom-tools diff cbom-before.json cbom-after.json
|
|
37
46
|
```
|
|
38
47
|
|
|
48
|
+
`cbom-diff` is still installed as a standalone alias.
|
|
49
|
+
|
|
39
50
|
Exit codes:
|
|
40
51
|
- `0` — no changes
|
|
41
|
-
- `1` — changes
|
|
42
|
-
- `2` —
|
|
43
|
-
- `3` — (reserved) quantum-vulnerable/weak algorithm detection
|
|
52
|
+
- `1` — informational changes (algorithm properties moved)
|
|
53
|
+
- `2` — warnings (algorithm removed, new classical reference), or file/parse error
|
|
54
|
+
- `3` — (reserved) quantum-vulnerable/weak algorithm detection
|
|
44
55
|
|
|
45
56
|
## Testing
|
|
46
57
|
|
package/algo-metadata.json
CHANGED
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
"X25519": {
|
|
135
135
|
"assetType": "algorithm",
|
|
136
136
|
"algorithmProperties": {
|
|
137
|
-
"primitive": "
|
|
137
|
+
"primitive": "key-agree"
|
|
138
138
|
},
|
|
139
139
|
"implementationPlatform": "c",
|
|
140
140
|
"certificationLevel": "known-answer-tested",
|
|
@@ -200,7 +200,7 @@
|
|
|
200
200
|
"ECDH": {
|
|
201
201
|
"assetType": "algorithm",
|
|
202
202
|
"algorithmProperties": {
|
|
203
|
-
"primitive": "
|
|
203
|
+
"primitive": "key-agree"
|
|
204
204
|
},
|
|
205
205
|
"implementationPlatform": "c",
|
|
206
206
|
"certificationLevel": "known-answer-tested",
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
"Keccak": {
|
|
223
223
|
"assetType": "algorithm",
|
|
224
224
|
"algorithmProperties": {
|
|
225
|
-
"primitive": "
|
|
225
|
+
"primitive": "hash"
|
|
226
226
|
},
|
|
227
227
|
"implementationPlatform": "javascript",
|
|
228
228
|
"certificationLevel": "known-answer-tested",
|
package/cbom-diff.js
CHANGED
|
@@ -92,8 +92,43 @@ const newCBOM = loadCBOM(newHash);
|
|
|
92
92
|
// ════════════════════════════
|
|
93
93
|
// 比较逻辑
|
|
94
94
|
// ════════════════════════════
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// CycloneDX `bom.version` is optional with `minimum: 1, default: 1`. An absent
|
|
96
|
+
// field therefore means "the default version", not 0 and not "no version".
|
|
97
|
+
// Coercing absence to 0 made one side omitted look like a version change
|
|
98
|
+
// (false "bumped") or a rollback (false "downgraded").
|
|
99
|
+
const BOM_VERSION_DEFAULT = 1;
|
|
100
|
+
|
|
101
|
+
function readBomVersion(bom) {
|
|
102
|
+
const raw = bom ? bom.version : undefined;
|
|
103
|
+
if (raw === undefined || raw === null) {
|
|
104
|
+
return { value: BOM_VERSION_DEFAULT, explicit: false, valid: true };
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isInteger(raw) || raw < 1) {
|
|
107
|
+
return { value: raw, explicit: true, valid: false };
|
|
108
|
+
}
|
|
109
|
+
return { value: raw, explicit: true, valid: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function compareBomVersion(older, newer) {
|
|
113
|
+
// A schema-invalid value is reported, never silently ordered.
|
|
114
|
+
if (!older.valid || !newer.valid) return 'unknown';
|
|
115
|
+
if (newer.value > older.value) return 'bumped';
|
|
116
|
+
if (newer.value < older.value) return 'downgraded';
|
|
117
|
+
return 'same';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const OLD_VERSION = readBomVersion(oldCBOM);
|
|
121
|
+
const NEW_VERSION = readBomVersion(newCBOM);
|
|
122
|
+
const OLD = OLD_VERSION.value;
|
|
123
|
+
const NEW = NEW_VERSION.value;
|
|
124
|
+
|
|
125
|
+
const versionNotes = [];
|
|
126
|
+
if (!OLD_VERSION.valid) {
|
|
127
|
+
versionNotes.push(`old: version ${JSON.stringify(OLD_VERSION.value)} is not a valid BOM version (integer >= 1)`);
|
|
128
|
+
}
|
|
129
|
+
if (!NEW_VERSION.valid) {
|
|
130
|
+
versionNotes.push(`new: version ${JSON.stringify(NEW_VERSION.value)} is not a valid BOM version (integer >= 1)`);
|
|
131
|
+
}
|
|
97
132
|
|
|
98
133
|
const diff = {
|
|
99
134
|
meta: {
|
|
@@ -101,9 +136,12 @@ const diff = {
|
|
|
101
136
|
newHash: newHash,
|
|
102
137
|
oldVersion: OLD,
|
|
103
138
|
newVersion: NEW,
|
|
139
|
+
oldVersionExplicit: OLD_VERSION.explicit,
|
|
140
|
+
newVersionExplicit: NEW_VERSION.explicit,
|
|
141
|
+
versionNotes: versionNotes,
|
|
104
142
|
date: new Date().toISOString(),
|
|
105
143
|
},
|
|
106
|
-
version:
|
|
144
|
+
version: compareBomVersion(OLD_VERSION, NEW_VERSION),
|
|
107
145
|
components: { added: [], removed: [], changed: [], unchanged: 0 },
|
|
108
146
|
dependencies: { added: [], removed: [] },
|
|
109
147
|
severity: 'none', // 'none' | 'info' | 'warning' | 'critical'
|
|
@@ -277,8 +315,10 @@ if (format === 'json') {
|
|
|
277
315
|
lines.push('');
|
|
278
316
|
lines.push(`| 属性 | 值 |`);
|
|
279
317
|
lines.push(`|------|-----|`);
|
|
280
|
-
|
|
281
|
-
|
|
318
|
+
const oldLabel = OLD_VERSION.explicit ? `${OLD}` : `${OLD} (default)`;
|
|
319
|
+
const newLabel = NEW_VERSION.explicit ? `${NEW}` : `${NEW} (default)`;
|
|
320
|
+
lines.push(`| 旧版本 | ${oldLabel} |`);
|
|
321
|
+
lines.push(`| 新版本 | ${newLabel} |`);
|
|
282
322
|
lines.push(`| 版本 | ${diff.version} |`);
|
|
283
323
|
lines.push(`| 严重性 | **${diff.severity}** (exit=${diff.exitCode}) |`);
|
|
284
324
|
lines.push('');
|
package/cbom-scan.cjs
CHANGED
|
@@ -3,215 +3,382 @@
|
|
|
3
3
|
// Copyright (c) 2026 刘天赫
|
|
4
4
|
//
|
|
5
5
|
// cbom-scan.cjs — scan a directory tree for cryptographic assets,
|
|
6
|
-
// emit CycloneDX 1.6 CBOM.
|
|
6
|
+
// emit CycloneDX 1.6 CBOM with provenance-aware merge policy.
|
|
7
7
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
|
|
13
|
+
const { HIERARCHY, lookup: algoLookup } = require('./algorithm-hierarchy.cjs');
|
|
14
|
+
|
|
13
15
|
const ALGO_META = JSON.parse(fs.readFileSync(path.join(__dirname, 'algo-metadata.json'), 'utf-8'));
|
|
14
16
|
const PKG = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'));
|
|
15
17
|
|
|
16
18
|
const NAME_ALIASES = {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
'ML-KEM-768': 'ML-KEM',
|
|
20
|
+
'ML-KEM-512': 'ML-KEM',
|
|
21
|
+
'ML-KEM-1024': 'ML-KEM',
|
|
22
|
+
'ML-DSA': 'ML-DSA/fml-dsa',
|
|
23
|
+
'ML-DSA-44': 'ML-DSA/fml-dsa',
|
|
24
|
+
'ML-DSA-65': 'ML-DSA/fml-dsa',
|
|
25
|
+
'ML-DSA-87': 'ML-DSA/fml-dsa',
|
|
26
|
+
'Keccak-256': 'Keccak',
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
function lookupMeta(name) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (ALGO_META[name]) return ALGO_META[name];
|
|
31
|
+
const alias = NAME_ALIASES[name];
|
|
32
|
+
if (alias && ALGO_META[alias]) return ALGO_META[alias];
|
|
33
|
+
return null;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// Package-level rules declare algorithm availability without
|
|
37
|
+
// source-level call sites. A component may exist with zero
|
|
38
|
+
// occurrences — this is informational, not a scan miss.
|
|
39
|
+
// See Issue #55 for the granularity decision.
|
|
34
40
|
const DEFAULT_RULES = {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
],
|
|
41
|
+
packages: {
|
|
42
|
+
// Multi-parameter packages → family-level inference per José Q2 ruling
|
|
43
|
+
'@noble/post-quantum': ['ML-DSA', 'ML-KEM', 'SLH-DSA'],
|
|
44
|
+
// @noble/hashes is multi-family — conservative: single-family inference
|
|
45
|
+
'@noble/hashes': ['Keccak'],
|
|
46
|
+
// Genuinely single-target packages → specific inference
|
|
47
|
+
'keccak256': ['Keccak-256'],
|
|
48
|
+
// Existing entries (unchanged)
|
|
49
|
+
'sm-crypto': ['SM2', 'SM3', 'SM4'],
|
|
50
|
+
'sm2-crypto': ['SM2'],
|
|
51
|
+
jsbn: ['RSA'],
|
|
52
|
+
'@fibemate/sm2-crypto': ['SM2'],
|
|
53
|
+
'@fibemate/keccak': ['Keccak-256', 'SHA3-256'],
|
|
54
|
+
},
|
|
55
|
+
sourcePatterns: [
|
|
56
|
+
// ── PQC specific (run first; dedup clears generic for same pos) ──
|
|
57
|
+
{ re: /\bml[-_]?dsa[-_]?44\b/gi, algs: ['ML-DSA-44'] },
|
|
58
|
+
{ re: /\bml[-_]?dsa[-_]?65\b/gi, algs: ['ML-DSA-65'] },
|
|
59
|
+
{ re: /\bml[-_]?dsa[-_]?87\b/gi, algs: ['ML-DSA-87'] },
|
|
60
|
+
{ re: /\bml[-_]?kem[-_]?512\b/gi, algs: ['ML-KEM-512'] },
|
|
61
|
+
{ re: /\bml[-_]?kem[-_]?768\b/gi, algs: ['ML-KEM-768'] },
|
|
62
|
+
{ re: /\bml[-_]?kem[-_]?1024\b/gi, algs: ['ML-KEM-1024'] },
|
|
63
|
+
{ re: /\bkeccak[-_]?224\b/gi, algs: ['Keccak-224'] },
|
|
64
|
+
{ re: /\bkeccak[-_]?256\b/gi, algs: ['Keccak-256'] },
|
|
65
|
+
{ re: /\bkeccak[-_]?384\b/gi, algs: ['Keccak-384'] },
|
|
66
|
+
{ re: /\bkeccak[-_]?512\b/gi, algs: ['Keccak-512'] },
|
|
67
|
+
// ── PQC generic (dedup removes overlap with specific above) ──
|
|
68
|
+
{ re: /\bml[-_]?kem\b/gi, algs: ['ML-KEM'] },
|
|
69
|
+
{ re: /\bml[-_]?dsa\b/gi, algs: ['ML-DSA'] },
|
|
70
|
+
{ re: /\bslh[-_]?dsa\b/gi, algs: ['SLH-DSA'] },
|
|
71
|
+
{ re: /\bkeccak\b/gi, algs: ['Keccak'] },
|
|
72
|
+
// ── SM / legacy ──
|
|
73
|
+
{ re: /require\(['"]sm-crypto['"]\)/g, algs: ['SM2', 'SM3', 'SM4'] },
|
|
74
|
+
{ re: /noble.*post-?quantum/gi, algs: ['ML-KEM', 'ML-DSA'] },
|
|
75
|
+
{ re: /crypto\.createHash\(['"]sha3-(\d+)['"]\)/g, algs: ['SHA3-$1'] },
|
|
76
|
+
{ re: /crypto\.createHash\(['"]sha-?256['"]\)/g, algs: ['SHA-256'] },
|
|
77
|
+
{ re: /crypto\.createHash\(['"]sha-?512['"]\)/g, algs: ['SHA-512'] },
|
|
78
|
+
{ re: /\bsm2\b/gi, algs: ['SM2'] },
|
|
79
|
+
{ re: /\bsm3\b/gi, algs: ['SM3'] },
|
|
80
|
+
{ re: /\bsm4\b/gi, algs: ['SM4'] },
|
|
81
|
+
// ── C/C++ (monocypher) ──
|
|
82
|
+
{ re: /\bcrypto_lock\b/g, algs: ['ChaCha20-Poly1305'] },
|
|
83
|
+
{ re: /\bcrypto_unlock\b/g, algs: ['ChaCha20-Poly1305'] },
|
|
84
|
+
{ re: /\bcrypto_x25519/g, algs: ['X25519'] },
|
|
85
|
+
{ re: /\bcrypto_sign/g, algs: ['Ed25519'] },
|
|
86
|
+
{ re: /\bcrypto_check\b/g, algs: ['Ed25519'] },
|
|
87
|
+
{ re: /\bcrypto_blake2b/g, algs: ['Blake2b'] },
|
|
88
|
+
{ re: /\bcrypto_argon2i\b/g, algs: ['Argon2i'] },
|
|
89
|
+
{ re: /\bcrypto_key_exchange\b/g, algs: ['X25519'] },
|
|
90
|
+
// ── C/C++ (mbedtls) ──
|
|
91
|
+
{ re: /\bmbedtls_sha256/g, algs: ['SHA-256'] },
|
|
92
|
+
{ re: /\bmbedtls_sha512/g, algs: ['SHA-512'] },
|
|
93
|
+
{ re: /\bmbedtls_aes/g, algs: ['AES'] },
|
|
94
|
+
{ re: /\bmbedtls_gcm/g, algs: ['AES-GCM'] },
|
|
95
|
+
{ re: /\bmbedtls_ecdh/g, algs: ['ECDH'] },
|
|
96
|
+
{ re: /\bmbedtls_rsa/g, algs: ['RSA'] },
|
|
97
|
+
// ── Python hashlib ──
|
|
98
|
+
{ re: /import\s+hashlib/g, algs: ['SHA-256'] },
|
|
99
|
+
{ re: /hashlib\.sha256\(/g, algs: ['SHA-256'] },
|
|
100
|
+
{ re: /hashlib\.new\(\s*["']sha256["']/gi, algs: ['SHA-256'] },
|
|
101
|
+
],
|
|
79
102
|
};
|
|
80
103
|
|
|
81
104
|
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.cache']);
|
|
82
105
|
const SCAN_EXT = /\.(cjs|mjs|js|ts|tsx|jsx|cpp|c|h|hpp|py)$/;
|
|
83
106
|
const MAX_OCC_PER_ALGO = 50;
|
|
84
107
|
|
|
108
|
+
// ── Dependency inference ────────────────────────────────────────────
|
|
109
|
+
|
|
85
110
|
function loadPackageDeps(dir) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
111
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
112
|
+
if (!fs.existsSync(pkgPath)) return {};
|
|
113
|
+
try {
|
|
114
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
115
|
+
return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
116
|
+
} catch {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
94
119
|
}
|
|
95
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Match manifest dependencies against rules.
|
|
123
|
+
* @returns {Map<string, {pkgName: string, confidence: number}>}
|
|
124
|
+
*/
|
|
96
125
|
function matchDeps(deps, rules) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
126
|
+
/** @type {Map<string, {origins: Array<{pkgName:string, confidence:number}>}>} */
|
|
127
|
+
const found = new Map();
|
|
128
|
+
for (const [pkgName, _v] of Object.entries(deps || {})) {
|
|
129
|
+
const algos = rules.packages[pkgName];
|
|
130
|
+
if (!algos) continue;
|
|
131
|
+
for (const algo of algos) {
|
|
132
|
+
if (!found.has(algo)) found.set(algo, { origins: [] });
|
|
133
|
+
found.get(algo).origins.push({ pkgName, confidence: 0.7 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
102
137
|
}
|
|
103
138
|
|
|
139
|
+
// ── Source scanning ─────────────────────────────────────────────────
|
|
140
|
+
|
|
104
141
|
function scanSource(root, rules) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
arr.
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
re.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
/** @type {Map<string, Array<{location:string, line:number, symbol:string}>>} */
|
|
143
|
+
const result = new Map();
|
|
144
|
+
|
|
145
|
+
function addOcc(algoName, file, line, symbol, span) {
|
|
146
|
+
if (!result.has(algoName)) result.set(algoName, []);
|
|
147
|
+
const arr = result.get(algoName);
|
|
148
|
+
if (arr.length >= MAX_OCC_PER_ALGO) return;
|
|
149
|
+
const loc = path.relative(root, file).replace(/\\/g, '/');
|
|
150
|
+
if (arr.some(o => o.location === loc && o.line === line && o.span.start === span.start && o.span.end === span.end)) return;
|
|
151
|
+
arr.push({ location: loc, line, symbol, span });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function walk(d) {
|
|
155
|
+
let entries;
|
|
156
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); }
|
|
157
|
+
catch { return; }
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
|
|
160
|
+
const full = path.join(d, entry.name);
|
|
161
|
+
if (entry.isDirectory()) { walk(full); continue; }
|
|
162
|
+
if (!SCAN_EXT.test(entry.name)) continue;
|
|
163
|
+
let content;
|
|
164
|
+
try { content = fs.readFileSync(full, 'utf-8'); }
|
|
165
|
+
catch { continue; }
|
|
166
|
+
for (const { re, algs } of rules.sourcePatterns) {
|
|
167
|
+
re.lastIndex = 0;
|
|
168
|
+
let m;
|
|
169
|
+
while ((m = re.exec(content)) !== null) {
|
|
170
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
171
|
+
const span = { start: m.index, end: m.index + m[0].length };
|
|
172
|
+
for (const a of algs) {
|
|
173
|
+
const name = a.replace(/\$(\d+)/g, (_, i) => m[i]);
|
|
174
|
+
addOcc(name, full, line, m[0], span);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
walk(root);
|
|
181
|
+
return result;
|
|
144
182
|
}
|
|
145
183
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
184
|
+
// ── Occurrence deduplication ────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* For each (file,line) position, if a family has both a specific match
|
|
188
|
+
* (with params) and a generic match (without params), drop the generic.
|
|
189
|
+
* Prevents e.g. "ml-dsa-65" from producing both ML-DSA-65 and ML-DSA.
|
|
190
|
+
*/
|
|
191
|
+
function deduplicateOccurrences(source) {
|
|
192
|
+
// 1. Flatten occurrences with span → [{algoName, family, params, location, line, start, end}]
|
|
193
|
+
const flat = [];
|
|
194
|
+
for (const [algoName, occs] of source) {
|
|
195
|
+
const h = HIERARCHY[algoName];
|
|
196
|
+
if (!h) continue;
|
|
197
|
+
for (const o of occs) {
|
|
198
|
+
flat.push({
|
|
199
|
+
algoName, family: h.family, params: h.params,
|
|
200
|
+
location: o.location, line: o.line,
|
|
201
|
+
start: o.span.start, end: o.span.end,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 2. Collect spans of all specific matches
|
|
207
|
+
const specificSpans = flat
|
|
208
|
+
.filter(e => e.params !== null)
|
|
209
|
+
.map(e => ({ location: e.location, line: e.line, start: e.start, end: e.end }));
|
|
210
|
+
|
|
211
|
+
function covers(outer, inner) {
|
|
212
|
+
return outer.location === inner.location
|
|
213
|
+
&& outer.line === inner.line
|
|
214
|
+
&& outer.start <= inner.start
|
|
215
|
+
&& outer.end >= inner.end;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. Drop generic occurrences whose span is covered by a specific match
|
|
219
|
+
const drop = new Set(); // "algoName@location:line:start:end"
|
|
220
|
+
for (const e of flat) {
|
|
221
|
+
if (e.params !== null) continue; // keep all specifics
|
|
222
|
+
const absorbed = specificSpans.some(s => covers(s, e));
|
|
223
|
+
if (absorbed) drop.add(`${e.algoName}@${e.location}:${e.line}:${e.start}:${e.end}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 4. Rebuild
|
|
227
|
+
const out = new Map();
|
|
228
|
+
for (const [algoName, occs] of source) {
|
|
229
|
+
const kept = occs.filter(o => !drop.has(`${algoName}@${o.location}:${o.line}:${o.span.start}:${o.span.end}`));
|
|
230
|
+
if (kept.length) out.set(algoName, kept);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Provenance merge ────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Merge dependency and source results into components with provenance evidence.
|
|
239
|
+
*
|
|
240
|
+
* Merge rule: exact-name match on both sides → one identity with two methods.
|
|
241
|
+
* Single source → one identity with one method.
|
|
242
|
+
* Different names → separate components (by union iteration).
|
|
243
|
+
*
|
|
244
|
+
* @param {Map<string, {pkgName:string, confidence:number}>} fromDeps
|
|
245
|
+
* @param {Map<string, Array<{location:string, line:number, symbol:string}>>} fromSource
|
|
246
|
+
* @returns {Array<object>} components
|
|
247
|
+
*/
|
|
248
|
+
function mergeEvidence(fromDeps, fromSource) {
|
|
249
|
+
const names = new Set([...fromDeps.keys(), ...fromSource.keys()]);
|
|
250
|
+
const components = [];
|
|
251
|
+
|
|
252
|
+
for (const name of names) {
|
|
253
|
+
const depInfo = fromDeps.get(name);
|
|
254
|
+
const srcOccs = fromSource.get(name) || [];
|
|
255
|
+
const methods = [];
|
|
256
|
+
|
|
257
|
+
// Bug 3 fix: one method per dependency origin
|
|
258
|
+
if (depInfo) {
|
|
259
|
+
for (const o of depInfo.origins) {
|
|
260
|
+
methods.push({
|
|
261
|
+
technique: 'manifest-analysis',
|
|
262
|
+
confidence: o.confidence,
|
|
263
|
+
value: o.pkgName,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (srcOccs.length) {
|
|
268
|
+
const h = HIERARCHY[name];
|
|
269
|
+
const conf = (h && h.params !== null) ? 0.9 : 0.7;
|
|
270
|
+
methods.push({
|
|
271
|
+
technique: 'source-code-analysis',
|
|
272
|
+
confidence: conf,
|
|
273
|
+
value: srcOccs[0].symbol,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Bug 1 fix: omit line for manifest occurrences (unknown real line)
|
|
278
|
+
const occurrences = [];
|
|
279
|
+
if (depInfo) {
|
|
280
|
+
for (const o of depInfo.origins) {
|
|
281
|
+
occurrences.push({ location: 'package.json', symbol: o.pkgName });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Strip internal span fields from source occurrences before output
|
|
285
|
+
for (const src of srcOccs) {
|
|
286
|
+
const { span, ...clean } = src;
|
|
287
|
+
occurrences.push(clean);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const identityConf = methods.length
|
|
291
|
+
? Math.max(...methods.map(m => m.confidence))
|
|
292
|
+
: 0.5;
|
|
293
|
+
|
|
294
|
+
const comp = {
|
|
295
|
+
type: 'cryptographic-asset',
|
|
296
|
+
name,
|
|
297
|
+
'bom-ref': `crypto:${name}`,
|
|
298
|
+
evidence: {
|
|
299
|
+
identity: [{
|
|
300
|
+
field: 'name',
|
|
301
|
+
confidence: identityConf,
|
|
302
|
+
concludedValue: name,
|
|
303
|
+
methods,
|
|
304
|
+
}],
|
|
305
|
+
occurrences,
|
|
306
|
+
},
|
|
166
307
|
};
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
308
|
+
|
|
309
|
+
const meta = lookupMeta(name);
|
|
310
|
+
if (meta) {
|
|
311
|
+
comp.cryptoProperties = {
|
|
312
|
+
assetType: meta.assetType,
|
|
313
|
+
algorithmProperties: meta.algorithmProperties,
|
|
314
|
+
};
|
|
315
|
+
const props = [];
|
|
316
|
+
if (meta.implementationPlatform) {
|
|
317
|
+
props.push({ name: 'fibemate:implementationLanguages', value: meta.implementationPlatform });
|
|
318
|
+
}
|
|
319
|
+
if (meta.certificationLevel) {
|
|
320
|
+
props.push({ name: 'fibemate:testingStatus', value: meta.certificationLevel });
|
|
321
|
+
}
|
|
322
|
+
if (meta.quantumSecurity && meta.quantumSecurity.level) {
|
|
323
|
+
props.push({ name: 'fibemate:quantumSecurity:level', value: meta.quantumSecurity.level });
|
|
324
|
+
}
|
|
325
|
+
if (props.length) comp.properties = props;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
components.push(comp);
|
|
175
329
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return comp;
|
|
179
|
-
}),
|
|
180
|
-
};
|
|
330
|
+
|
|
331
|
+
return components.sort((a, b) => a.name.localeCompare(b.name));
|
|
181
332
|
}
|
|
182
333
|
|
|
334
|
+
// ── Output ──────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
function toCycloneDX(components) {
|
|
337
|
+
return {
|
|
338
|
+
bomFormat: 'CycloneDX',
|
|
339
|
+
specVersion: '1.6',
|
|
340
|
+
version: 1,
|
|
341
|
+
metadata: {
|
|
342
|
+
timestamp: new Date().toISOString(),
|
|
343
|
+
tools: [{ name: 'cbom-scan', version: PKG.version, vendor: 'FIBEMATE' }],
|
|
344
|
+
},
|
|
345
|
+
components,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Entry point ─────────────────────────────────────────────────────
|
|
350
|
+
|
|
183
351
|
function main() {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
console.log(output);
|
|
213
|
-
}
|
|
214
|
-
process.exit(0);
|
|
352
|
+
const args = process.argv.slice(2);
|
|
353
|
+
const dirFlag = args.indexOf('--dir');
|
|
354
|
+
let dir = process.cwd();
|
|
355
|
+
if (dirFlag !== -1) {
|
|
356
|
+
const v = args[dirFlag + 1];
|
|
357
|
+
if (!v || v.startsWith('--')) {
|
|
358
|
+
console.error('usage: cbom-scan [--dir <path>] [--out <file>]');
|
|
359
|
+
process.exit(2);
|
|
360
|
+
}
|
|
361
|
+
dir = v;
|
|
362
|
+
}
|
|
363
|
+
const outPath = args.includes('--out') ? args[args.indexOf('--out') + 1] : null;
|
|
364
|
+
|
|
365
|
+
const deps = loadPackageDeps(dir);
|
|
366
|
+
const fromDeps = matchDeps(deps, DEFAULT_RULES);
|
|
367
|
+
const rawSource = scanSource(dir, DEFAULT_RULES);
|
|
368
|
+
const fromSource = deduplicateOccurrences(rawSource);
|
|
369
|
+
const components = mergeEvidence(fromDeps, fromSource);
|
|
370
|
+
const bom = toCycloneDX(components);
|
|
371
|
+
|
|
372
|
+
const output = JSON.stringify(bom, null, 2);
|
|
373
|
+
if (outPath) {
|
|
374
|
+
fs.writeFileSync(outPath, output, 'utf-8');
|
|
375
|
+
console.error(`cbom-scan: wrote ${components.length} algorithms to ${outPath}`);
|
|
376
|
+
} else {
|
|
377
|
+
console.log(output);
|
|
378
|
+
}
|
|
379
|
+
process.exit(0);
|
|
215
380
|
}
|
|
216
381
|
|
|
217
|
-
main
|
|
382
|
+
if (require.main === module) {
|
|
383
|
+
main();
|
|
384
|
+
}
|
package/cli.cjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Copyright (c) 2026 刘天赫
|
|
4
|
+
/**
|
|
5
|
+
* cbom-tools — one entry point for the two binaries.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: the package shipped `cbom-scan` and `cbom-diff` as separate
|
|
8
|
+
* bins, so `npx cbom-tools scan` — the thing a first-time reader types — failed
|
|
9
|
+
* with "could not determine executable to run". A package whose name is not a
|
|
10
|
+
* command is a package nobody tries twice.
|
|
11
|
+
*
|
|
12
|
+
* npx cbom-tools scan --dir ./project --out cbom.json
|
|
13
|
+
* npx cbom-tools diff cbom-before.json cbom-after.json
|
|
14
|
+
*
|
|
15
|
+
* The old bin names still work; this is an addition, not a rename.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { spawnSync } = require('node:child_process');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const COMMANDS = {
|
|
22
|
+
scan: 'cbom-scan.cjs',
|
|
23
|
+
diff: 'cbom-diff.js',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function usage() {
|
|
27
|
+
console.log(`cbom-tools — Cryptographic Bill of Materials
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
cbom-tools scan [--dir <path>] [--out <file>] scan a tree, emit CycloneDX CBOM
|
|
31
|
+
cbom-tools diff <old.json> <new.json> [--json] diff two CBOMs
|
|
32
|
+
cbom-tools --version
|
|
33
|
+
cbom-tools --help
|
|
34
|
+
|
|
35
|
+
Aliases installed alongside: cbom-scan, cbom-diff.
|
|
36
|
+
Zero external dependencies. Pure Node.js.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const argv = process.argv.slice(2);
|
|
40
|
+
const sub = argv[0];
|
|
41
|
+
|
|
42
|
+
if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
|
|
43
|
+
usage();
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (sub === '--version' || sub === '-v' || sub === 'version') {
|
|
48
|
+
const pkg = require('./package.json');
|
|
49
|
+
console.log(`cbom-tools ${pkg.version}`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const target = COMMANDS[sub];
|
|
54
|
+
if (!target) {
|
|
55
|
+
console.error(`cbom-tools: unknown command "${sub}"`);
|
|
56
|
+
usage();
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = spawnSync(process.execPath, [path.join(__dirname, target), ...argv.slice(1)], {
|
|
61
|
+
stdio: 'inherit',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (result.error) {
|
|
65
|
+
console.error(`cbom-tools: failed to run ${target}: ${result.error.message}`);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/package.json
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cbom-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Crypto Bill of Materials — scan, diff, report (CycloneDX 1.6 CBOM)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "刘天赫 <research@fibemate.net>",
|
|
7
7
|
"bin": {
|
|
8
|
-
"cbom-
|
|
9
|
-
"cbom-
|
|
8
|
+
"cbom-tools": "cli.cjs",
|
|
9
|
+
"cbom-scan": "cbom-scan.cjs",
|
|
10
|
+
"cbom-diff": "cbom-diff.js"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/Lennonhaha/fibemate-tools.git",
|
|
15
|
+
"directory": "cbom-tools"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://fibemate.net/tools/",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/Lennonhaha/fibemate-tools/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cbom",
|
|
23
|
+
"cyclonedx",
|
|
24
|
+
"sbom",
|
|
25
|
+
"cryptography",
|
|
26
|
+
"post-quantum",
|
|
27
|
+
"compliance"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
10
31
|
},
|
|
11
32
|
"files": [
|
|
33
|
+
"cli.cjs",
|
|
12
34
|
"cbom-scan.cjs",
|
|
13
35
|
"cbom-diff.js",
|
|
14
36
|
"algo-metadata.json",
|