cbom-tools 0.2.1 → 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 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
- ## Install
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 detected
42
- - `2` — file not found or parse error
43
- - `3` — (reserved) quantum-vulnerable/weak algorithm detection (planned for 0.2.0)
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
 
@@ -222,7 +222,7 @@
222
222
  "Keccak": {
223
223
  "assetType": "algorithm",
224
224
  "algorithmProperties": {
225
- "primitive": "other"
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
- const OLD = oldCBOM ? oldCBOM.version || 0 : 0;
96
- const NEW = newCBOM ? newCBOM.version || 0 : 0;
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: NEW > OLD ? 'bumped' : (NEW < OLD ? 'downgraded' : 'same'),
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
- lines.push(`| 旧版本 | ${OLD} |`);
281
- lines.push(`| 新版本 | ${NEW} |`);
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
- 'ML-KEM-768': 'ML-KEM',
18
- 'ML-KEM-512': 'ML-KEM',
19
- 'ML-KEM-1024': 'ML-KEM',
20
- 'ML-DSA': 'ML-DSA/fml-dsa',
21
- 'ML-DSA-44': 'ML-DSA/fml-dsa',
22
- 'ML-DSA-65': 'ML-DSA/fml-dsa',
23
- 'ML-DSA-87': 'ML-DSA/fml-dsa',
24
- 'Keccak-256': 'Keccak',
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
- if (ALGO_META[name]) return ALGO_META[name];
29
- const alias = NAME_ALIASES[name];
30
- if (alias && ALGO_META[alias]) return ALGO_META[alias];
31
- return null;
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
- packages: {
36
- '@noble/post-quantum': ['ML-KEM-768', 'ML-DSA-65', 'SLH-DSA'],
37
- '@noble/hashes': ['SHA3-256', 'SHA3-512', 'Keccak-256'],
38
- 'sm-crypto': ['SM2', 'SM3', 'SM4'],
39
- 'sm2-crypto': ['SM2'],
40
- jsbn: ['RSA'],
41
- '@fibemate/sm2-crypto': ['SM2'],
42
- '@fibemate/keccak': ['Keccak-256', 'SHA3-256'],
43
- },
44
- sourcePatterns: [
45
- { re: /require\(['"]sm-crypto['"]\)/g, algs: ['SM2', 'SM3', 'SM4'] },
46
- { re: /crypto\.createHash\(['"]sha3-(\d+)['"]\)/g, algs: ['SHA3-$1'] },
47
- { re: /crypto\.createHash\(['"]sha-?256['"]\)/g, algs: ['SHA-256'] },
48
- { re: /crypto\.createHash\(['"]sha-?512['"]\)/g, algs: ['SHA-512'] },
49
- { re: /noble.*post-?quantum/gi, algs: ['ML-KEM', 'ML-DSA'] },
50
- { re: /\bml[-_]?kem\b/gi, algs: ['ML-KEM'] },
51
- { re: /\bml[-_]?dsa\b/gi, algs: ['ML-DSA'] },
52
- { re: /\bslh[-_]?dsa\b/gi, algs: ['SLH-DSA'] },
53
- { re: /\bkeccak\b/gi, algs: ['Keccak'] },
54
- { re: /\bsm2\b/gi, algs: ['SM2'] },
55
- { re: /\bsm3\b/gi, algs: ['SM3'] },
56
- { re: /\bsm4\b/gi, algs: ['SM4'] },
57
- // === C/C++ 编译库(monocypher)===
58
- { re: /\bcrypto_lock\b/g, algs: ['ChaCha20-Poly1305'] },
59
- { re: /\bcrypto_unlock\b/g, algs: ['ChaCha20-Poly1305'] },
60
- { re: /\bcrypto_x25519/g, algs: ['X25519'] },
61
- { re: /\bcrypto_sign/g, algs: ['Ed25519'] },
62
- { re: /\bcrypto_check\b/g, algs: ['Ed25519'] },
63
- { re: /\bcrypto_blake2b/g, algs: ['Blake2b'] },
64
- { re: /\bcrypto_argon2i\b/g, algs: ['Argon2i'] },
65
- { re: /\bcrypto_key_exchange\b/g, algs: ['X25519'] },
66
- // === C/C++ 通用(mbedtls)===
67
- { re: /\bmbedtls_sha256/g, algs: ['SHA-256'] },
68
- { re: /\bmbedtls_sha512/g, algs: ['SHA-512'] },
69
- { re: /\bmbedtls_aes/g, algs: ['AES'] },
70
- { re: /\bmbedtls_gcm/g, algs: ['AES-GCM'] },
71
- { re: /\bmbedtls_ecdh/g, algs: ['ECDH'] },
72
- { re: /\bmbedtls_rsa/g, algs: ['RSA'] },
73
-
74
- // Python hashlib (MAVLink2 signing, pymavlink sample)
75
- { re: /import\s+hashlib/g, algs: ['SHA-256'] },
76
- { re: /hashlib\.sha256\(/g, algs: ['SHA-256'] },
77
- { re: /hashlib\.new\(\s*["']sha256["']/gi, algs: ['SHA-256'] },
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
- const pkgPath = path.join(dir, 'package.json');
87
- if (!fs.existsSync(pkgPath)) return {};
88
- try {
89
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
90
- return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
91
- } catch {
92
- return {};
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
- const found = new Set();
98
- for (const name of Object.keys(deps)) {
99
- if (rules.packages[name]) rules.packages[name].forEach(a => found.add(a));
100
- }
101
- return found;
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
- const found = new Map();
106
-
107
- function addOcc(name, file, line) {
108
- let arr = found.get(name);
109
- if (!arr) { arr = []; found.set(name, arr); }
110
- if (arr.length >= MAX_OCC_PER_ALGO) return;
111
- const loc = path.relative(root, file).replace(/\\/g, '/');
112
- if (arr.some(o => o.location === loc && o.line === line)) return;
113
- arr.push({ location: loc, line });
114
- }
115
-
116
- function walk(d) {
117
- let entries;
118
- try { entries = fs.readdirSync(d, { withFileTypes: true }); }
119
- catch { return; }
120
- for (const entry of entries) {
121
- if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
122
- const full = path.join(d, entry.name);
123
- if (entry.isDirectory()) walk(full);
124
- else if (SCAN_EXT.test(entry.name)) {
125
- let content;
126
- try { content = fs.readFileSync(full, 'utf-8'); }
127
- catch { continue; }
128
- for (const { re, algs } of rules.sourcePatterns) {
129
- re.lastIndex = 0;
130
- let m;
131
- while ((m = re.exec(content)) !== null) {
132
- const line = content.slice(0, m.index).split('\n').length;
133
- for (const a of algs) {
134
- const name = a.replace(/\$(\d+)/g, (_, i) => m[i]);
135
- addOcc(name, full, line);
136
- }
137
- }
138
- }
139
- }
140
- }
141
- }
142
- walk(root);
143
- return found;
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
- function toCycloneDX(algorithms) {
147
- return {
148
- bomFormat: 'CycloneDX',
149
- specVersion: '1.6',
150
- version: 1,
151
- metadata: {
152
- timestamp: new Date().toISOString(),
153
- tools: [{ name: 'cbom-scan', version: PKG.version, vendor: 'FIBEMATE' }],
154
- },
155
- components: [...algorithms.keys()].sort().map(name => {
156
- const meta = lookupMeta(name);
157
- const comp = {
158
- type: 'cryptographic-asset',
159
- name,
160
- 'bom-ref': `crypto:${name}`,
161
- };
162
- if (meta) {
163
- comp.cryptoProperties = {
164
- assetType: meta.assetType,
165
- algorithmProperties: meta.algorithmProperties,
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
- const props = [];
168
- if (meta.implementationPlatform)
169
- props.push({ name: 'fibemate:implementationLanguages', value: meta.implementationPlatform });
170
- if (meta.certificationLevel)
171
- props.push({ name: 'fibemate:testingStatus', value: meta.certificationLevel });
172
- if (meta.quantumSecurity?.level)
173
- props.push({ name: 'fibemate:quantumSecurity:level', value: meta.quantumSecurity.level });
174
- if (props.length) comp.properties = props;
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
- const occ = algorithms.get(name);
177
- if (occ && occ.length) comp.evidence = { occurrences: occ };
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
- const args = process.argv.slice(2);
185
- const dirFlag = args.indexOf('--dir');
186
- let dir = process.cwd();
187
- if (dirFlag !== -1) {
188
- const v = args[dirFlag + 1];
189
- if (!v || v.startsWith('--')) {
190
- console.error('usage: cbom-scan [--dir <path>] [--out <file>]');
191
- process.exit(2);
192
- }
193
- dir = v;
194
- }
195
- const outPath = args.includes('--out') ? args[args.indexOf('--out') + 1] : null;
196
-
197
- const deps = loadPackageDeps(dir);
198
- const fromDeps = matchDeps(deps, DEFAULT_RULES);
199
- const fromSource = scanSource(dir, DEFAULT_RULES);
200
-
201
- // Merge: union of both. Source occurrences override empty array from deps.
202
- const merged = new Map();
203
- for (const name of fromDeps) merged.set(name, []);
204
- for (const [name, occ] of fromSource) merged.set(name, occ);
205
-
206
- const cbom = toCycloneDX(merged);
207
- const output = JSON.stringify(cbom, null, 2);
208
- if (outPath) {
209
- fs.writeFileSync(outPath, output, 'utf-8');
210
- console.error(`cbom-scan: wrote ${merged.size} algorithms to ${outPath}`);
211
- } else {
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.1",
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-scan": "./cbom-scan.cjs",
9
- "cbom-diff": "./cbom-diff.js"
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",