@cyclonedx/cdxgen 12.2.0 → 12.3.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.
Files changed (181) hide show
  1. package/README.md +242 -90
  2. package/bin/audit.js +191 -0
  3. package/bin/cdxgen.js +532 -168
  4. package/bin/convert.js +99 -0
  5. package/bin/evinse.js +23 -0
  6. package/bin/repl.js +339 -8
  7. package/bin/sign.js +8 -0
  8. package/bin/validate.js +8 -0
  9. package/bin/verify.js +8 -0
  10. package/data/container-knowledge-index.json +125 -0
  11. package/data/gtfobins-index.json +6296 -0
  12. package/data/lolbas-index.json +150 -0
  13. package/data/queries-darwin.json +63 -3
  14. package/data/queries-win.json +45 -3
  15. package/data/queries.json +74 -2
  16. package/data/rules/chrome-extensions.yaml +240 -0
  17. package/data/rules/ci-permissions.yaml +478 -18
  18. package/data/rules/container-risk.yaml +270 -0
  19. package/data/rules/obom-runtime.yaml +891 -0
  20. package/data/rules/package-integrity.yaml +49 -0
  21. package/data/spdx-export.schema.json +6794 -0
  22. package/data/spdx-model-v3.0.1.jsonld +15999 -0
  23. package/lib/audit/index.js +1924 -0
  24. package/lib/audit/index.poku.js +1488 -0
  25. package/lib/audit/progress.js +137 -0
  26. package/lib/audit/progress.poku.js +188 -0
  27. package/lib/audit/reporters.js +618 -0
  28. package/lib/audit/scoring.js +310 -0
  29. package/lib/audit/scoring.poku.js +341 -0
  30. package/lib/audit/targets.js +260 -0
  31. package/lib/audit/targets.poku.js +331 -0
  32. package/lib/cli/index.js +276 -68
  33. package/lib/cli/index.poku.js +368 -0
  34. package/lib/helpers/analyzer.js +1052 -5
  35. package/lib/helpers/analyzer.poku.js +301 -0
  36. package/lib/helpers/annotationFormatter.js +49 -0
  37. package/lib/helpers/annotationFormatter.poku.js +44 -0
  38. package/lib/helpers/bomUtils.js +36 -0
  39. package/lib/helpers/bomUtils.poku.js +51 -0
  40. package/lib/helpers/caxa.js +2 -2
  41. package/lib/helpers/chromextutils.js +1153 -0
  42. package/lib/helpers/chromextutils.poku.js +493 -0
  43. package/lib/helpers/ciParsers/githubActions.js +1632 -45
  44. package/lib/helpers/ciParsers/githubActions.poku.js +853 -1
  45. package/lib/helpers/containerRisk.js +186 -0
  46. package/lib/helpers/containerRisk.poku.js +52 -0
  47. package/lib/helpers/depsUtils.js +16 -0
  48. package/lib/helpers/depsUtils.poku.js +58 -1
  49. package/lib/helpers/display.js +245 -61
  50. package/lib/helpers/display.poku.js +162 -2
  51. package/lib/helpers/exportUtils.js +123 -0
  52. package/lib/helpers/exportUtils.poku.js +60 -0
  53. package/lib/helpers/formulationParsers.js +69 -0
  54. package/lib/helpers/formulationParsers.poku.js +44 -0
  55. package/lib/helpers/gtfobins.js +189 -0
  56. package/lib/helpers/gtfobins.poku.js +49 -0
  57. package/lib/helpers/lolbas.js +267 -0
  58. package/lib/helpers/lolbas.poku.js +39 -0
  59. package/lib/helpers/osqueryTransform.js +84 -0
  60. package/lib/helpers/osqueryTransform.poku.js +49 -0
  61. package/lib/helpers/provenanceUtils.js +193 -0
  62. package/lib/helpers/provenanceUtils.poku.js +145 -0
  63. package/lib/helpers/pylockutils.js +281 -0
  64. package/lib/helpers/pylockutils.poku.js +48 -0
  65. package/lib/helpers/registryProvenance.js +793 -0
  66. package/lib/helpers/registryProvenance.poku.js +452 -0
  67. package/lib/helpers/remote/dependency-track.js +84 -0
  68. package/lib/helpers/remote/dependency-track.poku.js +119 -0
  69. package/lib/helpers/source.js +1267 -0
  70. package/lib/helpers/source.poku.js +771 -0
  71. package/lib/helpers/spdxUtils.js +97 -0
  72. package/lib/helpers/spdxUtils.poku.js +70 -0
  73. package/lib/helpers/table.js +384 -0
  74. package/lib/helpers/table.poku.js +186 -0
  75. package/lib/helpers/unicodeScan.js +147 -0
  76. package/lib/helpers/unicodeScan.poku.js +45 -0
  77. package/lib/helpers/utils.js +882 -136
  78. package/lib/helpers/utils.poku.js +995 -91
  79. package/lib/managers/binary.js +29 -5
  80. package/lib/managers/docker.js +179 -52
  81. package/lib/managers/docker.poku.js +327 -28
  82. package/lib/managers/oci.js +107 -23
  83. package/lib/managers/oci.poku.js +132 -0
  84. package/lib/server/openapi.yaml +50 -0
  85. package/lib/server/server.js +228 -331
  86. package/lib/server/server.poku.js +220 -5
  87. package/lib/stages/postgen/annotator.js +7 -0
  88. package/lib/stages/postgen/annotator.poku.js +40 -0
  89. package/lib/stages/postgen/auditBom.js +20 -5
  90. package/lib/stages/postgen/auditBom.poku.js +1729 -67
  91. package/lib/stages/postgen/postgen.js +40 -0
  92. package/lib/stages/postgen/postgen.poku.js +47 -0
  93. package/lib/stages/postgen/ruleEngine.js +80 -2
  94. package/lib/stages/postgen/spdxConverter.js +796 -0
  95. package/lib/stages/postgen/spdxConverter.poku.js +341 -0
  96. package/lib/validator/bomValidator.js +232 -0
  97. package/lib/validator/bomValidator.poku.js +70 -0
  98. package/lib/validator/complianceRules.js +70 -7
  99. package/lib/validator/complianceRules.poku.js +30 -0
  100. package/lib/validator/reporters/annotations.js +2 -2
  101. package/lib/validator/reporters/console.js +13 -2
  102. package/lib/validator/reporters.poku.js +13 -0
  103. package/package.json +10 -8
  104. package/types/bin/audit.d.ts +3 -0
  105. package/types/bin/audit.d.ts.map +1 -0
  106. package/types/bin/convert.d.ts +3 -0
  107. package/types/bin/convert.d.ts.map +1 -0
  108. package/types/bin/repl.d.ts.map +1 -1
  109. package/types/lib/audit/index.d.ts +115 -0
  110. package/types/lib/audit/index.d.ts.map +1 -0
  111. package/types/lib/audit/progress.d.ts +27 -0
  112. package/types/lib/audit/progress.d.ts.map +1 -0
  113. package/types/lib/audit/reporters.d.ts +35 -0
  114. package/types/lib/audit/reporters.d.ts.map +1 -0
  115. package/types/lib/audit/scoring.d.ts +35 -0
  116. package/types/lib/audit/scoring.d.ts.map +1 -0
  117. package/types/lib/audit/targets.d.ts +63 -0
  118. package/types/lib/audit/targets.d.ts.map +1 -0
  119. package/types/lib/cli/index.d.ts +8 -0
  120. package/types/lib/cli/index.d.ts.map +1 -1
  121. package/types/lib/helpers/analyzer.d.ts +13 -0
  122. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  123. package/types/lib/helpers/annotationFormatter.d.ts +23 -0
  124. package/types/lib/helpers/annotationFormatter.d.ts.map +1 -0
  125. package/types/lib/helpers/bomUtils.d.ts +5 -0
  126. package/types/lib/helpers/bomUtils.d.ts.map +1 -0
  127. package/types/lib/helpers/chromextutils.d.ts +97 -0
  128. package/types/lib/helpers/chromextutils.d.ts.map +1 -0
  129. package/types/lib/helpers/ciParsers/githubActions.d.ts +3 -8
  130. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  131. package/types/lib/helpers/containerRisk.d.ts +17 -0
  132. package/types/lib/helpers/containerRisk.d.ts.map +1 -0
  133. package/types/lib/helpers/depsUtils.d.ts.map +1 -1
  134. package/types/lib/helpers/display.d.ts +4 -1
  135. package/types/lib/helpers/display.d.ts.map +1 -1
  136. package/types/lib/helpers/exportUtils.d.ts +40 -0
  137. package/types/lib/helpers/exportUtils.d.ts.map +1 -0
  138. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  139. package/types/lib/helpers/gtfobins.d.ts +17 -0
  140. package/types/lib/helpers/gtfobins.d.ts.map +1 -0
  141. package/types/lib/helpers/lolbas.d.ts +16 -0
  142. package/types/lib/helpers/lolbas.d.ts.map +1 -0
  143. package/types/lib/helpers/osqueryTransform.d.ts +7 -0
  144. package/types/lib/helpers/osqueryTransform.d.ts.map +1 -0
  145. package/types/lib/helpers/provenanceUtils.d.ts +90 -0
  146. package/types/lib/helpers/provenanceUtils.d.ts.map +1 -0
  147. package/types/lib/helpers/pylockutils.d.ts +51 -0
  148. package/types/lib/helpers/pylockutils.d.ts.map +1 -0
  149. package/types/lib/helpers/registryProvenance.d.ts +17 -0
  150. package/types/lib/helpers/registryProvenance.d.ts.map +1 -0
  151. package/types/lib/helpers/remote/dependency-track.d.ts +16 -0
  152. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -0
  153. package/types/lib/helpers/source.d.ts +141 -0
  154. package/types/lib/helpers/source.d.ts.map +1 -0
  155. package/types/lib/helpers/spdxUtils.d.ts +2 -0
  156. package/types/lib/helpers/spdxUtils.d.ts.map +1 -0
  157. package/types/lib/helpers/table.d.ts +6 -0
  158. package/types/lib/helpers/table.d.ts.map +1 -0
  159. package/types/lib/helpers/unicodeScan.d.ts +46 -0
  160. package/types/lib/helpers/unicodeScan.d.ts.map +1 -0
  161. package/types/lib/helpers/utils.d.ts +30 -11
  162. package/types/lib/helpers/utils.d.ts.map +1 -1
  163. package/types/lib/managers/binary.d.ts.map +1 -1
  164. package/types/lib/managers/docker.d.ts.map +1 -1
  165. package/types/lib/managers/oci.d.ts.map +1 -1
  166. package/types/lib/server/server.d.ts +0 -35
  167. package/types/lib/server/server.d.ts.map +1 -1
  168. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  169. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  170. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  171. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  172. package/types/lib/stages/postgen/spdxConverter.d.ts +11 -0
  173. package/types/lib/stages/postgen/spdxConverter.d.ts.map +1 -0
  174. package/types/lib/validator/bomValidator.d.ts +1 -0
  175. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  176. package/types/lib/validator/complianceRules.d.ts.map +1 -1
  177. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  178. package/types/bin/dependencies.d.ts +0 -3
  179. package/types/bin/dependencies.d.ts.map +0 -1
  180. package/types/bin/licenses.d.ts +0 -3
  181. package/types/bin/licenses.d.ts.map +0 -1
