@cyclonedx/cdxgen 12.2.1 → 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.
- package/README.md +239 -90
- package/bin/audit.js +191 -0
- package/bin/cdxgen.js +513 -167
- package/bin/convert.js +99 -0
- package/bin/evinse.js +23 -0
- package/bin/repl.js +339 -8
- package/bin/sign.js +8 -0
- package/bin/validate.js +8 -0
- package/bin/verify.js +8 -0
- package/data/container-knowledge-index.json +125 -0
- package/data/gtfobins-index.json +6296 -0
- package/data/lolbas-index.json +150 -0
- package/data/queries-darwin.json +63 -3
- package/data/queries-win.json +45 -3
- package/data/queries.json +74 -2
- package/data/rules/chrome-extensions.yaml +240 -0
- package/data/rules/ci-permissions.yaml +478 -18
- package/data/rules/container-risk.yaml +270 -0
- package/data/rules/obom-runtime.yaml +891 -0
- package/data/rules/package-integrity.yaml +49 -0
- package/data/spdx-export.schema.json +6794 -0
- package/data/spdx-model-v3.0.1.jsonld +15999 -0
- package/lib/audit/index.js +1924 -0
- package/lib/audit/index.poku.js +1488 -0
- package/lib/audit/progress.js +137 -0
- package/lib/audit/progress.poku.js +188 -0
- package/lib/audit/reporters.js +618 -0
- package/lib/audit/scoring.js +310 -0
- package/lib/audit/scoring.poku.js +341 -0
- package/lib/audit/targets.js +260 -0
- package/lib/audit/targets.poku.js +331 -0
- package/lib/cli/index.js +154 -11
- package/lib/cli/index.poku.js +251 -0
- package/lib/helpers/analyzer.js +446 -2
- package/lib/helpers/analyzer.poku.js +72 -1
- package/lib/helpers/annotationFormatter.js +49 -0
- package/lib/helpers/annotationFormatter.poku.js +44 -0
- package/lib/helpers/bomUtils.js +36 -0
- package/lib/helpers/bomUtils.poku.js +51 -0
- package/lib/helpers/caxa.js +2 -2
- package/lib/helpers/chromextutils.js +1153 -0
- package/lib/helpers/chromextutils.poku.js +493 -0
- package/lib/helpers/ciParsers/githubActions.js +1632 -45
- package/lib/helpers/ciParsers/githubActions.poku.js +853 -1
- package/lib/helpers/containerRisk.js +186 -0
- package/lib/helpers/containerRisk.poku.js +52 -0
- package/lib/helpers/display.js +241 -59
- package/lib/helpers/display.poku.js +162 -2
- package/lib/helpers/exportUtils.js +123 -0
- package/lib/helpers/exportUtils.poku.js +60 -0
- package/lib/helpers/formulationParsers.js +69 -0
- package/lib/helpers/formulationParsers.poku.js +44 -0
- package/lib/helpers/gtfobins.js +189 -0
- package/lib/helpers/gtfobins.poku.js +49 -0
- package/lib/helpers/lolbas.js +267 -0
- package/lib/helpers/lolbas.poku.js +39 -0
- package/lib/helpers/osqueryTransform.js +84 -0
- package/lib/helpers/osqueryTransform.poku.js +49 -0
- package/lib/helpers/provenanceUtils.js +193 -0
- package/lib/helpers/provenanceUtils.poku.js +145 -0
- package/lib/helpers/pylockutils.js +281 -0
- package/lib/helpers/pylockutils.poku.js +48 -0
- package/lib/helpers/registryProvenance.js +793 -0
- package/lib/helpers/registryProvenance.poku.js +452 -0
- package/lib/helpers/source.js +1267 -0
- package/lib/helpers/source.poku.js +771 -0
- package/lib/helpers/spdxUtils.js +97 -0
- package/lib/helpers/spdxUtils.poku.js +70 -0
- package/lib/helpers/unicodeScan.js +147 -0
- package/lib/helpers/unicodeScan.poku.js +45 -0
- package/lib/helpers/utils.js +700 -128
- package/lib/helpers/utils.poku.js +877 -80
- package/lib/managers/binary.js +29 -5
- package/lib/managers/docker.js +179 -52
- package/lib/managers/docker.poku.js +327 -28
- package/lib/managers/oci.js +107 -23
- package/lib/managers/oci.poku.js +132 -0
- package/lib/server/openapi.yaml +17 -0
- package/lib/server/server.js +225 -336
- package/lib/server/server.poku.js +16 -10
- package/lib/stages/postgen/annotator.js +7 -0
- package/lib/stages/postgen/annotator.poku.js +40 -0
- package/lib/stages/postgen/auditBom.js +19 -3
- package/lib/stages/postgen/auditBom.poku.js +1729 -67
- package/lib/stages/postgen/postgen.js +40 -0
- package/lib/stages/postgen/postgen.poku.js +47 -0
- package/lib/stages/postgen/ruleEngine.js +80 -2
- package/lib/stages/postgen/spdxConverter.js +796 -0
- package/lib/stages/postgen/spdxConverter.poku.js +341 -0
- package/lib/validator/bomValidator.js +232 -0
- package/lib/validator/bomValidator.poku.js +70 -0
- package/lib/validator/complianceRules.js +70 -7
- package/lib/validator/complianceRules.poku.js +30 -0
- package/lib/validator/reporters/annotations.js +2 -2
- package/lib/validator/reporters/console.js +11 -0
- package/lib/validator/reporters.poku.js +13 -0
- package/package.json +10 -7
- package/types/bin/audit.d.ts +3 -0
- package/types/bin/audit.d.ts.map +1 -0
- package/types/bin/convert.d.ts +3 -0
- package/types/bin/convert.d.ts.map +1 -0
- package/types/bin/repl.d.ts.map +1 -1
- package/types/lib/audit/index.d.ts +115 -0
- package/types/lib/audit/index.d.ts.map +1 -0
- package/types/lib/audit/progress.d.ts +27 -0
- package/types/lib/audit/progress.d.ts.map +1 -0
- package/types/lib/audit/reporters.d.ts +35 -0
- package/types/lib/audit/reporters.d.ts.map +1 -0
- package/types/lib/audit/scoring.d.ts +35 -0
- package/types/lib/audit/scoring.d.ts.map +1 -0
- package/types/lib/audit/targets.d.ts +63 -0
- package/types/lib/audit/targets.d.ts.map +1 -0
- package/types/lib/cli/index.d.ts +8 -0
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/helpers/analyzer.d.ts +13 -0
- package/types/lib/helpers/analyzer.d.ts.map +1 -1
- package/types/lib/helpers/annotationFormatter.d.ts +23 -0
- package/types/lib/helpers/annotationFormatter.d.ts.map +1 -0
- package/types/lib/helpers/bomUtils.d.ts +5 -0
- package/types/lib/helpers/bomUtils.d.ts.map +1 -0
- package/types/lib/helpers/chromextutils.d.ts +97 -0
- package/types/lib/helpers/chromextutils.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/githubActions.d.ts +3 -8
- package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
- package/types/lib/helpers/containerRisk.d.ts +17 -0
- package/types/lib/helpers/containerRisk.d.ts.map +1 -0
- package/types/lib/helpers/display.d.ts +4 -1
- package/types/lib/helpers/display.d.ts.map +1 -1
- package/types/lib/helpers/exportUtils.d.ts +40 -0
- package/types/lib/helpers/exportUtils.d.ts.map +1 -0
- package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
- package/types/lib/helpers/gtfobins.d.ts +17 -0
- package/types/lib/helpers/gtfobins.d.ts.map +1 -0
- package/types/lib/helpers/lolbas.d.ts +16 -0
- package/types/lib/helpers/lolbas.d.ts.map +1 -0
- package/types/lib/helpers/osqueryTransform.d.ts +7 -0
- package/types/lib/helpers/osqueryTransform.d.ts.map +1 -0
- package/types/lib/helpers/provenanceUtils.d.ts +90 -0
- package/types/lib/helpers/provenanceUtils.d.ts.map +1 -0
- package/types/lib/helpers/pylockutils.d.ts +51 -0
- package/types/lib/helpers/pylockutils.d.ts.map +1 -0
- package/types/lib/helpers/registryProvenance.d.ts +17 -0
- package/types/lib/helpers/registryProvenance.d.ts.map +1 -0
- package/types/lib/helpers/source.d.ts +141 -0
- package/types/lib/helpers/source.d.ts.map +1 -0
- package/types/lib/helpers/spdxUtils.d.ts +2 -0
- package/types/lib/helpers/spdxUtils.d.ts.map +1 -0
- package/types/lib/helpers/unicodeScan.d.ts +46 -0
- package/types/lib/helpers/unicodeScan.d.ts.map +1 -0
- package/types/lib/helpers/utils.d.ts +29 -11
- package/types/lib/helpers/utils.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/server/server.d.ts +0 -36
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
- package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
- package/types/lib/stages/postgen/spdxConverter.d.ts +11 -0
- package/types/lib/stages/postgen/spdxConverter.d.ts.map +1 -0
- package/types/lib/validator/bomValidator.d.ts +1 -0
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/complianceRules.d.ts.map +1 -1
- package/types/lib/validator/reporters/console.d.ts.map +1 -1
- package/types/bin/dependencies.d.ts +0 -3
- package/types/bin/dependencies.d.ts.map +0 -1
- package/types/bin/licenses.d.ts +0 -3
- 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
|
+
}
|