@@ -0,0 +1,1924 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ mkdtempSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { dirname, join, relative, resolve } from "node:path";
12
+ import process from "node:process";
13
+
14
+ import { createBom } from "../cli/index.js";
15
+ import {
16
+ getNonCycloneDxErrorMessage,
17
+ isCycloneDxBom,
18
+ } from "../helpers/bomUtils.js";
19
+ import { thoughtLog } from "../helpers/logger.js";
20
+ import {
21
+ hasRegistryProvenanceEvidenceProperties,
22
+ hasTrustedPublishingProperties,
23
+ } from "../helpers/provenanceUtils.js";
24
+ import {
25
+ cleanupSourceDir,
26
+ findGitRefForPurlVersion,
27
+ hardenedGitCommand,
28
+ resolveGitUrlFromPurl,
29
+ resolvePurlSourceDirectory,
30
+ sanitizeRemoteUrlForLogs,
31
+ } from "../helpers/source.js";
32
+ import {
33
+ dirNameStr,
34
+ getTmpDir,
35
+ safeExistsSync,
36
+ safeMkdirSync,
37
+ } from "../helpers/utils.js";
38
+ import { auditBom } from "../stages/postgen/auditBom.js";
39
+ import { postProcess } from "../stages/postgen/postgen.js";
40
+ import { formatTargetLabel } from "./progress.js";
41
+ import { renderAuditReport } from "./reporters.js";
42
+ import {
43
+ SEVERITY_ORDER,
44
+ scoreTargetRisk,
45
+ severityMeetsThreshold,
46
+ } from "./scoring.js";
47
+ import { collectAuditTargets, normalizePackageName } from "./targets.js";
48
+
49
+ export const DEFAULT_AUDIT_CATEGORIES = [
50
+ "ci-permission",
51
+ "dependency-source",
52
+ "package-integrity",
53
+ ];
54
+
55
+ const AUDIT_CACHE_DIRNAME = ".cdx-audit";
56
+ const AUDIT_CACHE_BOM_FILE = "source-bom.json";
57
+ const AUDIT_CACHE_META_FILE = "source-bom.meta.json";
58
+ const CLONE_RETRY_DELAYS_MS = [750, 1500];
59
+ const LARGE_PREDICTIVE_AUDIT_THRESHOLD = 50;
60
+ const VERY_LARGE_PREDICTIVE_AUDIT_THRESHOLD = 100;
61
+
62
+ const PYTHON_METADATA_FILES = ["pyproject.toml", "setup.cfg", "setup.py"];
63
+ const PYTHON_HEURISTIC_FILENAMES = new Set(["setup.py", "__init__.py"]);
64
+ const PYTHON_HEURISTIC_FILE_LIMIT = 32;
65
+ const PYTHON_HEURISTIC_MAX_FILE_BYTES = 256 * 1024;
66
+ const PYTHON_SKIP_DIRS = new Set([
67
+ ".git",
68
+ ".hg",
69
+ ".tox",
70
+ ".venv",
71
+ "__pycache__",
72
+ "build",
73
+ "dist",
74
+ "node_modules",
75
+ "site-packages",
76
+ "venv",
77
+ ]);
78
+
79
+ const PYTHON_EXECUTION_PATTERN =
80
+ /\b(?:exec|eval|compile)\s*\(|\b(?:subprocess\.(?:Popen|run|call|check_output)|os\.(?:system|popen))\b/i;
81
+
82
+ const PYTHON_NETWORK_PATTERN =
83
+ /\b(?:requests\.(?:get|post|put|patch)|urllib(?:\.request)?\.urlopen|http\.client|socket\.socket)\b/i;
84
+
85
+ const PYTHON_OBFUSCATION_PATTERN =
86
+ /\b(?:base64\.(?:b64decode|urlsafe_b64decode)|binascii\.a2b_base64|marshal\.loads|zlib\.decompress|codecs\.decode\s*\([^)]*base64|bytes\.fromhex)\b/i;
87
+
88
+ const PYTHON_SETUP_CMDCLASS_PATTERN = /\bcmdclass\s*=/i;
89
+
90
+ /**
91
+ * Read and validate a CycloneDX BOM file.
92
+ *
93
+ * @param {string} bomPath BOM file path
94
+ * @returns {object} parsed CycloneDX BOM
95
+ */
96
+ export function loadBomFile(bomPath) {
97
+ const resolvedPath = resolve(bomPath);
98
+ let bomJson;
99
+ try {
100
+ bomJson = JSON.parse(readFileSync(resolvedPath, "utf8"));
101
+ } catch (error) {
102
+ throw new Error(`Failed to parse ${resolvedPath}: ${error.message}`);
103
+ }
104
+ if (!isCycloneDxBom(bomJson)) {
105
+ throw new Error(getNonCycloneDxErrorMessage(bomJson, "cdx-audit"));
106
+ }
107
+ return bomJson;
108
+ }
109
+
110
+ /**
111
+ * Recursively list JSON files under a BOM directory.
112
+ *
113
+ * @param {string} bomDir directory path
114
+ * @returns {string[]} discovered file paths
115
+ */
116
+ export function listBomFiles(bomDir) {
117
+ const foundFiles = [];
118
+ const queue = [resolve(bomDir)];
119
+ while (queue.length) {
120
+ const currentDir = queue.shift();
121
+ const entries = readdirSync(currentDir, { withFileTypes: true });
122
+ for (const entry of entries) {
123
+ const entryPath = join(currentDir, entry.name);
124
+ if (entry.isDirectory()) {
125
+ queue.push(entryPath);
126
+ continue;
127
+ }
128
+ if (entry.isFile() && entry.name.endsWith(".json")) {
129
+ foundFiles.push(entryPath);
130
+ }
131
+ }
132
+ }
133
+ return foundFiles.sort();
134
+ }
135
+
136
+ /**
137
+ * Load input BOM files from either a single file or a directory.
138
+ *
139
+ * @param {object} options CLI options
140
+ * @returns {{ source: string, bomJson: object }[]} loaded input BOMs
141
+ */
142
+ export function loadInputBoms(options) {
143
+ const inputBoms = [];
144
+ if (options.bom) {
145
+ inputBoms.push({
146
+ bomJson: loadBomFile(options.bom),
147
+ source: resolve(options.bom),
148
+ });
149
+ }
150
+ if (options.bomDir) {
151
+ const bomFiles = listBomFiles(options.bomDir);
152
+ for (const bomFile of bomFiles) {
153
+ try {
154
+ inputBoms.push({
155
+ bomJson: loadBomFile(bomFile),
156
+ source: bomFile,
157
+ });
158
+ } catch (error) {
159
+ console.warn(
160
+ `Skipping non-CycloneDX JSON file '${bomFile}': ${error.message}`,
161
+ );
162
+ }
163
+ }
164
+ }
165
+ return inputBoms;
166
+ }
167
+
168
+ /**
169
+ * Read the package version from the local package.json file.
170
+ *
171
+ * @returns {string} package version
172
+ */
173
+ function readPackageVersion() {
174
+ const packageJson = JSON.parse(
175
+ readFileSync(join(dirNameStr, "package.json"), "utf8"),
176
+ );
177
+ return packageJson.version;
178
+ }
179
+
180
+ /**
181
+ * Build a deterministic directory-safe slug for report and workspace paths.
182
+ *
183
+ * @param {object} target audit target
184
+ * @returns {string} slug string
185
+ */
186
+ function targetSlug(target) {
187
+ const packageName = target.namespace
188
+ ? `${target.namespace}-${target.name}`
189
+ : target.name;
190
+ const normalized = normalizePackageName(packageName)
191
+ .replace(/[^a-z0-9-]/g, "-")
192
+ .replace(/-+/g, "-")
193
+ .replace(/^-|-$/g, "");
194
+ const version = normalizePackageName(target.version || "latest") || "latest";
195
+ const digest = createHash("sha256")
196
+ .update(target.purl)
197
+ .digest("hex")
198
+ .slice(0, 12);
199
+ return `${target.type}-${normalized || "package"}-${version}-${digest}`;
200
+ }
201
+
202
+ /**
203
+ * Ensure a parent directory exists before writing a file.
204
+ *
205
+ * @param {string} filePath file path to create
206
+ * @param {string} content file content
207
+ * @returns {void}
208
+ */
209
+ function writeTextFile(filePath, content) {
210
+ const parentDir = dirname(filePath);
211
+ if (!safeExistsSync(parentDir)) {
212
+ safeMkdirSync(parentDir, { recursive: true });
213
+ }
214
+ writeFileSync(filePath, content);
215
+ }
216
+
217
+ /**
218
+ * Ensure a parent directory exists before writing JSON.
219
+ *
220
+ * @param {string} filePath file path to create
221
+ * @param {object} payload JSON payload
222
+ * @returns {void}
223
+ */
224
+ function writeJsonFile(filePath, payload) {
225
+ writeTextFile(filePath, `${JSON.stringify(payload, null, 2)}\n`);
226
+ }
227
+
228
+ function sleep(ms) {
229
+ return new Promise((resolvePromise) => {
230
+ setTimeout(resolvePromise, ms);
231
+ });
232
+ }
233
+
234
+ function isPathWithin(parentDir, childPath) {
235
+ const normalizePath = (candidatePath) => {
236
+ try {
237
+ return realpathSync.native
238
+ ? realpathSync.native(candidatePath)
239
+ : realpathSync(candidatePath);
240
+ } catch {
241
+ return resolve(candidatePath);
242
+ }
243
+ };
244
+ const normalizedChild = normalizePath(childPath);
245
+ const candidateParents = [parentDir];
246
+ if (process.platform !== "win32") {
247
+ candidateParents.push("/tmp");
248
+ candidateParents.push("/private/tmp");
249
+ }
250
+ return candidateParents.some((candidateParent) => {
251
+ const normalizedParent = normalizePath(candidateParent);
252
+ return (
253
+ normalizedChild === normalizedParent ||
254
+ normalizedChild.startsWith(`${normalizedParent}/`)
255
+ );
256
+ });
257
+ }
258
+
259
+ function isTemporaryWorkspaceDir(workspaceDir) {
260
+ return workspaceDir ? isPathWithin(getTmpDir(), workspaceDir) : false;
261
+ }
262
+
263
+ function prepareWorkspaceContext(options = {}) {
264
+ if (!options.workspaceDir) {
265
+ return {
266
+ cleanupOnFinish: false,
267
+ workspaceDir: undefined,
268
+ };
269
+ }
270
+ const workspaceDir = resolve(options.workspaceDir);
271
+ const existed = safeExistsSync(workspaceDir);
272
+ if (!existed) {
273
+ safeMkdirSync(workspaceDir, { recursive: true });
274
+ }
275
+ return {
276
+ cleanupOnFinish: !existed && isTemporaryWorkspaceDir(workspaceDir),
277
+ workspaceDir,
278
+ };
279
+ }
280
+
281
+ function getWorkspaceTargetDir(workspaceDir, target) {
282
+ return join(resolve(workspaceDir), targetSlug(target));
283
+ }
284
+
285
+ function getWorkspaceCachePaths(workspaceDir, target) {
286
+ const targetDir = getWorkspaceTargetDir(workspaceDir, target);
287
+ const cacheDir = join(targetDir, AUDIT_CACHE_DIRNAME);
288
+ return {
289
+ cacheDir,
290
+ metadataFile: join(cacheDir, AUDIT_CACHE_META_FILE),
291
+ sourceBomFile: join(cacheDir, AUDIT_CACHE_BOM_FILE),
292
+ targetDir,
293
+ };
294
+ }
295
+
296
+ function loadCachedChildBom(workspaceDir, target) {
297
+ if (!workspaceDir) {
298
+ return undefined;
299
+ }
300
+ const cachePaths = getWorkspaceCachePaths(workspaceDir, target);
301
+ if (!safeExistsSync(cachePaths.sourceBomFile)) {
302
+ return undefined;
303
+ }
304
+ try {
305
+ const bomJson = loadBomFile(cachePaths.sourceBomFile);
306
+ let metadata = {};
307
+ if (safeExistsSync(cachePaths.metadataFile)) {
308
+ metadata = JSON.parse(readFileSync(cachePaths.metadataFile, "utf8"));
309
+ }
310
+ const scanDir = metadata.scanDirRelative
311
+ ? resolve(cachePaths.targetDir, metadata.scanDirRelative)
312
+ : cachePaths.targetDir;
313
+ return {
314
+ bomJson,
315
+ cacheDir: cachePaths.cacheDir,
316
+ repoUrl: metadata.repoUrl,
317
+ resolution: metadata.resolution,
318
+ scanDir,
319
+ sourceDirectoryConfidence: metadata.sourceDirectoryConfidence || "medium",
320
+ versionMatched: metadata.versionMatched !== false,
321
+ };
322
+ } catch {
323
+ return undefined;
324
+ }
325
+ }
326
+
327
+ function writeCachedChildBom(workspaceDir, target, payload) {
328
+ if (!workspaceDir || !payload?.bomJson) {
329
+ return;
330
+ }
331
+ const cachePaths = getWorkspaceCachePaths(workspaceDir, target);
332
+ safeMkdirSync(cachePaths.cacheDir, { recursive: true });
333
+ writeJsonFile(cachePaths.sourceBomFile, payload.bomJson);
334
+ writeJsonFile(cachePaths.metadataFile, {
335
+ generatedAt: new Date().toISOString(),
336
+ repoUrl: payload.repoUrl,
337
+ resolution: payload.resolution,
338
+ scanDirRelative: payload.scanDir
339
+ ? relative(cachePaths.targetDir, resolve(payload.scanDir)) || "."
340
+ : ".",
341
+ sourceDirectoryConfidence: payload.sourceDirectoryConfidence,
342
+ versionMatched: payload.versionMatched,
343
+ });
344
+ }
345
+
346
+ function persistAuditArtifacts(result, options, sourceBomJson) {
347
+ if (!options.reportsDir) {
348
+ return result;
349
+ }
350
+ const resultDir = join(
351
+ resolve(options.reportsDir),
352
+ targetSlug(result.target),
353
+ );
354
+ safeMkdirSync(resultDir, { recursive: true });
355
+ result.reportDir = resultDir;
356
+ result.findingsFile = join(resultDir, "findings.json");
357
+ result.summaryFile = join(resultDir, "summary.json");
358
+ if (sourceBomJson) {
359
+ result.sourceBomFile = join(resultDir, "source-bom.json");
360
+ writeJsonFile(result.sourceBomFile, sourceBomJson);
361
+ }
362
+ writeJsonFile(result.findingsFile, result.findings || []);
363
+ writeJsonFile(result.summaryFile, {
364
+ assessment: result.assessment,
365
+ cacheHit: result.cacheHit || false,
366
+ error: result.error,
367
+ errorType: result.errorType,
368
+ findingsCount: result.findings?.length || 0,
369
+ repoUrl: result.repoUrl,
370
+ sourceDirectoryConfidence: result.sourceDirectoryConfidence,
371
+ status: result.status,
372
+ target: result.target,
373
+ });
374
+ return result;
375
+ }
376
+
377
+ /**
378
+ * Emit a progress event when a callback is configured.
379
+ *
380
+ * @param {object} options CLI options
381
+ * @param {object} event progress event payload
382
+ * @returns {void}
383
+ */
384
+ function emitProgress(options, event) {
385
+ if (typeof options?.onProgress === "function") {
386
+ options.onProgress(event);
387
+ }
388
+ }
389
+
390
+ function buildPredictiveAuditEstimate(selectedTargets) {
391
+ if (selectedTargets >= VERY_LARGE_PREDICTIVE_AUDIT_THRESHOLD) {
392
+ return "This may take 10+ minutes depending on repository lookups and child SBOM generation.";
393
+ }
394
+ if (selectedTargets >= LARGE_PREDICTIVE_AUDIT_THRESHOLD) {
395
+ return "This may take several minutes depending on repository lookups and child SBOM generation.";
396
+ }
397
+ return undefined;
398
+ }
399
+
400
+ function buildPredictiveAuditPreflightMessage(extractedTargets, options) {
401
+ const selectedTargets = extractedTargets?.targets?.length || 0;
402
+ const availableTargets = extractedTargets?.stats?.availableTargets || 0;
403
+ const requiredTargets = extractedTargets?.stats?.requiredTargets || 0;
404
+ const trustedTargetsExcluded =
405
+ extractedTargets?.stats?.trustedTargetsExcluded || 0;
406
+ const truncatedTargets = extractedTargets?.stats?.truncatedTargets || 0;
407
+ const estimate = buildPredictiveAuditEstimate(selectedTargets);
408
+ const trustedHint = options?.trustedSelectionHelp
409
+ ? ` ${options.trustedSelectionHelp}`
410
+ : "";
411
+ const trustedExclusionMessage = trustedTargetsExcluded
412
+ ? ` Skipping ${trustedTargetsExcluded} trusted-publishing-backed package(s) by default.${trustedHint}`
413
+ : "";
414
+ if (!estimate && availableTargets < LARGE_PREDICTIVE_AUDIT_THRESHOLD) {
415
+ return trustedTargetsExcluded ? trustedExclusionMessage.trim() : undefined;
416
+ }
417
+ if (options?.scope === "required") {
418
+ return `Predictive audit will scan ${selectedTargets} required package(s). ${estimate || "Large required-only scans may still take a while depending on repository lookups and child SBOM generation."}${trustedExclusionMessage}`;
419
+ }
420
+ if (truncatedTargets > 0) {
421
+ const additionalTargets = Math.max(0, selectedTargets - requiredTargets);
422
+ return `Predictive audit selected ${selectedTargets} of ${availableTargets} package(s) (${requiredTargets} required${additionalTargets ? ` + ${additionalTargets} additional` : ""}) using required-first prioritization. ${estimate || "This run was trimmed to keep audit time reasonable."}${trustedExclusionMessage}`;
423
+ }
424
+ return `Predictive audit will scan ${selectedTargets} package(s). ${estimate || "Large predictive audits may still take a while depending on repository lookups and child SBOM generation."}${trustedExclusionMessage}`;
425
+ }
426
+
427
+ /**
428
+ * Read a custom property from a target descriptor.
429
+ *
430
+ * @param {object} target audit target
431
+ * @param {string} propertyName property name
432
+ * @returns {string | undefined} property value
433
+ */
434
+ function getTargetProperty(target, propertyName) {
435
+ return target?.properties?.find((property) => property.name === propertyName)
436
+ ?.value;
437
+ }
438
+
439
+ function getTargetNumberProperty(target, propertyName) {
440
+ const value = getTargetProperty(target, propertyName);
441
+ if (!value) {
442
+ return undefined;
443
+ }
444
+ const numericValue = Number(value);
445
+ return Number.isFinite(numericValue) ? numericValue : undefined;
446
+ }
447
+
448
+ function getTargetTimestampProperty(target, propertyName) {
449
+ const value = getTargetProperty(target, propertyName);
450
+ if (!value) {
451
+ return undefined;
452
+ }
453
+ const timestamp = Date.parse(value);
454
+ return Number.isNaN(timestamp) ? undefined : timestamp;
455
+ }
456
+
457
+ function getTargetListProperty(target, propertyName) {
458
+ const value = getTargetProperty(target, propertyName);
459
+ if (!value) {
460
+ return [];
461
+ }
462
+ return [
463
+ ...new Set(
464
+ value
465
+ .split(",")
466
+ .map((entry) => entry.trim())
467
+ .filter(Boolean),
468
+ ),
469
+ ];
470
+ }
471
+
472
+ function isEstablishedPackage(target, propertyPrefix) {
473
+ const packageCreatedTime = getTargetTimestampProperty(
474
+ target,
475
+ `${propertyPrefix}:packageCreatedTime`,
476
+ );
477
+ const versionCount = getTargetNumberProperty(
478
+ target,
479
+ `${propertyPrefix}:versionCount`,
480
+ );
481
+ if (!packageCreatedTime || !versionCount) {
482
+ return false;
483
+ }
484
+ const packageAgeMs = Date.now() - packageCreatedTime;
485
+ return packageAgeMs >= 1000 * 60 * 60 * 24 * 30 && versionCount >= 3;
486
+ }
487
+
488
+ function isRecentRelease(target, propertyPrefix) {
489
+ const publishTime = getTargetTimestampProperty(
490
+ target,
491
+ `${propertyPrefix}:publishTime`,
492
+ );
493
+ if (!publishTime) {
494
+ return false;
495
+ }
496
+ const releaseAgeMs = Date.now() - publishTime;
497
+ return releaseAgeMs >= 0 && releaseAgeMs <= 1000 * 60 * 60 * 72;
498
+ }
499
+
500
+ function hasPublisherDrift(target, propertyPrefix) {
501
+ return (
502
+ getTargetProperty(target, `${propertyPrefix}:publisherDrift`) === "true"
503
+ );
504
+ }
505
+
506
+ function hasMaintainerSetDrift(target, propertyPrefix) {
507
+ return (
508
+ getTargetProperty(target, `${propertyPrefix}:maintainerSetDrift`) ===
509
+ "true" ||
510
+ getTargetProperty(target, `${propertyPrefix}:uploaderSetDrift`) === "true"
511
+ );
512
+ }
513
+
514
+ function hasPartialIdentitySetDrift(target, propertyPrefix) {
515
+ const explicitPropertyName =
516
+ propertyPrefix === "cdx:npm"
517
+ ? `${propertyPrefix}:maintainerSetPartialDrift`
518
+ : `${propertyPrefix}:uploaderSetPartialDrift`;
519
+ if (getTargetProperty(target, explicitPropertyName) === "true") {
520
+ return true;
521
+ }
522
+ const currentPropertyName =
523
+ propertyPrefix === "cdx:npm"
524
+ ? `${propertyPrefix}:maintainerSet`
525
+ : `${propertyPrefix}:uploaderSet`;
526
+ const priorPropertyName =
527
+ propertyPrefix === "cdx:npm"
528
+ ? `${propertyPrefix}:priorMaintainerSet`
529
+ : `${propertyPrefix}:priorUploaderSet`;
530
+ const currentSet = getTargetListProperty(target, currentPropertyName);
531
+ const priorSet = getTargetListProperty(target, priorPropertyName);
532
+ if (!currentSet.length || !priorSet.length) {
533
+ return false;
534
+ }
535
+ const priorValues = new Set(priorSet);
536
+ const overlapCount = currentSet.filter((value) =>
537
+ priorValues.has(value),
538
+ ).length;
539
+ if (overlapCount === 0) {
540
+ return false;
541
+ }
542
+ const unionCount = new Set([...currentSet, ...priorSet]).size;
543
+ return (
544
+ overlapCount < unionCount &&
545
+ (overlapCount < currentSet.length || overlapCount < priorSet.length)
546
+ );
547
+ }
548
+
549
+ function hasDormantReleaseGapAnomaly(target, propertyPrefix) {
550
+ const currentGapDays = getTargetNumberProperty(
551
+ target,
552
+ `${propertyPrefix}:releaseGapDays`,
553
+ );
554
+ const baselineGapDays = getTargetNumberProperty(
555
+ target,
556
+ `${propertyPrefix}:releaseGapBaselineDays`,
557
+ );
558
+ const sampleSize = getTargetNumberProperty(
559
+ target,
560
+ `${propertyPrefix}:releaseGapSampleSize`,
561
+ );
562
+ if (!currentGapDays || !baselineGapDays || !sampleSize || sampleSize < 3) {
563
+ return false;
564
+ }
565
+ return currentGapDays >= Math.max(90, baselineGapDays * 8);
566
+ }
567
+
568
+ function hasCompressedCadence(target, propertyPrefix) {
569
+ if (
570
+ getTargetProperty(target, `${propertyPrefix}:compressedCadence`) === "true"
571
+ ) {
572
+ return true;
573
+ }
574
+ const currentGapDays = getTargetNumberProperty(
575
+ target,
576
+ `${propertyPrefix}:releaseGapDays`,
577
+ );
578
+ const baselineGapDays = getTargetNumberProperty(
579
+ target,
580
+ `${propertyPrefix}:releaseGapBaselineDays`,
581
+ );
582
+ const sampleSize = getTargetNumberProperty(
583
+ target,
584
+ `${propertyPrefix}:releaseGapSampleSize`,
585
+ );
586
+ if (
587
+ currentGapDays === undefined ||
588
+ baselineGapDays === undefined ||
589
+ sampleSize === undefined ||
590
+ sampleSize < 3 ||
591
+ currentGapDays <= 0 ||
592
+ baselineGapDays <= 0 ||
593
+ baselineGapDays < 21
594
+ ) {
595
+ return false;
596
+ }
597
+ return currentGapDays <= 14 && currentGapDays / baselineGapDays <= 0.33;
598
+ }
599
+
600
+ /**
601
+ * Build low-noise provenance-aware contextual findings from the root BOM target.
602
+ *
603
+ * These are intentionally conservative and only fire when there is explicit risk
604
+ * posture already present in the target metadata.
605
+ *
606
+ * @param {object} target audit target
607
+ * @returns {object[]} contextual findings
608
+ */
609
+ export function buildTargetContextFindings(target) {
610
+ const findings = [];
611
+ const hasTrustedPublishing = hasTrustedPublishingProperties(
612
+ target?.properties,
613
+ );
614
+ const hasProvenanceEvidence = hasRegistryProvenanceEvidenceProperties(
615
+ target?.properties,
616
+ );
617
+ if (target.type === "npm") {
618
+ const hasInstallScript =
619
+ getTargetProperty(target, "cdx:npm:hasInstallScript") === "true";
620
+ const establishedPackage = isEstablishedPackage(target, "cdx:npm");
621
+ const recentRelease = isRecentRelease(target, "cdx:npm");
622
+ const publisherDrift = hasPublisherDrift(target, "cdx:npm");
623
+ const maintainerSetDrift = hasMaintainerSetDrift(target, "cdx:npm");
624
+ const partialMaintainerSetDrift = hasPartialIdentitySetDrift(
625
+ target,
626
+ "cdx:npm",
627
+ );
628
+ const dormantReleaseGapAnomaly = hasDormantReleaseGapAnomaly(
629
+ target,
630
+ "cdx:npm",
631
+ );
632
+ const compressedCadence = hasCompressedCadence(target, "cdx:npm");
633
+ if (
634
+ target.version &&
635
+ hasInstallScript &&
636
+ !hasTrustedPublishing &&
637
+ !hasProvenanceEvidence
638
+ ) {
639
+ findings.push({
640
+ category: "package-integrity",
641
+ description:
642
+ "Install-time execution combined with missing registry-visible provenance raises future tampering risk.",
643
+ location: {
644
+ bomRef: target.bomRefs?.[0],
645
+ purl: target.purl,
646
+ },
647
+ message: `npm package '${target.name}@${target.version}' has install-time execution hooks but no registry-visible trusted publishing or provenance evidence.`,
648
+ mitigation:
649
+ "Prefer versions with registry-visible provenance evidence, review install scripts carefully, and pin/allowlist publishers for high-risk packages.",
650
+ ruleId: "PROV-001",
651
+ severity: "medium",
652
+ });
653
+ }
654
+ if (
655
+ target.version &&
656
+ establishedPackage &&
657
+ recentRelease &&
658
+ hasInstallScript &&
659
+ !hasTrustedPublishing &&
660
+ !hasProvenanceEvidence
661
+ ) {
662
+ findings.push({
663
+ category: "package-integrity",
664
+ description:
665
+ "A very recent release on a mature package, combined with install-time execution and missing provenance, deserves extra scrutiny before adoption.",
666
+ location: {
667
+ bomRef: target.bomRefs?.[0],
668
+ purl: target.purl,
669
+ },
670
+ message: `npm package '${target.name}@${target.version}' is a very recent release on an established package and still lacks registry-visible provenance.`,
671
+ mitigation:
672
+ "Delay adoption briefly, verify publisher identity, and prefer registry-visible provenance for high-risk packages with install hooks.",
673
+ ruleId: "PROV-003",
674
+ severity: "low",
675
+ });
676
+ }
677
+ if (
678
+ target.version &&
679
+ establishedPackage &&
680
+ publisherDrift &&
681
+ hasInstallScript &&
682
+ !hasTrustedPublishing &&
683
+ !hasProvenanceEvidence
684
+ ) {
685
+ findings.push({
686
+ category: "package-integrity",
687
+ description:
688
+ "Publisher drift on mature packages can be legitimate, but becomes more concerning when install-time execution is present and provenance is weak.",
689
+ location: {
690
+ bomRef: target.bomRefs?.[0],
691
+ purl: target.purl,
692
+ },
693
+ message: `npm package '${target.name}@${target.version}' was published by a different identity than the prior release and lacks registry-visible provenance.`,
694
+ mitigation:
695
+ "Review maintainer changes, compare the prior release publisher, and validate provenance before upgrading execution-capable packages.",
696
+ ruleId: "PROV-004",
697
+ severity: "medium",
698
+ });
699
+ }
700
+ if (
701
+ target.version &&
702
+ establishedPackage &&
703
+ maintainerSetDrift &&
704
+ hasInstallScript &&
705
+ !hasTrustedPublishing &&
706
+ !hasProvenanceEvidence
707
+ ) {
708
+ findings.push({
709
+ category: "package-integrity",
710
+ description:
711
+ "Maintainer-set drift on execution-capable packages is a triage signal when the resolved release also lacks registry-visible provenance.",
712
+ location: {
713
+ bomRef: target.bomRefs?.[0],
714
+ purl: target.purl,
715
+ },
716
+ message: `npm package '${target.name}@${target.version}' has a fully different maintainer identity set than the prior release and lacks registry-visible provenance.`,
717
+ mitigation:
718
+ "Compare the prior and current maintainer sets, verify maintainer transitions, and prefer releases with provenance before upgrading packages with install hooks.",
719
+ ruleId: "PROV-007",
720
+ severity: "medium",
721
+ });
722
+ }
723
+ if (
724
+ target.version &&
725
+ establishedPackage &&
726
+ partialMaintainerSetDrift &&
727
+ !maintainerSetDrift &&
728
+ hasInstallScript &&
729
+ !hasTrustedPublishing &&
730
+ !hasProvenanceEvidence
731
+ ) {
732
+ findings.push({
733
+ category: "package-integrity",
734
+ description:
735
+ "Partial maintainer-set drift is a low-severity triage signal when execution-capable releases retain some identities but also introduce maintainer churn without registry-visible provenance.",
736
+ location: {
737
+ bomRef: target.bomRefs?.[0],
738
+ purl: target.purl,
739
+ },
740
+ message: `npm package '${target.name}@${target.version}' retains only part of the prior maintainer identity set and lacks registry-visible provenance.`,
741
+ mitigation:
742
+ "Review which maintainer identities changed, compare against the prior release, and validate the transition before upgrading packages with install hooks.",
743
+ ruleId: "PROV-011",
744
+ severity: "low",
745
+ });
746
+ }
747
+ if (
748
+ target.version &&
749
+ establishedPackage &&
750
+ dormantReleaseGapAnomaly &&
751
+ hasInstallScript &&
752
+ !hasTrustedPublishing &&
753
+ !hasProvenanceEvidence
754
+ ) {
755
+ findings.push({
756
+ category: "package-integrity",
757
+ description:
758
+ "A long dormant gap followed by a new execution-capable release can warrant a short review window when provenance is missing.",
759
+ location: {
760
+ bomRef: target.bomRefs?.[0],
761
+ purl: target.purl,
762
+ },
763
+ message: `npm package '${target.name}@${target.version}' arrived after an unusually long release gap and lacks registry-visible provenance.`,
764
+ mitigation:
765
+ "Review the release diff, compare against the prior version, and validate maintainer continuity before adopting after long dormancy.",
766
+ ruleId: "PROV-008",
767
+ severity: "low",
768
+ });
769
+ }
770
+ if (
771
+ target.version &&
772
+ establishedPackage &&
773
+ compressedCadence &&
774
+ hasInstallScript &&
775
+ !hasTrustedPublishing &&
776
+ !hasProvenanceEvidence
777
+ ) {
778
+ findings.push({
779
+ category: "package-integrity",
780
+ description:
781
+ "A materially faster-than-usual release on a mature execution-capable package is a low-severity review signal when registry-visible provenance is absent.",
782
+ location: {
783
+ bomRef: target.bomRefs?.[0],
784
+ purl: target.purl,
785
+ },
786
+ message: `npm package '${target.name}@${target.version}' arrived materially faster than its prior release cadence and lacks registry-visible provenance.`,
787
+ mitigation:
788
+ "Review the release diff, compare the release timing against prior cadence, and validate the publisher transition before rapid upgrades of execution-capable packages.",
789
+ ruleId: "PROV-012",
790
+ severity: "low",
791
+ });
792
+ }
793
+ }
794
+ if (target.type === "pypi") {
795
+ const registry = getTargetProperty(target, "cdx:pypi:registry");
796
+ const isDefaultRegistry =
797
+ !registry ||
798
+ ["https://pypi.org", "https://pypi.org/simple"].includes(registry);
799
+ const uploaderVerified =
800
+ getTargetProperty(target, "cdx:pypi:uploaderVerified") === "true";
801
+ const establishedPackage = isEstablishedPackage(target, "cdx:pypi");
802
+ const recentRelease = isRecentRelease(target, "cdx:pypi");
803
+ const publisherDrift = hasPublisherDrift(target, "cdx:pypi");
804
+ const maintainerSetDrift = hasMaintainerSetDrift(target, "cdx:pypi");
805
+ const partialMaintainerSetDrift = hasPartialIdentitySetDrift(
806
+ target,
807
+ "cdx:pypi",
808
+ );
809
+ const dormantReleaseGapAnomaly = hasDormantReleaseGapAnomaly(
810
+ target,
811
+ "cdx:pypi",
812
+ );
813
+ const compressedCadence = hasCompressedCadence(target, "cdx:pypi");
814
+ if (
815
+ target.version &&
816
+ isDefaultRegistry &&
817
+ !hasTrustedPublishing &&
818
+ !hasProvenanceEvidence &&
819
+ !uploaderVerified
820
+ ) {
821
+ findings.push({
822
+ category: "package-integrity",
823
+ description:
824
+ "Default-registry PyPI packages without provenance or verified uploader context are weaker candidates for publisher-trust decisions.",
825
+ location: {
826
+ bomRef: target.bomRefs?.[0],
827
+ purl: target.purl,
828
+ },
829
+ message: `PyPI package '${target.name}@${target.version}' lacks registry-visible provenance and uploader verification signals.`,
830
+ mitigation:
831
+ "Prefer releases with provenance evidence or verified uploader metadata, especially for sensitive or newly introduced dependencies.",
832
+ ruleId: "PROV-002",
833
+ severity: "low",
834
+ });
835
+ }
836
+ if (
837
+ target.version &&
838
+ isDefaultRegistry &&
839
+ establishedPackage &&
840
+ recentRelease &&
841
+ !hasTrustedPublishing &&
842
+ !hasProvenanceEvidence &&
843
+ !uploaderVerified
844
+ ) {
845
+ findings.push({
846
+ category: "package-integrity",
847
+ description:
848
+ "Very recent releases on mature packages can benefit from a short review window when provenance and uploader-verification signals are absent.",
849
+ location: {
850
+ bomRef: target.bomRefs?.[0],
851
+ purl: target.purl,
852
+ },
853
+ message: `PyPI package '${target.name}@${target.version}' is a very recent release on an established package without provenance or uploader verification signals.`,
854
+ mitigation:
855
+ "Delay adoption briefly, compare the release to the previous known-good version, and prefer verified/provenance-backed uploads for sensitive dependencies.",
856
+ ruleId: "PROV-005",
857
+ severity: "low",
858
+ });
859
+ }
860
+ if (
861
+ target.version &&
862
+ isDefaultRegistry &&
863
+ establishedPackage &&
864
+ publisherDrift &&
865
+ !hasTrustedPublishing &&
866
+ !hasProvenanceEvidence &&
867
+ !uploaderVerified
868
+ ) {
869
+ findings.push({
870
+ category: "package-integrity",
871
+ description:
872
+ "Uploader drift on established PyPI packages is usually a triage signal, but becomes more meaningful when provenance and verification are missing.",
873
+ location: {
874
+ bomRef: target.bomRefs?.[0],
875
+ purl: target.purl,
876
+ },
877
+ message: `PyPI package '${target.name}@${target.version}' was uploaded by a different identity than the prior release and lacks provenance or uploader verification signals.`,
878
+ mitigation:
879
+ "Review the uploader change, compare the prior release uploader, and validate project ownership before upgrading critical dependencies.",
880
+ ruleId: "PROV-006",
881
+ severity: "low",
882
+ });
883
+ }
884
+ if (
885
+ target.version &&
886
+ isDefaultRegistry &&
887
+ establishedPackage &&
888
+ maintainerSetDrift &&
889
+ !hasTrustedPublishing &&
890
+ !hasProvenanceEvidence &&
891
+ !uploaderVerified
892
+ ) {
893
+ findings.push({
894
+ category: "package-integrity",
895
+ description:
896
+ "Uploader-set drift on established PyPI packages is a triage signal when provenance and uploader verification are absent.",
897
+ location: {
898
+ bomRef: target.bomRefs?.[0],
899
+ purl: target.purl,
900
+ },
901
+ message: `PyPI package '${target.name}@${target.version}' has a fully different uploader identity set than the prior release and lacks provenance or uploader verification signals.`,
902
+ mitigation:
903
+ "Review uploader transitions, compare the prior release uploader set, and validate project ownership before upgrading sensitive dependencies.",
904
+ ruleId: "PROV-009",
905
+ severity: "low",
906
+ });
907
+ }
908
+ if (
909
+ target.version &&
910
+ isDefaultRegistry &&
911
+ establishedPackage &&
912
+ partialMaintainerSetDrift &&
913
+ !maintainerSetDrift &&
914
+ !hasTrustedPublishing &&
915
+ !hasProvenanceEvidence &&
916
+ !uploaderVerified
917
+ ) {
918
+ findings.push({
919
+ category: "package-integrity",
920
+ description:
921
+ "Partial uploader-set drift is a low-severity review signal on established PyPI packages when provenance and uploader verification are absent.",
922
+ location: {
923
+ bomRef: target.bomRefs?.[0],
924
+ purl: target.purl,
925
+ },
926
+ message: `PyPI package '${target.name}@${target.version}' retains only part of the prior uploader identity set and lacks provenance or uploader verification signals.`,
927
+ mitigation:
928
+ "Review which uploader identities changed, compare the release against the prior version, and validate project ownership before upgrading sensitive dependencies.",
929
+ ruleId: "PROV-013",
930
+ severity: "low",
931
+ });
932
+ }
933
+ if (
934
+ target.version &&
935
+ isDefaultRegistry &&
936
+ establishedPackage &&
937
+ dormantReleaseGapAnomaly &&
938
+ !hasTrustedPublishing &&
939
+ !hasProvenanceEvidence &&
940
+ !uploaderVerified
941
+ ) {
942
+ findings.push({
943
+ category: "package-integrity",
944
+ description:
945
+ "Established packages resurfacing after a long dormant gap benefit from extra review when provenance is weak.",
946
+ location: {
947
+ bomRef: target.bomRefs?.[0],
948
+ purl: target.purl,
949
+ },
950
+ message: `PyPI package '${target.name}@${target.version}' followed an unusually long release gap and lacks provenance or uploader verification signals.`,
951
+ mitigation:
952
+ "Compare the release to the prior known-good version and review maintainership continuity before adopting after long dormancy.",
953
+ ruleId: "PROV-010",
954
+ severity: "low",
955
+ });
956
+ }
957
+ if (
958
+ target.version &&
959
+ isDefaultRegistry &&
960
+ establishedPackage &&
961
+ compressedCadence &&
962
+ !hasTrustedPublishing &&
963
+ !hasProvenanceEvidence &&
964
+ !uploaderVerified
965
+ ) {
966
+ findings.push({
967
+ category: "package-integrity",
968
+ description:
969
+ "Materially faster-than-usual release timing is a low-severity triage signal on mature PyPI packages when provenance and uploader verification remain weak.",
970
+ location: {
971
+ bomRef: target.bomRefs?.[0],
972
+ purl: target.purl,
973
+ },
974
+ message: `PyPI package '${target.name}@${target.version}' arrived materially faster than its prior release cadence and lacks provenance or uploader verification signals.`,
975
+ mitigation:
976
+ "Compare the release timing and contents against prior versions, then validate uploader continuity before rapid upgrades of sensitive dependencies.",
977
+ ruleId: "PROV-014",
978
+ severity: "low",
979
+ });
980
+ }
981
+ }
982
+ return findings;
983
+ }
984
+
985
+ /**
986
+ * Clone a repository into a deterministic workspace directory.
987
+ *
988
+ * @param {string} repoUrl repository URL
989
+ * @param {string} cloneDir target clone directory
990
+ * @param {string | undefined} gitRef git ref to checkout
991
+ * @returns {void}
992
+ */
993
+ function cloneRepositoryToDir(repoUrl, cloneDir, gitRef) {
994
+ const gitArgs = [
995
+ "-c",
996
+ "alias.clone=",
997
+ "-c",
998
+ "core.fsmonitor=false",
999
+ "-c",
1000
+ "safe.bareRepository=explicit",
1001
+ "-c",
1002
+ "core.hooksPath=/dev/null",
1003
+ "clone",
1004
+ "--template=",
1005
+ repoUrl,
1006
+ "--depth",
1007
+ "1",
1008
+ cloneDir,
1009
+ ];
1010
+ if (gitRef) {
1011
+ const cloneIndex = gitArgs.indexOf("clone");
1012
+ gitArgs.splice(cloneIndex + 1, 0, "--branch", gitRef);
1013
+ }
1014
+ const result = hardenedGitCommand(gitArgs);
1015
+ if (result.status !== 0) {
1016
+ const stderr = result.stderr
1017
+ ? result.stderr.toString()
1018
+ : "unknown git clone error";
1019
+ const error = new Error(stderr.trim());
1020
+ error.retryable =
1021
+ /(timed out|unable to connect|could not resolve host|network is unreachable|connection reset|connection refused|temporary failure|remote end hung up unexpectedly|http 5\d\d|tls|econnreset|econnrefused|etimedout)/i.test(
1022
+ stderr,
1023
+ );
1024
+ error.errorType = error.retryable ? "network" : "clone";
1025
+ throw error;
1026
+ }
1027
+ }
1028
+
1029
+ async function cloneRepositoryToDirWithRetry(repoUrl, cloneDir, gitRef) {
1030
+ let lastError;
1031
+ for (let attempt = 0; attempt <= CLONE_RETRY_DELAYS_MS.length; attempt += 1) {
1032
+ try {
1033
+ cloneRepositoryToDir(repoUrl, cloneDir, gitRef);
1034
+ return;
1035
+ } catch (error) {
1036
+ lastError = error;
1037
+ rmSync(cloneDir, { force: true, recursive: true });
1038
+ if (!error?.retryable || attempt >= CLONE_RETRY_DELAYS_MS.length) {
1039
+ break;
1040
+ }
1041
+ await sleep(CLONE_RETRY_DELAYS_MS[attempt]);
1042
+ }
1043
+ }
1044
+ const sanitizedRepoUrl = sanitizeRemoteUrlForLogs(repoUrl);
1045
+ const message = lastError?.message || "unknown git clone error";
1046
+ const error = new Error(
1047
+ `Unable to clone '${sanitizedRepoUrl}' after ${CLONE_RETRY_DELAYS_MS.length + 1} attempt(s): ${message}`,
1048
+ );
1049
+ error.errorType = lastError?.errorType || "clone";
1050
+ error.retryable = Boolean(lastError?.retryable);
1051
+ throw error;
1052
+ }
1053
+
1054
+ /**
1055
+ * Reuse or create a checkout for a target repository.
1056
+ *
1057
+ * @param {object} target audit target
1058
+ * @param {object} resolution resolved repository metadata
1059
+ * @param {string | undefined} workspaceDir workspace directory
1060
+ * @param {string | undefined} gitRef git ref to checkout
1061
+ * @returns {{ cleanup: boolean, cloneDir: string, reused: boolean }} checkout info
1062
+ */
1063
+ async function ensureCheckout(target, resolution, workspaceDir, gitRef) {
1064
+ if (!workspaceDir) {
1065
+ const cloneDir = mkdtempSync(join(getTmpDir(), `${targetSlug(target)}-`));
1066
+ await cloneRepositoryToDirWithRetry(resolution.repoUrl, cloneDir, gitRef);
1067
+ return {
1068
+ cleanup: true,
1069
+ cloneDir,
1070
+ reused: false,
1071
+ };
1072
+ }
1073
+ const resolvedWorkspaceDir = resolve(workspaceDir);
1074
+ if (!safeExistsSync(resolvedWorkspaceDir)) {
1075
+ safeMkdirSync(resolvedWorkspaceDir, { recursive: true });
1076
+ }
1077
+ const cloneDir = join(resolvedWorkspaceDir, targetSlug(target));
1078
+ if (safeExistsSync(join(cloneDir, ".git"))) {
1079
+ return {
1080
+ cleanup: false,
1081
+ cloneDir,
1082
+ reused: true,
1083
+ };
1084
+ }
1085
+ if (safeExistsSync(cloneDir)) {
1086
+ rmSync(cloneDir, { force: true, recursive: true });
1087
+ }
1088
+ await cloneRepositoryToDirWithRetry(resolution.repoUrl, cloneDir, gitRef);
1089
+ return {
1090
+ cleanup: false,
1091
+ cloneDir,
1092
+ reused: false,
1093
+ };
1094
+ }
1095
+
1096
+ /**
1097
+ * Extract an expected package name from Python packaging metadata.
1098
+ *
1099
+ * @param {string} filePath metadata file path
1100
+ * @returns {string | undefined} discovered package name
1101
+ */
1102
+ function readPythonPackageName(filePath) {
1103
+ let fileContent;
1104
+ try {
1105
+ fileContent = readFileSync(filePath, "utf8");
1106
+ } catch {
1107
+ return undefined;
1108
+ }
1109
+ const patterns = [
1110
+ /(^|\n)\s*name\s*=\s*["']([^"'\n]+)["']/m,
1111
+ /(^|\n)\s*name\s*=\s*([^\n#]+)/m,
1112
+ /setup\s*\([^)]*name\s*=\s*["']([^"']+)["']/ms,
1113
+ ];
1114
+ for (const pattern of patterns) {
1115
+ const match = fileContent.match(pattern);
1116
+ if (!match) {
1117
+ continue;
1118
+ }
1119
+ const packageName = (match[2] || match[1] || "").trim();
1120
+ if (packageName) {
1121
+ return packageName;
1122
+ }
1123
+ }
1124
+ return undefined;
1125
+ }
1126
+
1127
+ /**
1128
+ * Resolve the most specific Python package directory inside a cloned repo.
1129
+ *
1130
+ * @param {string} cloneDir cloned repository root
1131
+ * @param {object} target audit target
1132
+ * @returns {{ confidence: string, scanDir: string }} selected directory and confidence
1133
+ */
1134
+ export function resolvePythonSourceDirectory(cloneDir, target) {
1135
+ const normalizedTargetName = normalizePackageName(target.name);
1136
+ const queue = [cloneDir];
1137
+ const matches = [];
1138
+ while (queue.length) {
1139
+ const currentDir = queue.shift();
1140
+ let entries = [];
1141
+ try {
1142
+ entries = readdirSync(currentDir, { withFileTypes: true });
1143
+ } catch {
1144
+ continue;
1145
+ }
1146
+ for (const entry of entries) {
1147
+ const entryPath = join(currentDir, entry.name);
1148
+ if (entry.isDirectory()) {
1149
+ if (!PYTHON_SKIP_DIRS.has(entry.name)) {
1150
+ queue.push(entryPath);
1151
+ }
1152
+ continue;
1153
+ }
1154
+ if (!entry.isFile() || !PYTHON_METADATA_FILES.includes(entry.name)) {
1155
+ continue;
1156
+ }
1157
+ const packageName = readPythonPackageName(entryPath);
1158
+ if (normalizePackageName(packageName) === normalizedTargetName) {
1159
+ matches.push(currentDir);
1160
+ }
1161
+ }
1162
+ }
1163
+ if (!matches.length) {
1164
+ return {
1165
+ confidence: "low",
1166
+ scanDir: cloneDir,
1167
+ };
1168
+ }
1169
+ matches.sort((left, right) => left.length - right.length);
1170
+ return {
1171
+ confidence: matches[0] === cloneDir ? "medium" : "high",
1172
+ scanDir: matches[0],
1173
+ };
1174
+ }
1175
+
1176
+ /**
1177
+ * Resolve the most appropriate scan directory for a cloned target repository.
1178
+ *
1179
+ * @param {string} cloneDir cloned repository root
1180
+ * @param {object} target audit target
1181
+ * @param {object} resolution repository resolution metadata
1182
+ * @returns {{ confidence: string, scanDir: string }} selected directory and confidence
1183
+ */
1184
+ export function resolveTargetSourceDirectory(cloneDir, target, resolution) {
1185
+ if (target.type === "npm") {
1186
+ const scanDir = resolvePurlSourceDirectory(cloneDir, resolution);
1187
+ if (!scanDir) {
1188
+ return {
1189
+ confidence: "medium",
1190
+ scanDir: cloneDir,
1191
+ };
1192
+ }
1193
+ return {
1194
+ confidence: scanDir === cloneDir ? "medium" : "high",
1195
+ scanDir,
1196
+ };
1197
+ }
1198
+ if (target.type === "pypi") {
1199
+ return resolvePythonSourceDirectory(cloneDir, target);
1200
+ }
1201
+ return {
1202
+ confidence: "low",
1203
+ scanDir: cloneDir,
1204
+ };
1205
+ }
1206
+
1207
+ function collectPythonHeuristicFiles(scanDir) {
1208
+ const candidates = [];
1209
+ const queue = [scanDir];
1210
+ while (queue.length && candidates.length < PYTHON_HEURISTIC_FILE_LIMIT) {
1211
+ const currentDir = queue.shift();
1212
+ let entries = [];
1213
+ try {
1214
+ entries = readdirSync(currentDir, { withFileTypes: true });
1215
+ } catch {
1216
+ continue;
1217
+ }
1218
+ for (const entry of entries) {
1219
+ const entryPath = join(currentDir, entry.name);
1220
+ if (entry.isDirectory()) {
1221
+ if (!PYTHON_SKIP_DIRS.has(entry.name)) {
1222
+ queue.push(entryPath);
1223
+ }
1224
+ continue;
1225
+ }
1226
+ if (
1227
+ entry.isFile() &&
1228
+ PYTHON_HEURISTIC_FILENAMES.has(entry.name) &&
1229
+ candidates.length < PYTHON_HEURISTIC_FILE_LIMIT
1230
+ ) {
1231
+ candidates.push(entryPath);
1232
+ }
1233
+ }
1234
+ }
1235
+ return candidates;
1236
+ }
1237
+
1238
+ function inspectPythonHeuristicFile(filePath) {
1239
+ let fileSize;
1240
+ try {
1241
+ fileSize = statSync(filePath).size;
1242
+ } catch {
1243
+ return undefined;
1244
+ }
1245
+ if (fileSize > PYTHON_HEURISTIC_MAX_FILE_BYTES) {
1246
+ return undefined;
1247
+ }
1248
+ let fileContent;
1249
+ try {
1250
+ fileContent = readFileSync(filePath, "utf8");
1251
+ } catch {
1252
+ return undefined;
1253
+ }
1254
+ const indicators = [];
1255
+ if (PYTHON_EXECUTION_PATTERN.test(fileContent)) {
1256
+ indicators.push("process-or-dynamic-execution");
1257
+ }
1258
+ if (PYTHON_NETWORK_PATTERN.test(fileContent)) {
1259
+ indicators.push("network-access");
1260
+ }
1261
+ if (PYTHON_OBFUSCATION_PATTERN.test(fileContent)) {
1262
+ indicators.push("encoded-loader");
1263
+ }
1264
+ if (
1265
+ filePath.endsWith("setup.py") &&
1266
+ PYTHON_SETUP_CMDCLASS_PATTERN.test(fileContent)
1267
+ ) {
1268
+ indicators.push("setup-cmdclass");
1269
+ }
1270
+ return indicators.length ? indicators : undefined;
1271
+ }
1272
+
1273
+ /**
1274
+ * Build shallow predictive findings for suspicious Python packaging files.
1275
+ *
1276
+ * Phase 1 intentionally focuses on high-signal packaging surfaces (`setup.py`
1277
+ * and package `__init__.py`) until deeper Python static analysis is added.
1278
+ *
1279
+ * @param {string} scanDir cloned repository scan directory
1280
+ * @param {object} target audit target
1281
+ * @returns {object[]} predictive findings
1282
+ */
1283
+ export function buildPythonSourceHeuristicFindings(scanDir, target) {
1284
+ if (!scanDir || target?.type !== "pypi") {
1285
+ return [];
1286
+ }
1287
+ const findings = [];
1288
+ collectPythonHeuristicFiles(scanDir).forEach((filePath) => {
1289
+ const indicators = inspectPythonHeuristicFile(filePath);
1290
+ if (!indicators?.length) {
1291
+ return;
1292
+ }
1293
+ const relativeFile = relative(scanDir, filePath) || filePath;
1294
+ if (
1295
+ relativeFile.endsWith("setup.py") &&
1296
+ indicators.includes("encoded-loader") &&
1297
+ (indicators.includes("process-or-dynamic-execution") ||
1298
+ indicators.includes("network-access") ||
1299
+ indicators.includes("setup-cmdclass"))
1300
+ ) {
1301
+ findings.push({
1302
+ category: "package-integrity",
1303
+ description:
1304
+ "setup.py contains encoded or dynamically executed packaging logic, which is a strong signal of install-time code injection risk.",
1305
+ evidence: {
1306
+ indicators: indicators.join(","),
1307
+ },
1308
+ location: {
1309
+ bomRef: target.bomRefs?.[0],
1310
+ file: relativeFile,
1311
+ purl: target.purl,
1312
+ },
1313
+ message: `PyPI package '${target.name}@${target.version}' contains suspicious setup.py execution patterns in '${relativeFile}'.`,
1314
+ mitigation:
1315
+ "Inspect setup.py before installation, compare against prior releases, and avoid executing packaging hooks until the encoded or dynamic logic is explained and validated.",
1316
+ ruleId: "PYSRC-001",
1317
+ severity: "high",
1318
+ });
1319
+ return;
1320
+ }
1321
+ if (
1322
+ relativeFile.endsWith("__init__.py") &&
1323
+ (indicators.includes("process-or-dynamic-execution") ||
1324
+ indicators.includes("network-access"))
1325
+ ) {
1326
+ findings.push({
1327
+ category: "package-integrity",
1328
+ description:
1329
+ "__init__.py appears to perform process spawning, dynamic execution, or network access during import, which is unusual for a package initializer.",
1330
+ evidence: {
1331
+ indicators: indicators.join(","),
1332
+ },
1333
+ location: {
1334
+ bomRef: target.bomRefs?.[0],
1335
+ file: relativeFile,
1336
+ purl: target.purl,
1337
+ },
1338
+ message: `PyPI package '${target.name}@${target.version}' contains suspicious import-time logic in '${relativeFile}'.`,
1339
+ mitigation:
1340
+ "Review the initializer for import-time side effects, compare it to prior versions, and quarantine the release until maintainers confirm the added behavior.",
1341
+ ruleId: "PYSRC-002",
1342
+ severity: indicators.includes("encoded-loader") ? "high" : "medium",
1343
+ });
1344
+ }
1345
+ });
1346
+ return findings;
1347
+ }
1348
+
1349
+ /**
1350
+ * Build cdxgen options for a child source scan.
1351
+ *
1352
+ * @param {object} options CLI options
1353
+ * @param {object} target audit target
1354
+ * @returns {object} createBom options
1355
+ */
1356
+ function buildChildOptions(options, target) {
1357
+ return {
1358
+ deep: true,
1359
+ failOnError: false,
1360
+ filePath: options.workspaceDir || process.cwd(),
1361
+ includeFormulation: true,
1362
+ installDeps: false,
1363
+ multiProject: true,
1364
+ profile: "threat-modeling",
1365
+ projectType: [target.type === "npm" ? "js" : "py"],
1366
+ specVersion: 1.7,
1367
+ };
1368
+ }
1369
+
1370
+ /**
1371
+ * Analyze a single purl target by generating a child SBOM and auditing it.
1372
+ *
1373
+ * @param {object} target audit target
1374
+ * @param {object} options CLI options
1375
+ * @returns {Promise<object>} analyzed target result
1376
+ */
1377
+ export async function auditTarget(target, options) {
1378
+ const categories = options.categories?.length
1379
+ ? options.categories
1380
+ : DEFAULT_AUDIT_CATEGORIES;
1381
+ const targetIndex = options._targetIndex || 0;
1382
+ const targetTotal = options._targetTotal || 0;
1383
+ const targetLabel = formatTargetLabel(target);
1384
+ const originalFetchPackageMetadata = process.env.CDXGEN_FETCH_PKG_METADATA;
1385
+ let checkout;
1386
+ let processedBomJson;
1387
+ let resolution;
1388
+ let sourceSelection;
1389
+ let cacheHit = false;
1390
+ let sanitizedRepoUrl;
1391
+ let versionMatched = false;
1392
+ try {
1393
+ const cachedChildBom = loadCachedChildBom(options.workspaceDir, target);
1394
+ if (cachedChildBom) {
1395
+ cacheHit = true;
1396
+ processedBomJson = cachedChildBom.bomJson;
1397
+ resolution = cachedChildBom.resolution;
1398
+ sanitizedRepoUrl = cachedChildBom.repoUrl;
1399
+ sourceSelection = {
1400
+ confidence: cachedChildBom.sourceDirectoryConfidence,
1401
+ scanDir: cachedChildBom.scanDir,
1402
+ };
1403
+ versionMatched = cachedChildBom.versionMatched;
1404
+ emitProgress(options, {
1405
+ index: targetIndex,
1406
+ label: targetLabel,
1407
+ target,
1408
+ total: targetTotal,
1409
+ type: "target:stage",
1410
+ stage: "reusing cached child SBOM",
1411
+ });
1412
+ } else {
1413
+ emitProgress(options, {
1414
+ index: targetIndex,
1415
+ label: targetLabel,
1416
+ target,
1417
+ total: targetTotal,
1418
+ type: "target:stage",
1419
+ stage: "resolving repository metadata",
1420
+ });
1421
+ resolution = await resolveGitUrlFromPurl(target.purl);
1422
+ if (!resolution?.repoUrl) {
1423
+ return persistAuditArtifacts(
1424
+ {
1425
+ assessment: scoreTargetRisk([], target, {
1426
+ skipReason:
1427
+ "Unable to resolve repository URL from purl metadata.",
1428
+ }),
1429
+ error: "Unable to resolve repository URL from purl metadata.",
1430
+ findings: [],
1431
+ resolution,
1432
+ status: "skipped",
1433
+ target,
1434
+ },
1435
+ options,
1436
+ );
1437
+ }
1438
+ sanitizedRepoUrl = sanitizeRemoteUrlForLogs(resolution.repoUrl);
1439
+ thoughtLog("Preparing predictive audit target.", {
1440
+ purl: target.purl,
1441
+ repoUrl: sanitizedRepoUrl,
1442
+ });
1443
+ const gitRef = findGitRefForPurlVersion(resolution.repoUrl, resolution);
1444
+ versionMatched = Boolean(gitRef);
1445
+ emitProgress(options, {
1446
+ index: targetIndex,
1447
+ label: targetLabel,
1448
+ target,
1449
+ total: targetTotal,
1450
+ type: "target:stage",
1451
+ stage: gitRef ? `cloning source at ref ${gitRef}` : "cloning source",
1452
+ });
1453
+ checkout = await ensureCheckout(
1454
+ target,
1455
+ resolution,
1456
+ options.workspaceDir,
1457
+ gitRef,
1458
+ );
1459
+ sourceSelection = resolveTargetSourceDirectory(
1460
+ checkout.cloneDir,
1461
+ target,
1462
+ resolution,
1463
+ );
1464
+ const childOptions = buildChildOptions(options, target);
1465
+ process.env.CDXGEN_FETCH_PKG_METADATA = "true";
1466
+ emitProgress(options, {
1467
+ index: targetIndex,
1468
+ label: targetLabel,
1469
+ target,
1470
+ total: targetTotal,
1471
+ type: "target:stage",
1472
+ stage: "generating child SBOM",
1473
+ });
1474
+ const bomNSData =
1475
+ (await createBom(sourceSelection.scanDir, childOptions)) || {};
1476
+ if (!bomNSData?.bomJson) {
1477
+ return persistAuditArtifacts(
1478
+ {
1479
+ assessment: scoreTargetRisk([], target, {
1480
+ errorMessage:
1481
+ "Unable to generate a child SBOM for the resolved source repository.",
1482
+ scanError: true,
1483
+ }),
1484
+ error:
1485
+ "Unable to generate a child SBOM for the resolved source repository.",
1486
+ errorType: "sbom-generation",
1487
+ findings: [],
1488
+ repoUrl: sanitizedRepoUrl,
1489
+ resolution,
1490
+ status: "error",
1491
+ target,
1492
+ },
1493
+ options,
1494
+ );
1495
+ }
1496
+ const processedBomNSData = postProcess(
1497
+ bomNSData,
1498
+ childOptions,
1499
+ sourceSelection.scanDir,
1500
+ );
1501
+ processedBomJson = processedBomNSData.bomJson;
1502
+ writeCachedChildBom(options.workspaceDir, target, {
1503
+ bomJson: processedBomJson,
1504
+ repoUrl: sanitizedRepoUrl,
1505
+ resolution,
1506
+ scanDir: sourceSelection.scanDir,
1507
+ sourceDirectoryConfidence: sourceSelection.confidence,
1508
+ versionMatched,
1509
+ });
1510
+ }
1511
+ emitProgress(options, {
1512
+ index: targetIndex,
1513
+ label: targetLabel,
1514
+ target,
1515
+ total: targetTotal,
1516
+ type: "target:stage",
1517
+ stage: "evaluating audit rules",
1518
+ });
1519
+ const findings = await auditBom(processedBomJson, {
1520
+ bomAuditCategories: categories.join(","),
1521
+ bomAuditMinSeverity: options.minSeverity || "low",
1522
+ });
1523
+ const contextualFindings = buildTargetContextFindings(target);
1524
+ const pythonSourceFindings = buildPythonSourceHeuristicFindings(
1525
+ sourceSelection.scanDir,
1526
+ target,
1527
+ );
1528
+ const predictiveFindings = findings.concat(
1529
+ contextualFindings,
1530
+ pythonSourceFindings,
1531
+ );
1532
+ const assessment = scoreTargetRisk(predictiveFindings, target, {
1533
+ bomJson: processedBomJson,
1534
+ repoReused: Boolean(checkout?.reused || cacheHit),
1535
+ resolution,
1536
+ sourceDirectoryConfidence: sourceSelection.confidence,
1537
+ versionMatched,
1538
+ });
1539
+ return persistAuditArtifacts(
1540
+ {
1541
+ assessment,
1542
+ cacheHit,
1543
+ findings: predictiveFindings,
1544
+ repoUrl: sanitizedRepoUrl,
1545
+ resolution,
1546
+ scanDir: sourceSelection.scanDir,
1547
+ sourceDirectoryConfidence: sourceSelection.confidence,
1548
+ status: "audited",
1549
+ target,
1550
+ },
1551
+ options,
1552
+ processedBomJson,
1553
+ );
1554
+ } catch (error) {
1555
+ return persistAuditArtifacts(
1556
+ {
1557
+ assessment: scoreTargetRisk([], target, {
1558
+ errorMessage: error.message,
1559
+ scanError: true,
1560
+ }),
1561
+ error: error.message,
1562
+ errorType: error?.errorType || "runtime",
1563
+ findings: [],
1564
+ repoUrl: sanitizedRepoUrl,
1565
+ resolution,
1566
+ sourceDirectoryConfidence: sourceSelection?.confidence,
1567
+ status: "error",
1568
+ target,
1569
+ },
1570
+ options,
1571
+ processedBomJson,
1572
+ );
1573
+ } finally {
1574
+ if (originalFetchPackageMetadata === undefined) {
1575
+ delete process.env.CDXGEN_FETCH_PKG_METADATA;
1576
+ } else {
1577
+ process.env.CDXGEN_FETCH_PKG_METADATA = originalFetchPackageMetadata;
1578
+ }
1579
+ if (checkout?.cleanup) {
1580
+ cleanupSourceDir(checkout.cloneDir);
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ /**
1586
+ * Build an aggregate summary for all analyzed targets.
1587
+ *
1588
+ * @param {object[]} inputBoms loaded BOMs
1589
+ * @param {object[]} results target results
1590
+ * @param {object[]} skipped skipped component entries
1591
+ * @returns {object} summary object
1592
+ */
1593
+ function summarizeAudit(inputBoms, results, skipped) {
1594
+ const severityCounts = {
1595
+ critical: 0,
1596
+ high: 0,
1597
+ low: 0,
1598
+ medium: 0,
1599
+ none: 0,
1600
+ };
1601
+ let scannedTargets = 0;
1602
+ let erroredTargets = 0;
1603
+ for (const result of results) {
1604
+ const severity = result?.assessment?.severity || "none";
1605
+ severityCounts[severity] = (severityCounts[severity] || 0) + 1;
1606
+ if (result.status === "audited") {
1607
+ scannedTargets += 1;
1608
+ }
1609
+ if (result.status === "error") {
1610
+ erroredTargets += 1;
1611
+ }
1612
+ }
1613
+ return {
1614
+ erroredTargets,
1615
+ inputBomCount: inputBoms.length,
1616
+ scannedTargets,
1617
+ severityCounts,
1618
+ skippedTargets:
1619
+ skipped.length +
1620
+ results.filter((result) => result.status === "skipped").length,
1621
+ totalTargets: results.length,
1622
+ };
1623
+ }
1624
+
1625
+ function preferredResult(left, right) {
1626
+ const leftSeverity = SEVERITY_ORDER[left?.assessment?.severity] ?? -1;
1627
+ const rightSeverity = SEVERITY_ORDER[right?.assessment?.severity] ?? -1;
1628
+ if (leftSeverity !== rightSeverity) {
1629
+ return leftSeverity > rightSeverity ? left : right;
1630
+ }
1631
+ const leftScore = left?.assessment?.score || 0;
1632
+ const rightScore = right?.assessment?.score || 0;
1633
+ return leftScore >= rightScore ? left : right;
1634
+ }
1635
+
1636
+ function dedupeFindings(findings) {
1637
+ const seen = new Set();
1638
+ const deduped = [];
1639
+ for (const finding of findings || []) {
1640
+ const key = [
1641
+ finding?.ruleId,
1642
+ finding?.message,
1643
+ finding?.location?.file,
1644
+ finding?.location?.purl,
1645
+ finding?.location?.bomRef,
1646
+ ].join("|");
1647
+ if (seen.has(key)) {
1648
+ continue;
1649
+ }
1650
+ seen.add(key);
1651
+ deduped.push(finding);
1652
+ }
1653
+ return deduped;
1654
+ }
1655
+
1656
+ function getNamespaceGroupingKey(result) {
1657
+ if (
1658
+ result?.status !== "audited" ||
1659
+ result?.target?.type !== "npm" ||
1660
+ !result?.target?.namespace ||
1661
+ (result?.assessment?.severity || "none") === "none"
1662
+ ) {
1663
+ return undefined;
1664
+ }
1665
+ const categories = Object.keys(
1666
+ result.assessment?.categoryCounts || {},
1667
+ ).sort();
1668
+ const ruleIds = [
1669
+ ...new Set((result.findings || []).map((f) => f.ruleId).filter(Boolean)),
1670
+ ].sort();
1671
+ if (!categories.length || !ruleIds.length) {
1672
+ return undefined;
1673
+ }
1674
+ return `${result.target.namespace}|${categories.join(",")}|${ruleIds.join(",")}`;
1675
+ }
1676
+
1677
+ function consolidateNamespaceResult(group) {
1678
+ const representative = group.reduce((best, result) =>
1679
+ preferredResult(best, result),
1680
+ );
1681
+ const allBomRefs = [
1682
+ ...new Set(group.flatMap((result) => result.target?.bomRefs || [])),
1683
+ ];
1684
+ const groupedPurls = [
1685
+ ...new Set(group.map((result) => result.target?.purl).filter(Boolean)),
1686
+ ];
1687
+ const mergedFindings = dedupeFindings(
1688
+ group.flatMap((result) => result.findings || []),
1689
+ );
1690
+ const categoryCounts = {};
1691
+ for (const result of group) {
1692
+ for (const [category, count] of Object.entries(
1693
+ result.assessment?.categoryCounts || {},
1694
+ )) {
1695
+ categoryCounts[category] = (categoryCounts[category] || 0) + count;
1696
+ }
1697
+ }
1698
+ const reasons = [
1699
+ `${group.length} npm packages under namespace '${representative.target.namespace}' shared the same predictive pattern and were consolidated into one alert.`,
1700
+ ...(representative.assessment?.reasons || []),
1701
+ ];
1702
+ return {
1703
+ ...representative,
1704
+ assessment: {
1705
+ ...representative.assessment,
1706
+ categoryCounts,
1707
+ findingsCount: mergedFindings.length,
1708
+ reasons: [...new Set(reasons)],
1709
+ },
1710
+ findings: mergedFindings,
1711
+ grouping: {
1712
+ kind: "npm-namespace",
1713
+ label: `npm:${representative.target.namespace}/*`,
1714
+ memberCount: group.length,
1715
+ namespace: representative.target.namespace,
1716
+ groupedPurls,
1717
+ },
1718
+ target: {
1719
+ ...representative.target,
1720
+ bomRefs: allBomRefs,
1721
+ name: "*",
1722
+ version: undefined,
1723
+ },
1724
+ };
1725
+ }
1726
+
1727
+ export function groupAuditResults(results) {
1728
+ const groupedResults = [];
1729
+ const orderedEntries = [];
1730
+ const namespaceGroups = new Map();
1731
+ for (const result of results) {
1732
+ const groupKey = getNamespaceGroupingKey(result);
1733
+ if (!groupKey) {
1734
+ orderedEntries.push({ result, type: "single" });
1735
+ continue;
1736
+ }
1737
+ if (!namespaceGroups.has(groupKey)) {
1738
+ namespaceGroups.set(groupKey, []);
1739
+ orderedEntries.push({ groupKey, type: "group" });
1740
+ }
1741
+ namespaceGroups.get(groupKey).push(result);
1742
+ }
1743
+ for (const entry of orderedEntries) {
1744
+ if (entry.type === "single") {
1745
+ groupedResults.push(entry.result);
1746
+ continue;
1747
+ }
1748
+ const group = namespaceGroups.get(entry.groupKey) || [];
1749
+ groupedResults.push(
1750
+ group.length > 1 ? consolidateNamespaceResult(group) : group[0],
1751
+ );
1752
+ }
1753
+ return groupedResults;
1754
+ }
1755
+
1756
+ function summarizeGroupedResults(results) {
1757
+ const severityCounts = {
1758
+ critical: 0,
1759
+ high: 0,
1760
+ low: 0,
1761
+ medium: 0,
1762
+ none: 0,
1763
+ };
1764
+ for (const result of results) {
1765
+ const severity = result?.assessment?.severity || "none";
1766
+ severityCounts[severity] = (severityCounts[severity] || 0) + 1;
1767
+ }
1768
+ return {
1769
+ groupedResultCount: results.length,
1770
+ groupedSeverityCounts: severityCounts,
1771
+ };
1772
+ }
1773
+
1774
+ /**
1775
+ * Run the predictive audit flow from one or more already-loaded CycloneDX BOM inputs.
1776
+ *
1777
+ * @param {{ source: string, bomJson: object }[]} inputBoms loaded CycloneDX BOM objects
1778
+ * @param {object} options CLI options
1779
+ * @returns {Promise<object>} aggregate audit report
1780
+ */
1781
+ export async function runAuditFromBoms(inputBoms, options) {
1782
+ if (!inputBoms.length) {
1783
+ throw new Error("No CycloneDX BOM inputs were found.");
1784
+ }
1785
+ const extractedTargets = collectAuditTargets(inputBoms, {
1786
+ maxTargets: options.maxTargets,
1787
+ scope: options.scope,
1788
+ trusted: options.trusted,
1789
+ });
1790
+ const results = [];
1791
+ const preflightMessage = buildPredictiveAuditPreflightMessage(
1792
+ extractedTargets,
1793
+ options,
1794
+ );
1795
+ if (preflightMessage) {
1796
+ emitProgress(options, {
1797
+ message: preflightMessage,
1798
+ total: extractedTargets.targets.length,
1799
+ type: "run:info",
1800
+ });
1801
+ }
1802
+ if (extractedTargets.targets.length) {
1803
+ emitProgress(options, {
1804
+ total: extractedTargets.targets.length,
1805
+ type: "run:start",
1806
+ });
1807
+ }
1808
+ for (const [index, target] of extractedTargets.targets.entries()) {
1809
+ const targetIndex = index + 1;
1810
+ emitProgress(options, {
1811
+ index: targetIndex,
1812
+ label: formatTargetLabel(target),
1813
+ target,
1814
+ total: extractedTargets.targets.length,
1815
+ type: "target:start",
1816
+ });
1817
+ const result = await auditTarget(target, {
1818
+ ...options,
1819
+ _targetIndex: targetIndex,
1820
+ _targetTotal: extractedTargets.targets.length,
1821
+ });
1822
+ results.push(result);
1823
+ emitProgress(options, {
1824
+ index: targetIndex,
1825
+ label: formatTargetLabel(target),
1826
+ result,
1827
+ target,
1828
+ total: extractedTargets.targets.length,
1829
+ type: "target:finish",
1830
+ });
1831
+ }
1832
+ const groupedResults = groupAuditResults(results);
1833
+ const report = {
1834
+ generatedAt: new Date().toISOString(),
1835
+ inputs: inputBoms.map((inputBom) => inputBom.source),
1836
+ groupedResults,
1837
+ results,
1838
+ skipped: extractedTargets.skipped,
1839
+ summary: summarizeAudit(inputBoms, results, extractedTargets.skipped),
1840
+ tool: {
1841
+ name: "cdx-audit",
1842
+ version: readPackageVersion(),
1843
+ },
1844
+ };
1845
+ Object.assign(report.summary, summarizeGroupedResults(groupedResults));
1846
+ if (options.reportsDir) {
1847
+ const aggregateFile = join(
1848
+ resolve(options.reportsDir),
1849
+ "aggregate-report.json",
1850
+ );
1851
+ writeJsonFile(aggregateFile, report);
1852
+ report.aggregateReportFile = aggregateFile;
1853
+ }
1854
+ if (extractedTargets.targets.length) {
1855
+ emitProgress(options, {
1856
+ summary: report.summary,
1857
+ type: "run:finish",
1858
+ });
1859
+ }
1860
+ return report;
1861
+ }
1862
+
1863
+ /**
1864
+ * Run the predictive audit flow from one or more CycloneDX BOM inputs.
1865
+ *
1866
+ * @param {object} options CLI options
1867
+ * @returns {Promise<object>} aggregate audit report
1868
+ */
1869
+ export async function runAudit(options) {
1870
+ const inputBoms = loadInputBoms(options);
1871
+ const workspaceContext = prepareWorkspaceContext(options);
1872
+ try {
1873
+ return await runAuditFromBoms(inputBoms, {
1874
+ ...options,
1875
+ workspaceDir: workspaceContext.workspaceDir,
1876
+ });
1877
+ } finally {
1878
+ if (workspaceContext.cleanupOnFinish) {
1879
+ cleanupSourceDir(workspaceContext.workspaceDir);
1880
+ }
1881
+ }
1882
+ }
1883
+
1884
+ /**
1885
+ * Render a report and compute the proper process exit code.
1886
+ *
1887
+ * @param {object} report aggregate report
1888
+ * @param {object} options CLI options
1889
+ * @returns {{ exitCode: number, output: string }} rendered output and exit code
1890
+ */
1891
+ export function finalizeAuditReport(report, options) {
1892
+ const output = renderAuditReport(options.report, report, {
1893
+ minSeverity: options.minSeverity,
1894
+ });
1895
+ const effectiveResults = report.groupedResults?.length
1896
+ ? report.groupedResults
1897
+ : report.results;
1898
+ const shouldFail = effectiveResults.some((result) =>
1899
+ severityMeetsThreshold(
1900
+ result?.assessment?.severity || "none",
1901
+ options.failSeverity || "high",
1902
+ ),
1903
+ );
1904
+ return {
1905
+ exitCode: shouldFail ? 3 : 0,
1906
+ output,
1907
+ };
1908
+ }
1909
+
1910
+ /**
1911
+ * Build a result file name for user-provided report output paths.
1912
+ *
1913
+ * @param {object} options CLI options
1914
+ * @returns {string | undefined} output file path
1915
+ */
1916
+ export function defaultOutputFile(options) {
1917
+ if (!options.reportsDir) {
1918
+ return undefined;
1919
+ }
1920
+ return join(
1921
+ resolve(options.reportsDir),
1922
+ `cdx-audit-report.${options.report || "console"}.txt`,
1923
+ );
1924
+ }