verimu 0.0.9 → 0.0.13
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 +9 -6
- package/dist/{cli.mjs → cli.js} +1295 -65
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +1503 -182
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +415 -24
- package/dist/index.d.ts +415 -24
- package/dist/index.mjs +1495 -182
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -3
- package/dist/cli.mjs.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -37,6 +37,7 @@ __export(index_exports, {
|
|
|
37
37
|
CveAggregator: () => CveAggregator,
|
|
38
38
|
CveSourceError: () => CveSourceError,
|
|
39
39
|
CycloneDxGenerator: () => CycloneDxGenerator,
|
|
40
|
+
DenoScanner: () => DenoScanner,
|
|
40
41
|
GoScanner: () => GoScanner,
|
|
41
42
|
LockfileParseError: () => LockfileParseError,
|
|
42
43
|
MavenScanner: () => MavenScanner,
|
|
@@ -45,11 +46,18 @@ __export(index_exports, {
|
|
|
45
46
|
NugetScanner: () => NugetScanner,
|
|
46
47
|
OsvSource: () => OsvSource,
|
|
47
48
|
PipScanner: () => PipScanner,
|
|
49
|
+
PnpmScanner: () => PnpmScanner,
|
|
48
50
|
RubyScanner: () => RubyScanner,
|
|
49
51
|
ScannerRegistry: () => ScannerRegistry,
|
|
52
|
+
SpdxJsonGenerator: () => SpdxJsonGenerator,
|
|
53
|
+
SwidTagGenerator: () => SwidTagGenerator,
|
|
50
54
|
VerimuApiClient: () => VerimuApiClient,
|
|
51
55
|
VerimuError: () => VerimuError,
|
|
56
|
+
YarnScanner: () => YarnScanner,
|
|
52
57
|
generateSbom: () => generateSbom,
|
|
58
|
+
generateSbomArtifacts: () => generateSbomArtifacts,
|
|
59
|
+
generateSpdxSbom: () => generateSpdxSbom,
|
|
60
|
+
generateSwidTag: () => generateSwidTag,
|
|
53
61
|
printReport: () => printReport,
|
|
54
62
|
scan: () => scan,
|
|
55
63
|
shouldFailCi: () => shouldFailCi,
|
|
@@ -59,18 +67,64 @@ module.exports = __toCommonJS(index_exports);
|
|
|
59
67
|
|
|
60
68
|
// src/generate-sbom.ts
|
|
61
69
|
var import_crypto = require("crypto");
|
|
70
|
+
|
|
71
|
+
// src/sbom/shared.ts
|
|
72
|
+
var VERIMU_TOOL_NAME = "verimu";
|
|
73
|
+
var VERIMU_TOOL_WEBSITE = "https://verimu.com";
|
|
74
|
+
var VERIMU_TOOL_DESCRIPTION = "Verimu CRA Compliance Scanner";
|
|
75
|
+
var DEFAULT_TOOL_VERSION = "0.1.0";
|
|
76
|
+
var DEFAULT_PROJECT_VERSION = "0.0.0";
|
|
77
|
+
var DEFAULT_SWID_VERSION = "0.0.0";
|
|
78
|
+
var PURL_TYPE_MAP = {
|
|
79
|
+
npm: "npm",
|
|
80
|
+
nuget: "nuget",
|
|
81
|
+
cargo: "cargo",
|
|
82
|
+
maven: "maven",
|
|
83
|
+
pip: "pypi",
|
|
84
|
+
poetry: "pypi",
|
|
85
|
+
uv: "pypi",
|
|
86
|
+
go: "golang",
|
|
87
|
+
ruby: "gem",
|
|
88
|
+
composer: "composer",
|
|
89
|
+
deno: "deno"
|
|
90
|
+
};
|
|
91
|
+
function buildPurl(name, version, ecosystem) {
|
|
92
|
+
const type = PURL_TYPE_MAP[ecosystem] || ecosystem;
|
|
93
|
+
if (ecosystem === "npm" && name.startsWith("@")) {
|
|
94
|
+
return `pkg:${type}/%40${name.slice(1)}@${version}`;
|
|
95
|
+
}
|
|
96
|
+
return `pkg:${type}/${name}@${version}`;
|
|
97
|
+
}
|
|
98
|
+
function deriveSupplierName(packageName) {
|
|
99
|
+
if (packageName.startsWith("@")) {
|
|
100
|
+
return packageName.split("/")[0];
|
|
101
|
+
}
|
|
102
|
+
return packageName;
|
|
103
|
+
}
|
|
104
|
+
function extractProjectName(projectPath) {
|
|
105
|
+
const parts = projectPath.replace(/\\/g, "/").split("/");
|
|
106
|
+
return parts[parts.length - 1] || "unknown-project";
|
|
107
|
+
}
|
|
108
|
+
function normalizeDependencies(dependencies) {
|
|
109
|
+
return dependencies.map((dep) => ({
|
|
110
|
+
name: dep.name,
|
|
111
|
+
version: dep.version,
|
|
112
|
+
ecosystem: dep.ecosystem,
|
|
113
|
+
direct: dep.direct ?? true,
|
|
114
|
+
purl: dep.purl ?? buildPurl(dep.name, dep.version, dep.ecosystem),
|
|
115
|
+
supplierName: deriveSupplierName(dep.name)
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/generate-sbom.ts
|
|
62
120
|
function generateSbom(input) {
|
|
63
121
|
const {
|
|
64
122
|
projectName,
|
|
65
|
-
projectVersion =
|
|
123
|
+
projectVersion = DEFAULT_PROJECT_VERSION,
|
|
66
124
|
dependencies
|
|
67
125
|
} = input;
|
|
68
126
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
69
|
-
const resolvedDeps = dependencies
|
|
70
|
-
...dep,
|
|
71
|
-
direct: dep.direct ?? true,
|
|
72
|
-
purl: dep.purl ?? buildPurl(dep.name, dep.version, dep.ecosystem)
|
|
73
|
-
}));
|
|
127
|
+
const resolvedDeps = normalizeDependencies(dependencies);
|
|
74
128
|
const rootPurl = buildPurl(projectName, projectVersion, "npm");
|
|
75
129
|
const sbom = {
|
|
76
130
|
$schema: "http://cyclonedx.org/schema/bom-1.7.schema.json",
|
|
@@ -84,12 +138,12 @@ function generateSbom(input) {
|
|
|
84
138
|
components: [
|
|
85
139
|
{
|
|
86
140
|
type: "application",
|
|
87
|
-
name:
|
|
141
|
+
name: VERIMU_TOOL_NAME,
|
|
88
142
|
version: "0.0.1",
|
|
89
|
-
description:
|
|
143
|
+
description: VERIMU_TOOL_DESCRIPTION,
|
|
90
144
|
supplier: { name: "Verimu" },
|
|
91
145
|
externalReferences: [
|
|
92
|
-
{ type: "website", url:
|
|
146
|
+
{ type: "website", url: VERIMU_TOOL_WEBSITE }
|
|
93
147
|
]
|
|
94
148
|
}
|
|
95
149
|
]
|
|
@@ -110,7 +164,7 @@ function generateSbom(input) {
|
|
|
110
164
|
purl: dep.purl,
|
|
111
165
|
"bom-ref": dep.purl,
|
|
112
166
|
scope: dep.direct ? "required" : "optional",
|
|
113
|
-
supplier: { name:
|
|
167
|
+
supplier: { name: dep.supplierName }
|
|
114
168
|
})),
|
|
115
169
|
dependencies: [
|
|
116
170
|
{
|
|
@@ -128,33 +182,130 @@ function generateSbom(input) {
|
|
|
128
182
|
generatedAt: timestamp
|
|
129
183
|
};
|
|
130
184
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
185
|
+
|
|
186
|
+
// src/generate-spdx.ts
|
|
187
|
+
var import_crypto2 = require("crypto");
|
|
188
|
+
var SPDX_VERSION = "2.3";
|
|
189
|
+
function generateSpdxSbom(input) {
|
|
190
|
+
const {
|
|
191
|
+
projectName,
|
|
192
|
+
projectVersion = DEFAULT_PROJECT_VERSION,
|
|
193
|
+
dependencies
|
|
194
|
+
} = input;
|
|
195
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
196
|
+
const resolvedDeps = normalizeDependencies(dependencies);
|
|
197
|
+
const rootPackageId = "SPDXRef-Package-root";
|
|
198
|
+
const spdx = {
|
|
199
|
+
spdxVersion: `SPDX-${SPDX_VERSION}`,
|
|
200
|
+
dataLicense: "CC0-1.0",
|
|
201
|
+
SPDXID: "SPDXRef-DOCUMENT",
|
|
202
|
+
name: `${projectName}-sbom`,
|
|
203
|
+
documentNamespace: `https://verimu.com/spdxdocs/${projectName}-${(0, import_crypto2.randomUUID)()}`,
|
|
204
|
+
creationInfo: {
|
|
205
|
+
created: timestamp,
|
|
206
|
+
creators: [`Tool: ${VERIMU_TOOL_NAME}`]
|
|
207
|
+
},
|
|
208
|
+
documentDescribes: [rootPackageId],
|
|
209
|
+
packages: [
|
|
210
|
+
{
|
|
211
|
+
name: projectName,
|
|
212
|
+
SPDXID: rootPackageId,
|
|
213
|
+
versionInfo: projectVersion,
|
|
214
|
+
supplier: `Organization: ${projectName}`,
|
|
215
|
+
downloadLocation: "NOASSERTION",
|
|
216
|
+
filesAnalyzed: false,
|
|
217
|
+
licenseConcluded: "NOASSERTION",
|
|
218
|
+
licenseDeclared: "NOASSERTION",
|
|
219
|
+
primaryPackagePurpose: "APPLICATION"
|
|
220
|
+
},
|
|
221
|
+
...resolvedDeps.map((dep, index) => ({
|
|
222
|
+
name: dep.name,
|
|
223
|
+
SPDXID: `SPDXRef-Package-${index + 1}`,
|
|
224
|
+
versionInfo: dep.version,
|
|
225
|
+
supplier: `Organization: ${dep.supplierName}`,
|
|
226
|
+
downloadLocation: "NOASSERTION",
|
|
227
|
+
filesAnalyzed: false,
|
|
228
|
+
licenseConcluded: "NOASSERTION",
|
|
229
|
+
licenseDeclared: "NOASSERTION",
|
|
230
|
+
primaryPackagePurpose: "LIBRARY",
|
|
231
|
+
externalRefs: [
|
|
232
|
+
{
|
|
233
|
+
referenceCategory: "PACKAGE-MANAGER",
|
|
234
|
+
referenceType: "purl",
|
|
235
|
+
referenceLocator: dep.purl
|
|
236
|
+
}
|
|
237
|
+
]
|
|
238
|
+
}))
|
|
239
|
+
],
|
|
240
|
+
relationships: [
|
|
241
|
+
{
|
|
242
|
+
spdxElementId: "SPDXRef-DOCUMENT",
|
|
243
|
+
relationshipType: "DESCRIBES",
|
|
244
|
+
relatedSpdxElement: rootPackageId
|
|
245
|
+
},
|
|
246
|
+
...resolvedDeps.map((_dep, index) => ({
|
|
247
|
+
spdxElementId: rootPackageId,
|
|
248
|
+
relationshipType: "DEPENDS_ON",
|
|
249
|
+
relatedSpdxElement: `SPDXRef-Package-${index + 1}`
|
|
250
|
+
}))
|
|
251
|
+
]
|
|
252
|
+
};
|
|
253
|
+
const content = JSON.stringify(spdx, null, 2);
|
|
254
|
+
return {
|
|
255
|
+
sbom: spdx,
|
|
256
|
+
content,
|
|
257
|
+
componentCount: resolvedDeps.length,
|
|
258
|
+
specVersion: SPDX_VERSION,
|
|
259
|
+
generatedAt: timestamp
|
|
260
|
+
};
|
|
147
261
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
262
|
+
|
|
263
|
+
// src/generate-swid.ts
|
|
264
|
+
var import_crypto3 = require("crypto");
|
|
265
|
+
var SWID_SPEC_VERSION = "ISO/IEC 19770-2:2015";
|
|
266
|
+
function generateSwidTag(input) {
|
|
267
|
+
const {
|
|
268
|
+
projectName,
|
|
269
|
+
projectVersion = DEFAULT_PROJECT_VERSION
|
|
270
|
+
} = input;
|
|
271
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
272
|
+
const tagVersion = 1;
|
|
273
|
+
const normalizedVersion = projectVersion || DEFAULT_SWID_VERSION;
|
|
274
|
+
const tagId = `com.verimu:${sanitizeTagId(projectName)}:${normalizedVersion}:${(0, import_crypto3.randomUUID)()}`;
|
|
275
|
+
const tag = [
|
|
276
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
277
|
+
"<SoftwareIdentity",
|
|
278
|
+
' xmlns="http://standards.iso.org/iso/19770/-2/2015/schema.xsd"',
|
|
279
|
+
` name="${escapeXml(projectName)}"`,
|
|
280
|
+
` tagId="${escapeXml(tagId)}"`,
|
|
281
|
+
` tagVersion="${tagVersion}"`,
|
|
282
|
+
` version="${escapeXml(normalizedVersion)}"`,
|
|
283
|
+
' versionScheme="semver">',
|
|
284
|
+
` <Entity name="${escapeXml(projectName)}" role="softwareCreator" />`,
|
|
285
|
+
' <Entity name="Verimu" role="tagCreator" />',
|
|
286
|
+
` <Meta product="${escapeXml(projectName)}" generator="${VERIMU_TOOL_NAME}" generated="${timestamp}" />`,
|
|
287
|
+
" <!-- TODO: Consider adding dependency/package evidence if we need richer SWID coverage. -->",
|
|
288
|
+
' <Link rel="describedby" href="https://verimu.com" />',
|
|
289
|
+
"</SoftwareIdentity>"
|
|
290
|
+
].join("\n");
|
|
291
|
+
return {
|
|
292
|
+
tag,
|
|
293
|
+
content: tag,
|
|
294
|
+
componentCount: 1,
|
|
295
|
+
specVersion: SWID_SPEC_VERSION,
|
|
296
|
+
generatedAt: timestamp
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function escapeXml(value) {
|
|
300
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
301
|
+
}
|
|
302
|
+
function sanitizeTagId(value) {
|
|
303
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
153
304
|
}
|
|
154
305
|
|
|
155
306
|
// src/scan.ts
|
|
156
|
-
var
|
|
157
|
-
var
|
|
307
|
+
var import_promises14 = require("fs/promises");
|
|
308
|
+
var import_path14 = require("path");
|
|
158
309
|
|
|
159
310
|
// src/scanners/npm/npm-scanner.ts
|
|
160
311
|
var import_promises = require("fs/promises");
|
|
@@ -172,7 +323,7 @@ var VerimuError = class extends Error {
|
|
|
172
323
|
var NoLockfileError = class extends VerimuError {
|
|
173
324
|
constructor(projectPath) {
|
|
174
325
|
super(
|
|
175
|
-
`No supported lockfile found in ${projectPath}. Supported: package-lock.json (npm), packages.lock.json (NuGet), Cargo.lock (Rust), requirements.txt / Pipfile.lock (
|
|
326
|
+
`No supported lockfile found in ${projectPath}. Supported: package-lock.json (npm), packages.lock.json (NuGet), Cargo.lock (Rust), requirements.txt / Pipfile.lock (pip), poetry.lock (Poetry), uv.lock (uv), pom.xml (Maven), go.sum (Go), Gemfile.lock (Ruby), composer.lock (Composer), yarn.lock (Yarn), pnpm-lock.yaml (pnpm)`,
|
|
176
327
|
"NO_LOCKFILE"
|
|
177
328
|
);
|
|
178
329
|
this.name = "NoLockfileError";
|
|
@@ -1089,159 +1240,1278 @@ var ComposerScanner = class {
|
|
|
1089
1240
|
}
|
|
1090
1241
|
};
|
|
1091
1242
|
|
|
1092
|
-
// src/scanners/
|
|
1093
|
-
var
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
]
|
|
1243
|
+
// src/scanners/yarn/yarn-scanner.ts
|
|
1244
|
+
var import_promises9 = require("fs/promises");
|
|
1245
|
+
var import_fs9 = require("fs");
|
|
1246
|
+
var import_path9 = __toESM(require("path"), 1);
|
|
1247
|
+
var import_yaml = require("yaml");
|
|
1248
|
+
var YarnScanner = class {
|
|
1249
|
+
ecosystem = "npm";
|
|
1250
|
+
lockfileNames = ["yarn.lock"];
|
|
1251
|
+
async detect(projectPath) {
|
|
1252
|
+
const lockfilePath = import_path9.default.join(projectPath, "yarn.lock");
|
|
1253
|
+
return (0, import_fs9.existsSync)(lockfilePath) ? lockfilePath : null;
|
|
1254
|
+
}
|
|
1255
|
+
async scan(projectPath, lockfilePath) {
|
|
1256
|
+
const [lockfileRaw, packageJsonRaw] = await Promise.all([
|
|
1257
|
+
(0, import_promises9.readFile)(lockfilePath, "utf-8"),
|
|
1258
|
+
(0, import_promises9.readFile)(import_path9.default.join(projectPath, "package.json"), "utf-8").catch(() => null)
|
|
1259
|
+
]);
|
|
1260
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
1261
|
+
if (packageJsonRaw) {
|
|
1262
|
+
try {
|
|
1263
|
+
const pkg = JSON.parse(packageJsonRaw);
|
|
1264
|
+
for (const name of Object.keys(pkg.dependencies ?? {})) {
|
|
1265
|
+
directNames.add(name);
|
|
1266
|
+
}
|
|
1267
|
+
for (const name of Object.keys(pkg.devDependencies ?? {})) {
|
|
1268
|
+
directNames.add(name);
|
|
1269
|
+
}
|
|
1270
|
+
} catch {
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const dependencies = this.parseLockfile(lockfileRaw, lockfilePath, directNames);
|
|
1274
|
+
return {
|
|
1275
|
+
projectPath,
|
|
1276
|
+
ecosystem: "npm",
|
|
1277
|
+
dependencies,
|
|
1278
|
+
lockfilePath,
|
|
1279
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1280
|
+
};
|
|
1106
1281
|
}
|
|
1107
1282
|
/**
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1283
|
+
* Parses yarn.lock file and extracts dependencies.
|
|
1284
|
+
* Automatically detects and handles both v1 (Classic) and v2+ (Berry) formats.
|
|
1110
1285
|
*/
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const
|
|
1114
|
-
if (
|
|
1115
|
-
return
|
|
1286
|
+
parseLockfile(content, lockfilePath, directNames) {
|
|
1287
|
+
try {
|
|
1288
|
+
const isV2Plus = this.isYarnV2Plus(content);
|
|
1289
|
+
if (isV2Plus) {
|
|
1290
|
+
return this.parseLockfileV2Plus(content, lockfilePath, directNames);
|
|
1291
|
+
} else {
|
|
1292
|
+
return this.parseLockfileV1(content, lockfilePath, directNames);
|
|
1116
1293
|
}
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
throw new LockfileParseError(
|
|
1296
|
+
lockfilePath,
|
|
1297
|
+
`Failed to parse yarn.lock: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
1298
|
+
);
|
|
1117
1299
|
}
|
|
1118
|
-
throw new NoLockfileError(projectPath);
|
|
1119
|
-
}
|
|
1120
|
-
/** Returns a specific scanner by ecosystem name */
|
|
1121
|
-
getScanner(ecosystem) {
|
|
1122
|
-
return this.scanners.find((s) => s.ecosystem === ecosystem);
|
|
1123
1300
|
}
|
|
1124
|
-
/**
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
// src/sbom/cyclonedx.ts
|
|
1131
|
-
var import_crypto2 = require("crypto");
|
|
1132
|
-
var CycloneDxGenerator = class {
|
|
1133
|
-
format = "cyclonedx-json";
|
|
1134
|
-
generate(scanResult, toolVersion = "0.1.0") {
|
|
1135
|
-
const bom = this.buildBom(scanResult, toolVersion);
|
|
1136
|
-
const content = JSON.stringify(bom, null, 2);
|
|
1137
|
-
return {
|
|
1138
|
-
format: "cyclonedx-json",
|
|
1139
|
-
specVersion: "1.7",
|
|
1140
|
-
content,
|
|
1141
|
-
componentCount: scanResult.dependencies.length,
|
|
1142
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1143
|
-
};
|
|
1301
|
+
/**
|
|
1302
|
+
* Detects if the lockfile is Yarn v2+ (Berry) format.
|
|
1303
|
+
* v2+ uses YAML format and contains __metadata section.
|
|
1304
|
+
*/
|
|
1305
|
+
isYarnV2Plus(content) {
|
|
1306
|
+
return content.startsWith("__metadata:") || content.includes("\n__metadata:");
|
|
1144
1307
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
name: projectName
|
|
1175
|
-
},
|
|
1176
|
-
component: {
|
|
1177
|
-
type: "application",
|
|
1178
|
-
name: projectName,
|
|
1179
|
-
"bom-ref": "root-component",
|
|
1180
|
-
supplier: { name: projectName }
|
|
1308
|
+
/**
|
|
1309
|
+
* Parses Yarn v2+ (Berry) lockfile format.
|
|
1310
|
+
*
|
|
1311
|
+
* Yarn v2+ format (YAML):
|
|
1312
|
+
* ```yaml
|
|
1313
|
+
* __metadata:
|
|
1314
|
+
* version: 6
|
|
1315
|
+
*
|
|
1316
|
+
* "package-name@npm:^1.0.0":
|
|
1317
|
+
* version: 1.2.3
|
|
1318
|
+
* resolution: "package-name@npm:1.2.3"
|
|
1319
|
+
* dependencies:
|
|
1320
|
+
* dep1: ^2.0.0
|
|
1321
|
+
* checksum: ...
|
|
1322
|
+
* languageName: node
|
|
1323
|
+
* linkType: hard
|
|
1324
|
+
* ```
|
|
1325
|
+
*/
|
|
1326
|
+
parseLockfileV2Plus(content, lockfilePath, directNames) {
|
|
1327
|
+
const deps = [];
|
|
1328
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1329
|
+
try {
|
|
1330
|
+
const parsed = (0, import_yaml.parse)(content);
|
|
1331
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1332
|
+
throw new Error("Invalid YAML format");
|
|
1333
|
+
}
|
|
1334
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1335
|
+
if (key === "__metadata" || key.includes("@workspace:")) {
|
|
1336
|
+
continue;
|
|
1181
1337
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1338
|
+
if (typeof value !== "object" || value === null) {
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
const entry = value;
|
|
1342
|
+
let name = null;
|
|
1343
|
+
if (entry.resolution && typeof entry.resolution === "string") {
|
|
1344
|
+
name = this.extractPackageNameFromResolution(entry.resolution);
|
|
1345
|
+
}
|
|
1346
|
+
if (!name) {
|
|
1347
|
+
name = this.extractPackageNameV2Plus(key);
|
|
1348
|
+
}
|
|
1349
|
+
const version = entry.version;
|
|
1350
|
+
if (!name || !version || typeof version !== "string") {
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
const depKey = `${name}@${version}`;
|
|
1354
|
+
if (seen.has(depKey)) {
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
seen.set(depKey, true);
|
|
1358
|
+
deps.push({
|
|
1359
|
+
name,
|
|
1360
|
+
version,
|
|
1361
|
+
direct: directNames.has(name),
|
|
1362
|
+
ecosystem: "npm",
|
|
1363
|
+
purl: this.buildPurl(name, version)
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
throw new Error(`Failed to parse Yarn v2+ lockfile: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1368
|
+
}
|
|
1369
|
+
return deps;
|
|
1186
1370
|
}
|
|
1187
|
-
/**
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1371
|
+
/**
|
|
1372
|
+
* Extracts package name from Yarn v2+ resolution field.
|
|
1373
|
+
* The resolution field contains the real package name.
|
|
1374
|
+
* Examples:
|
|
1375
|
+
* "express@npm:4.18.2" → "express"
|
|
1376
|
+
* "@types/node@npm:20.11.5" → "@types/node"
|
|
1377
|
+
* "lodash@npm:4.17.21" → "lodash"
|
|
1378
|
+
*/
|
|
1379
|
+
extractPackageNameFromResolution(resolution) {
|
|
1380
|
+
if (resolution.startsWith("@")) {
|
|
1381
|
+
const match2 = resolution.match(/^(@[^@]+\/[^@]+)@/);
|
|
1382
|
+
if (match2) {
|
|
1383
|
+
return match2[1];
|
|
1199
1384
|
}
|
|
1200
|
-
}
|
|
1385
|
+
}
|
|
1386
|
+
const match = resolution.match(/^([^@]+)@/);
|
|
1387
|
+
if (match) {
|
|
1388
|
+
return match[1];
|
|
1389
|
+
}
|
|
1390
|
+
return null;
|
|
1201
1391
|
}
|
|
1202
1392
|
/**
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
* This is the same heuristic used by Syft, Trivy, and other SBOM tools
|
|
1209
|
-
* when registry metadata (author/publisher) isn't available from the lockfile.
|
|
1393
|
+
* Extracts package name from Yarn v2+ package key.
|
|
1394
|
+
* Examples:
|
|
1395
|
+
* "express@npm:^4.18.0" → "express"
|
|
1396
|
+
* "@types/node@npm:^20.0.0" → "@types/node"
|
|
1397
|
+
* "pkg@npm:other@npm:^1.0.0" → "pkg" (aliased packages)
|
|
1210
1398
|
*/
|
|
1211
|
-
|
|
1212
|
-
if (
|
|
1213
|
-
const
|
|
1214
|
-
|
|
1399
|
+
extractPackageNameV2Plus(key) {
|
|
1400
|
+
if (key.startsWith("@")) {
|
|
1401
|
+
const match2 = key.match(/^(@[^@]+\/[^@]+)@/);
|
|
1402
|
+
if (match2) {
|
|
1403
|
+
return match2[1];
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
const match = key.match(/^([^@]+)@/);
|
|
1407
|
+
if (match) {
|
|
1408
|
+
return match[1];
|
|
1215
1409
|
}
|
|
1216
|
-
return
|
|
1410
|
+
return null;
|
|
1217
1411
|
}
|
|
1218
1412
|
/**
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
* The root component depends on all dependencies (direct + transitive).
|
|
1222
|
-
* This ensures a single root node in the graph, which NTIA validators expect.
|
|
1413
|
+
* Parses Yarn v1 (Classic) lockfile format.
|
|
1223
1414
|
*
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1415
|
+
* Yarn v1 format:
|
|
1416
|
+
* ```
|
|
1417
|
+
* "package-name@^1.0.0":
|
|
1418
|
+
* version "1.2.3"
|
|
1419
|
+
* resolved "https://..."
|
|
1420
|
+
* integrity sha512-...
|
|
1421
|
+
* dependencies:
|
|
1422
|
+
* dep1 "^2.0.0"
|
|
1423
|
+
* ```
|
|
1228
1424
|
*/
|
|
1229
|
-
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1425
|
+
parseLockfileV1(content, lockfilePath, directNames) {
|
|
1426
|
+
const deps = [];
|
|
1427
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1428
|
+
const lines = content.split("\n");
|
|
1429
|
+
let currentPackage = null;
|
|
1430
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1431
|
+
const line = lines[i];
|
|
1432
|
+
if (line.trim().startsWith("#") || line.trim() === "") {
|
|
1433
|
+
continue;
|
|
1235
1434
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1435
|
+
if (line.match(/^["\w@]/) && line.includes(":") && !line.startsWith(" ")) {
|
|
1436
|
+
if (currentPackage?.version) {
|
|
1437
|
+
this.addDependency(currentPackage, directNames, seen, deps);
|
|
1438
|
+
}
|
|
1439
|
+
const pkgLine = line.substring(0, line.lastIndexOf(":"));
|
|
1440
|
+
const names = pkgLine.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).map((s) => this.extractPackageName(s)).filter((s) => !!s);
|
|
1441
|
+
currentPackage = { names, version: void 0 };
|
|
1442
|
+
} else if (line.trim().startsWith("version ") && currentPackage) {
|
|
1443
|
+
const match = line.match(/version\s+"([^"]+)"/);
|
|
1444
|
+
if (match) {
|
|
1445
|
+
currentPackage.version = match[1];
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
if (currentPackage?.version) {
|
|
1450
|
+
this.addDependency(currentPackage, directNames, seen, deps);
|
|
1451
|
+
}
|
|
1452
|
+
return deps;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Adds a dependency to the result list (deduplicates by name@version)
|
|
1456
|
+
*/
|
|
1457
|
+
addDependency(pkg, directNames, seen, deps) {
|
|
1458
|
+
if (!pkg.version) return;
|
|
1459
|
+
const name = pkg.names[0];
|
|
1460
|
+
if (!name) return;
|
|
1461
|
+
const key = `${name}@${pkg.version}`;
|
|
1462
|
+
if (seen.has(key)) return;
|
|
1463
|
+
seen.set(key, true);
|
|
1464
|
+
deps.push({
|
|
1465
|
+
name,
|
|
1466
|
+
version: pkg.version,
|
|
1467
|
+
direct: directNames.has(name),
|
|
1468
|
+
ecosystem: "npm",
|
|
1469
|
+
purl: this.buildPurl(name, pkg.version)
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Extracts package name from yarn.lock package declaration.
|
|
1474
|
+
* Examples:
|
|
1475
|
+
* "express@^4.18.0" → "express"
|
|
1476
|
+
* "@types/node@^20.0.0" → "@types/node"
|
|
1477
|
+
* "pkg@npm:other@^1.0.0" → "pkg" (aliased packages)
|
|
1478
|
+
*/
|
|
1479
|
+
extractPackageName(pkgDeclaration) {
|
|
1480
|
+
if (pkgDeclaration.includes("@npm:")) {
|
|
1481
|
+
const beforeAlias = pkgDeclaration.split("@npm:")[0];
|
|
1482
|
+
return beforeAlias || null;
|
|
1483
|
+
}
|
|
1484
|
+
if (pkgDeclaration.startsWith("@")) {
|
|
1485
|
+
const parts = pkgDeclaration.split("@");
|
|
1486
|
+
if (parts.length >= 3) {
|
|
1487
|
+
return `@${parts[1]}`;
|
|
1488
|
+
}
|
|
1489
|
+
} else {
|
|
1490
|
+
const atIndex = pkgDeclaration.indexOf("@");
|
|
1491
|
+
if (atIndex > 0) {
|
|
1492
|
+
return pkgDeclaration.substring(0, atIndex);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Builds a purl (Package URL) for an npm package.
|
|
1499
|
+
*
|
|
1500
|
+
* Per the purl spec:
|
|
1501
|
+
* "The npm scope @ sign prefix is always percent encoded."
|
|
1502
|
+
*
|
|
1503
|
+
* So @types/node@20.11.5 → pkg:npm/%40types/node@20.11.5
|
|
1504
|
+
* And express@4.18.2 → pkg:npm/express@4.18.2
|
|
1505
|
+
*/
|
|
1506
|
+
buildPurl(name, version) {
|
|
1507
|
+
if (name.startsWith("@")) {
|
|
1508
|
+
return `pkg:npm/%40${name.slice(1)}@${version}`;
|
|
1509
|
+
}
|
|
1510
|
+
return `pkg:npm/${name}@${version}`;
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
// src/scanners/pnpm/pnpm-scanner.ts
|
|
1515
|
+
var import_promises10 = require("fs/promises");
|
|
1516
|
+
var import_fs10 = require("fs");
|
|
1517
|
+
var import_path10 = __toESM(require("path"), 1);
|
|
1518
|
+
var import_yaml2 = require("yaml");
|
|
1519
|
+
var PnpmScanner = class {
|
|
1520
|
+
ecosystem = "npm";
|
|
1521
|
+
lockfileNames = ["pnpm-lock.yaml"];
|
|
1522
|
+
async detect(projectPath) {
|
|
1523
|
+
const lockfilePath = import_path10.default.join(projectPath, "pnpm-lock.yaml");
|
|
1524
|
+
return (0, import_fs10.existsSync)(lockfilePath) ? lockfilePath : null;
|
|
1525
|
+
}
|
|
1526
|
+
async scan(projectPath, lockfilePath) {
|
|
1527
|
+
const lockfileRaw = await (0, import_promises10.readFile)(lockfilePath, "utf-8");
|
|
1528
|
+
const dependencies = this.parseLockfile(lockfileRaw, lockfilePath);
|
|
1529
|
+
return {
|
|
1530
|
+
projectPath,
|
|
1531
|
+
ecosystem: "npm",
|
|
1532
|
+
dependencies,
|
|
1533
|
+
lockfilePath,
|
|
1534
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Parses pnpm-lock.yaml file and extracts dependencies.
|
|
1539
|
+
*
|
|
1540
|
+
* pnpm-lock.yaml format (v5.4+):
|
|
1541
|
+
* ```yaml
|
|
1542
|
+
* lockfileVersion: 5.4
|
|
1543
|
+
*
|
|
1544
|
+
* dependencies:
|
|
1545
|
+
* express: 4.18.2
|
|
1546
|
+
*
|
|
1547
|
+
* devDependencies:
|
|
1548
|
+
* typescript: 5.0.0
|
|
1549
|
+
*
|
|
1550
|
+
* packages:
|
|
1551
|
+
* /express/4.18.2:
|
|
1552
|
+
* resolution: {integrity: sha512-...}
|
|
1553
|
+
* dependencies:
|
|
1554
|
+
* accepts: 1.3.8
|
|
1555
|
+
* /@types/node/20.11.5:
|
|
1556
|
+
* resolution: {integrity: sha512-...}
|
|
1557
|
+
* dev: true
|
|
1558
|
+
* ```
|
|
1559
|
+
*
|
|
1560
|
+
* pnpm-lock.yaml format (v6.0+):
|
|
1561
|
+
* ```yaml
|
|
1562
|
+
* lockfileVersion: '6.0'
|
|
1563
|
+
*
|
|
1564
|
+
* dependencies:
|
|
1565
|
+
* express:
|
|
1566
|
+
* specifier: ^4.18.0
|
|
1567
|
+
* version: 4.18.2
|
|
1568
|
+
*
|
|
1569
|
+
* packages:
|
|
1570
|
+
* /express@4.18.2:
|
|
1571
|
+
* resolution: {integrity: sha512-...}
|
|
1572
|
+
* ```
|
|
1573
|
+
*/
|
|
1574
|
+
parseLockfile(content, lockfilePath) {
|
|
1575
|
+
try {
|
|
1576
|
+
const parsed = (0, import_yaml2.parse)(content);
|
|
1577
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1578
|
+
throw new Error("Invalid YAML format");
|
|
1579
|
+
}
|
|
1580
|
+
const lockfile = parsed;
|
|
1581
|
+
const lockfileVersion = this.parseLockfileVersion(lockfile.lockfileVersion);
|
|
1582
|
+
const directNames = this.extractDirectDependencies(lockfile);
|
|
1583
|
+
return this.extractDependencies(lockfile, lockfileVersion, directNames);
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
throw new LockfileParseError(
|
|
1586
|
+
lockfilePath,
|
|
1587
|
+
`Failed to parse pnpm-lock.yaml: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Extracts direct dependency names from pnpm lockfile.
|
|
1593
|
+
*
|
|
1594
|
+
* Supports both formats:
|
|
1595
|
+
* - pnpm v5.x: root-level dependencies/devDependencies
|
|
1596
|
+
* - pnpm v6+: importers['.'].dependencies/devDependencies
|
|
1597
|
+
*/
|
|
1598
|
+
extractDirectDependencies(lockfile) {
|
|
1599
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
1600
|
+
if (lockfile.importers && typeof lockfile.importers === "object") {
|
|
1601
|
+
const rootImporter = lockfile.importers["."];
|
|
1602
|
+
if (rootImporter && typeof rootImporter === "object") {
|
|
1603
|
+
if (rootImporter.dependencies && typeof rootImporter.dependencies === "object") {
|
|
1604
|
+
for (const name of Object.keys(rootImporter.dependencies)) {
|
|
1605
|
+
directNames.add(name);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
if (rootImporter.devDependencies && typeof rootImporter.devDependencies === "object") {
|
|
1609
|
+
for (const name of Object.keys(rootImporter.devDependencies)) {
|
|
1610
|
+
directNames.add(name);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (directNames.size === 0) {
|
|
1616
|
+
if (lockfile.dependencies && typeof lockfile.dependencies === "object") {
|
|
1617
|
+
for (const name of Object.keys(lockfile.dependencies)) {
|
|
1618
|
+
directNames.add(name);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (lockfile.devDependencies && typeof lockfile.devDependencies === "object") {
|
|
1622
|
+
for (const name of Object.keys(lockfile.devDependencies)) {
|
|
1623
|
+
directNames.add(name);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
return directNames;
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Parses lockfile version (can be string or number)
|
|
1631
|
+
*/
|
|
1632
|
+
parseLockfileVersion(version) {
|
|
1633
|
+
if (typeof version === "number") {
|
|
1634
|
+
return version;
|
|
1635
|
+
}
|
|
1636
|
+
if (typeof version === "string") {
|
|
1637
|
+
const parsed = parseFloat(version);
|
|
1638
|
+
return isNaN(parsed) ? 5.4 : parsed;
|
|
1639
|
+
}
|
|
1640
|
+
return 5.4;
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Extracts dependencies from the lockfile packages section
|
|
1644
|
+
*/
|
|
1645
|
+
extractDependencies(lockfile, lockfileVersion, directNames) {
|
|
1646
|
+
const deps = [];
|
|
1647
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1648
|
+
if (!lockfile.packages || typeof lockfile.packages !== "object") {
|
|
1649
|
+
return deps;
|
|
1650
|
+
}
|
|
1651
|
+
for (const [pkgPath, pkgInfo] of Object.entries(lockfile.packages)) {
|
|
1652
|
+
if (!pkgInfo || typeof pkgInfo !== "object") {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
if (pkgPath.includes("@workspace:")) {
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
const { name, version } = this.parsePackagePath(pkgPath, lockfileVersion);
|
|
1659
|
+
if (!name || !version) {
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
const depKey = `${name}@${version}`;
|
|
1663
|
+
if (seen.has(depKey)) {
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
seen.set(depKey, true);
|
|
1667
|
+
deps.push({
|
|
1668
|
+
name,
|
|
1669
|
+
version,
|
|
1670
|
+
direct: directNames.has(name),
|
|
1671
|
+
ecosystem: "npm",
|
|
1672
|
+
purl: this.buildPurl(name, version)
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
return deps;
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Parses package path to extract name and version.
|
|
1679
|
+
*
|
|
1680
|
+
* pnpm v5.x format:
|
|
1681
|
+
* "/express/4.18.2" → name: "express", version: "4.18.2"
|
|
1682
|
+
* "/@types/node/20.11.5" → name: "@types/node", version: "20.11.5"
|
|
1683
|
+
* "/accepts/1.3.8" → name: "accepts", version: "1.3.8"
|
|
1684
|
+
*
|
|
1685
|
+
* pnpm v6+ format:
|
|
1686
|
+
* "/express@4.18.2" → name: "express", version: "4.18.2"
|
|
1687
|
+
* "/@types/node@20.11.5" → name: "@types/node", version: "20.11.5"
|
|
1688
|
+
* "/accepts@1.3.8" → name: "accepts", version: "1.3.8"
|
|
1689
|
+
*
|
|
1690
|
+
* Also handles peer dependency suffixes:
|
|
1691
|
+
* "/pkg@1.0.0_dep@2.0.0" → name: "pkg", version: "1.0.0"
|
|
1692
|
+
* "/pkg@1.0.0(dep@2.0.0)" → name: "pkg", version: "1.0.0"
|
|
1693
|
+
*/
|
|
1694
|
+
parsePackagePath(pkgPath, lockfileVersion) {
|
|
1695
|
+
const path14 = pkgPath.startsWith("/") ? pkgPath.slice(1) : pkgPath;
|
|
1696
|
+
const cleanPath = path14.split("_")[0].split("(")[0];
|
|
1697
|
+
if (!cleanPath) {
|
|
1698
|
+
return { name: null, version: null };
|
|
1699
|
+
}
|
|
1700
|
+
if (lockfileVersion >= 6) {
|
|
1701
|
+
return this.parseV6Format(cleanPath);
|
|
1702
|
+
}
|
|
1703
|
+
return this.parseV5Format(cleanPath);
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Parses v6+ format: "express@4.18.2" or "@types/node@20.11.5"
|
|
1707
|
+
*/
|
|
1708
|
+
parseV6Format(path14) {
|
|
1709
|
+
if (path14.startsWith("@")) {
|
|
1710
|
+
const lastAtIndex = path14.lastIndexOf("@");
|
|
1711
|
+
if (lastAtIndex <= 0) {
|
|
1712
|
+
return { name: null, version: null };
|
|
1713
|
+
}
|
|
1714
|
+
const name2 = path14.substring(0, lastAtIndex);
|
|
1715
|
+
const version2 = path14.substring(lastAtIndex + 1);
|
|
1716
|
+
return { name: name2, version: version2 };
|
|
1717
|
+
}
|
|
1718
|
+
const atIndex = path14.indexOf("@");
|
|
1719
|
+
if (atIndex < 0) {
|
|
1720
|
+
return { name: null, version: null };
|
|
1721
|
+
}
|
|
1722
|
+
const name = path14.substring(0, atIndex);
|
|
1723
|
+
const version = path14.substring(atIndex + 1);
|
|
1724
|
+
return { name, version };
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Parses v5.x format: "express/4.18.2" or "@types/node/20.11.5"
|
|
1728
|
+
*/
|
|
1729
|
+
parseV5Format(path14) {
|
|
1730
|
+
if (path14.startsWith("@")) {
|
|
1731
|
+
const parts = path14.split("/");
|
|
1732
|
+
if (parts.length < 3) {
|
|
1733
|
+
return { name: null, version: null };
|
|
1734
|
+
}
|
|
1735
|
+
const name2 = `${parts[0]}/${parts[1]}`;
|
|
1736
|
+
const version2 = parts[2];
|
|
1737
|
+
return { name: name2, version: version2 };
|
|
1738
|
+
}
|
|
1739
|
+
const slashIndex = path14.indexOf("/");
|
|
1740
|
+
if (slashIndex < 0) {
|
|
1741
|
+
return { name: null, version: null };
|
|
1742
|
+
}
|
|
1743
|
+
const name = path14.substring(0, slashIndex);
|
|
1744
|
+
const version = path14.substring(slashIndex + 1);
|
|
1745
|
+
return { name, version };
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Builds a purl (Package URL) for an npm package.
|
|
1749
|
+
*
|
|
1750
|
+
* Per the purl spec:
|
|
1751
|
+
* "The npm scope @ sign prefix is always percent encoded."
|
|
1752
|
+
*
|
|
1753
|
+
* So @types/node@20.11.5 → pkg:npm/%40types/node@20.11.5
|
|
1754
|
+
* And express@4.18.2 → pkg:npm/express@4.18.2
|
|
1755
|
+
*/
|
|
1756
|
+
buildPurl(name, version) {
|
|
1757
|
+
if (name.startsWith("@")) {
|
|
1758
|
+
return `pkg:npm/%40${name.slice(1)}@${version}`;
|
|
1759
|
+
}
|
|
1760
|
+
return `pkg:npm/${name}@${version}`;
|
|
1761
|
+
}
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
// src/scanners/deno/deno-scanner.ts
|
|
1765
|
+
var import_promises11 = require("fs/promises");
|
|
1766
|
+
var import_fs11 = require("fs");
|
|
1767
|
+
var import_path11 = __toESM(require("path"), 1);
|
|
1768
|
+
var DenoScanner = class {
|
|
1769
|
+
ecosystem = "deno";
|
|
1770
|
+
lockfileNames = ["deno.lock"];
|
|
1771
|
+
async detect(projectPath) {
|
|
1772
|
+
for (const name of this.lockfileNames) {
|
|
1773
|
+
const lockfilePath = import_path11.default.join(projectPath, name);
|
|
1774
|
+
if ((0, import_fs11.existsSync)(lockfilePath)) return lockfilePath;
|
|
1775
|
+
}
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
async scan(projectPath, lockfilePath) {
|
|
1779
|
+
const lockfileRaw = await (0, import_promises11.readFile)(lockfilePath, "utf-8");
|
|
1780
|
+
let lockfile;
|
|
1781
|
+
try {
|
|
1782
|
+
lockfile = JSON.parse(lockfileRaw);
|
|
1783
|
+
} catch {
|
|
1784
|
+
throw new LockfileParseError(lockfilePath, "Invalid JSON");
|
|
1785
|
+
}
|
|
1786
|
+
const dependencies = this.parseLockfile(lockfile);
|
|
1787
|
+
return {
|
|
1788
|
+
projectPath,
|
|
1789
|
+
ecosystem: "deno",
|
|
1790
|
+
dependencies,
|
|
1791
|
+
lockfilePath,
|
|
1792
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Parses the lockfile and extracts dependencies from both
|
|
1797
|
+
* npm and jsr package registries.
|
|
1798
|
+
*
|
|
1799
|
+
* Supports v3 (packages nested under `packages`), v4, and v5 (top-level jsr/npm sections).
|
|
1800
|
+
* Uses lockfile specifiers to determine direct vs transitive dependencies.
|
|
1801
|
+
*/
|
|
1802
|
+
parseLockfile(lockfile) {
|
|
1803
|
+
const deps = [];
|
|
1804
|
+
const directNames = this.extractDirectDependencies(lockfile);
|
|
1805
|
+
const jsrPackages = lockfile.jsr ?? lockfile.packages?.jsr ?? {};
|
|
1806
|
+
const npmPackages = lockfile.npm ?? lockfile.packages?.npm ?? {};
|
|
1807
|
+
for (const key of Object.keys(jsrPackages)) {
|
|
1808
|
+
const parsed = this.parsePackageKey(key);
|
|
1809
|
+
if (!parsed) continue;
|
|
1810
|
+
deps.push({
|
|
1811
|
+
name: parsed.name,
|
|
1812
|
+
version: parsed.version,
|
|
1813
|
+
direct: directNames.has(`jsr:${parsed.name}`),
|
|
1814
|
+
ecosystem: "deno",
|
|
1815
|
+
// JSR packages belong to Deno ecosystem
|
|
1816
|
+
purl: this.buildJsrPurl(parsed.name, parsed.version)
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
for (const key of Object.keys(npmPackages)) {
|
|
1820
|
+
const parsed = this.parsePackageKey(key);
|
|
1821
|
+
if (!parsed) continue;
|
|
1822
|
+
deps.push({
|
|
1823
|
+
name: parsed.name,
|
|
1824
|
+
version: parsed.version,
|
|
1825
|
+
direct: directNames.has(`npm:${parsed.name}`),
|
|
1826
|
+
ecosystem: "npm",
|
|
1827
|
+
// npm packages belong to npm ecosystem (for CVE tracking)
|
|
1828
|
+
purl: this.buildNpmPurl(parsed.name, parsed.version)
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
return deps;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Extracts direct dependency names from lockfile specifiers.
|
|
1835
|
+
*
|
|
1836
|
+
* Returns a set of ecosystem-qualified package names like "jsr:@std/assert" or "npm:express".
|
|
1837
|
+
* The ecosystem prefix prevents collisions if the same package name exists in both registries.
|
|
1838
|
+
*/
|
|
1839
|
+
extractDirectDependencies(lockfile) {
|
|
1840
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
1841
|
+
const specifiers = lockfile.specifiers ?? lockfile.packages?.specifiers ?? {};
|
|
1842
|
+
for (const [constraint, resolved] of Object.entries(specifiers)) {
|
|
1843
|
+
if (resolved.startsWith("file:") || resolved.startsWith("https:") || resolved.startsWith("http:") || resolved.startsWith("data:")) {
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
let ecosystem = null;
|
|
1847
|
+
if (constraint.startsWith("jsr:")) {
|
|
1848
|
+
ecosystem = "jsr";
|
|
1849
|
+
} else if (constraint.startsWith("npm:")) {
|
|
1850
|
+
ecosystem = "npm";
|
|
1851
|
+
}
|
|
1852
|
+
if (!ecosystem) continue;
|
|
1853
|
+
let resolvedKey = resolved;
|
|
1854
|
+
if (resolved.startsWith("jsr:") || resolved.startsWith("npm:")) {
|
|
1855
|
+
resolvedKey = resolved.replace(/^(jsr:|npm:)/, "");
|
|
1856
|
+
const parsed = this.parsePackageKey(resolvedKey);
|
|
1857
|
+
if (parsed) {
|
|
1858
|
+
directNames.add(`${ecosystem}:${parsed.name}`);
|
|
1859
|
+
}
|
|
1860
|
+
} else {
|
|
1861
|
+
const name = this.extractNameFromSpecifier(constraint);
|
|
1862
|
+
if (name) {
|
|
1863
|
+
directNames.add(`${ecosystem}:${name}`);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return directNames;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Parses a package key like "@std/assert@1.0.10" or "express@4.21.2"
|
|
1871
|
+
* into { name, version }.
|
|
1872
|
+
*
|
|
1873
|
+
* Handles scoped packages where the name starts with @ (e.g., @std/assert).
|
|
1874
|
+
* In that case the version separator is the LAST @ sign.
|
|
1875
|
+
*/
|
|
1876
|
+
parsePackageKey(key) {
|
|
1877
|
+
const lastAtIndex = key.lastIndexOf("@");
|
|
1878
|
+
if (lastAtIndex <= 0) return null;
|
|
1879
|
+
const name = key.slice(0, lastAtIndex);
|
|
1880
|
+
const version = key.slice(lastAtIndex + 1);
|
|
1881
|
+
if (!name || !version) return null;
|
|
1882
|
+
return { name, version };
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Extracts the package name from a Deno import specifier.
|
|
1886
|
+
*
|
|
1887
|
+
* Examples:
|
|
1888
|
+
* "jsr:@std/assert@^1.0.0" → "@std/assert"
|
|
1889
|
+
* "npm:express@^4.18.0" → "express"
|
|
1890
|
+
* "npm:@hono/hono@^4.0.0" → "@hono/hono"
|
|
1891
|
+
* "lodash" (bare) → "lodash"
|
|
1892
|
+
*/
|
|
1893
|
+
extractNameFromSpecifier(specifier) {
|
|
1894
|
+
const withoutPrefix = specifier.replace(/^(jsr:|npm:)/, "");
|
|
1895
|
+
if (!withoutPrefix) return null;
|
|
1896
|
+
if (withoutPrefix.startsWith("@")) {
|
|
1897
|
+
const slashIndex = withoutPrefix.indexOf("/");
|
|
1898
|
+
if (slashIndex === -1) return null;
|
|
1899
|
+
const afterSlash = withoutPrefix.indexOf("@", slashIndex);
|
|
1900
|
+
if (afterSlash === -1) return withoutPrefix;
|
|
1901
|
+
return withoutPrefix.slice(0, afterSlash);
|
|
1902
|
+
}
|
|
1903
|
+
const atIndex = withoutPrefix.indexOf("@");
|
|
1904
|
+
if (atIndex === -1) return withoutPrefix;
|
|
1905
|
+
return withoutPrefix.slice(0, atIndex);
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Builds a purl for a JSR package.
|
|
1909
|
+
*
|
|
1910
|
+
* JSR packages use the "jsr" purl type (non-standard but descriptive).
|
|
1911
|
+
* For scoped packages, both @ and all / characters are percent-encoded.
|
|
1912
|
+
* Example: `pkg:jsr/%40std%2Fassert@1.0.10`
|
|
1913
|
+
*/
|
|
1914
|
+
buildJsrPurl(name, version) {
|
|
1915
|
+
if (name.startsWith("@")) {
|
|
1916
|
+
const encoded = "%40" + name.slice(1).replace(/\//g, "%2F");
|
|
1917
|
+
return `pkg:jsr/${encoded}@${version}`;
|
|
1918
|
+
}
|
|
1919
|
+
return `pkg:jsr/${name}@${version}`;
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Builds a purl for an npm package used via Deno.
|
|
1923
|
+
*
|
|
1924
|
+
* Uses the standard npm purl type since these are npm packages.
|
|
1925
|
+
* Per npm purl spec, only @ is encoded, / remains as namespace separator.
|
|
1926
|
+
* Example: `pkg:npm/%40std/assert@1.0.10` or `pkg:npm/express@4.21.2`
|
|
1927
|
+
*/
|
|
1928
|
+
buildNpmPurl(name, version) {
|
|
1929
|
+
if (name.startsWith("@")) {
|
|
1930
|
+
return `pkg:npm/%40${name.slice(1)}@${version}`;
|
|
1931
|
+
}
|
|
1932
|
+
return `pkg:npm/${name}@${version}`;
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
// src/scanners/poetry/poetry-scanner.ts
|
|
1937
|
+
var import_promises12 = require("fs/promises");
|
|
1938
|
+
var import_fs12 = require("fs");
|
|
1939
|
+
var import_path12 = __toESM(require("path"), 1);
|
|
1940
|
+
var PoetryScanner = class {
|
|
1941
|
+
ecosystem = "poetry";
|
|
1942
|
+
lockfileNames = ["poetry.lock"];
|
|
1943
|
+
async detect(projectPath) {
|
|
1944
|
+
const lockfilePath = import_path12.default.join(projectPath, "poetry.lock");
|
|
1945
|
+
return (0, import_fs12.existsSync)(lockfilePath) ? lockfilePath : null;
|
|
1946
|
+
}
|
|
1947
|
+
async scan(projectPath, lockfilePath) {
|
|
1948
|
+
const [lockfileRaw, pyprojectRaw] = await Promise.all([
|
|
1949
|
+
(0, import_promises12.readFile)(lockfilePath, "utf-8"),
|
|
1950
|
+
(0, import_promises12.readFile)(import_path12.default.join(projectPath, "pyproject.toml"), "utf-8").catch(() => null)
|
|
1951
|
+
]);
|
|
1952
|
+
const packages = this.parseLockfile(lockfileRaw, lockfilePath);
|
|
1953
|
+
const directNames = pyprojectRaw ? this.parsePyprojectToml(pyprojectRaw) : /* @__PURE__ */ new Set();
|
|
1954
|
+
const dependencies = [];
|
|
1955
|
+
for (const pkg of packages) {
|
|
1956
|
+
dependencies.push({
|
|
1957
|
+
name: this.normalizePipName(pkg.name),
|
|
1958
|
+
version: pkg.version,
|
|
1959
|
+
direct: directNames.size > 0 ? directNames.has(this.normalizePipName(pkg.name)) : true,
|
|
1960
|
+
ecosystem: "poetry",
|
|
1961
|
+
purl: this.buildPurl(pkg.name, pkg.version)
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
return {
|
|
1965
|
+
projectPath,
|
|
1966
|
+
ecosystem: "poetry",
|
|
1967
|
+
dependencies,
|
|
1968
|
+
lockfilePath,
|
|
1969
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Parses poetry.lock by splitting on [[package]] blocks.
|
|
1974
|
+
* Lightweight parser that handles the regular structure
|
|
1975
|
+
* without needing a full TOML library.
|
|
1976
|
+
*/
|
|
1977
|
+
parseLockfile(content, lockfilePath) {
|
|
1978
|
+
const packages = [];
|
|
1979
|
+
const blocks = content.split(/^\[\[package\]\]$/m);
|
|
1980
|
+
for (const block of blocks) {
|
|
1981
|
+
if (!block.trim()) continue;
|
|
1982
|
+
const name = this.extractField(block, "name");
|
|
1983
|
+
const version = this.extractField(block, "version");
|
|
1984
|
+
if (name && version) {
|
|
1985
|
+
packages.push({ name, version });
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
if (packages.length === 0 && content.includes("[[package]]")) {
|
|
1989
|
+
throw new LockfileParseError(lockfilePath, "Failed to parse any packages from poetry.lock");
|
|
1990
|
+
}
|
|
1991
|
+
return packages;
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Extracts a string field value from a TOML block.
|
|
1995
|
+
* Handles: `name = "value"` format.
|
|
1996
|
+
*/
|
|
1997
|
+
extractField(block, fieldName) {
|
|
1998
|
+
const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
|
|
1999
|
+
const match = block.match(regex);
|
|
2000
|
+
return match ? match[1] : null;
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Parses `pyproject.toml` to extract direct dependency names.
|
|
2004
|
+
*
|
|
2005
|
+
* Looks for:
|
|
2006
|
+
* - `[tool.poetry.dependencies]` — main dependencies
|
|
2007
|
+
* - `[tool.poetry.group.dev.dependencies]` — dev dependencies
|
|
2008
|
+
* - `[tool.poetry.group.*.dependencies]` — other groups
|
|
2009
|
+
*
|
|
2010
|
+
* Supports formats:
|
|
2011
|
+
* - `requests = "^2.31.0"`
|
|
2012
|
+
* - `requests = { version = "^2.31.0", optional = true }`
|
|
2013
|
+
* - `python = "^3.12"` — skipped (the Python interpreter itself)
|
|
2014
|
+
*/
|
|
2015
|
+
parsePyprojectToml(content) {
|
|
2016
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
2017
|
+
let inDepsSection = false;
|
|
2018
|
+
for (const rawLine of content.split("\n")) {
|
|
2019
|
+
const line = rawLine.trim();
|
|
2020
|
+
if (line.startsWith("[")) {
|
|
2021
|
+
inDepsSection = line === "[tool.poetry.dependencies]" || /^\[tool\.poetry\.group\.[^\]]+\.dependencies\]$/.test(line);
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
if (inDepsSection && line && !line.startsWith("#")) {
|
|
2025
|
+
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*=/);
|
|
2026
|
+
if (match && match[1]) {
|
|
2027
|
+
const name = this.normalizePipName(match[1]);
|
|
2028
|
+
if (name !== "python") {
|
|
2029
|
+
directNames.add(name);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
return directNames;
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Normalizes a pip package name per PEP 503.
|
|
2038
|
+
* Converts to lowercase and replaces any run of [-_.] with a single hyphen.
|
|
2039
|
+
*/
|
|
2040
|
+
normalizePipName(name) {
|
|
2041
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Builds a purl for a PyPI package.
|
|
2045
|
+
* Per purl spec, the type is "pypi" (not "poetry").
|
|
2046
|
+
*/
|
|
2047
|
+
buildPurl(name, version) {
|
|
2048
|
+
return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
|
|
2052
|
+
// src/scanners/uv/uv-scanner.ts
|
|
2053
|
+
var import_promises13 = require("fs/promises");
|
|
2054
|
+
var import_fs13 = require("fs");
|
|
2055
|
+
var import_path13 = __toESM(require("path"), 1);
|
|
2056
|
+
var UvScanner = class {
|
|
2057
|
+
ecosystem = "uv";
|
|
2058
|
+
lockfileNames = ["uv.lock"];
|
|
2059
|
+
async detect(projectPath) {
|
|
2060
|
+
const lockfilePath = import_path13.default.join(projectPath, "uv.lock");
|
|
2061
|
+
return (0, import_fs13.existsSync)(lockfilePath) ? lockfilePath : null;
|
|
2062
|
+
}
|
|
2063
|
+
async scan(projectPath, lockfilePath) {
|
|
2064
|
+
const [lockfileRaw, pyprojectRaw] = await Promise.all([
|
|
2065
|
+
(0, import_promises13.readFile)(lockfilePath, "utf-8"),
|
|
2066
|
+
(0, import_promises13.readFile)(import_path13.default.join(projectPath, "pyproject.toml"), "utf-8").catch(() => null)
|
|
2067
|
+
]);
|
|
2068
|
+
const packages = this.parseLockfile(lockfileRaw, lockfilePath);
|
|
2069
|
+
const projectName = pyprojectRaw ? this.extractProjectName(pyprojectRaw) : null;
|
|
2070
|
+
const directNames = pyprojectRaw ? this.parsePyprojectDeps(pyprojectRaw) : /* @__PURE__ */ new Set();
|
|
2071
|
+
const dependencies = [];
|
|
2072
|
+
for (const pkg of packages) {
|
|
2073
|
+
if (pkg.isEditable) continue;
|
|
2074
|
+
if (projectName && this.normalizePipName(pkg.name) === this.normalizePipName(projectName)) {
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
dependencies.push({
|
|
2078
|
+
name: this.normalizePipName(pkg.name),
|
|
2079
|
+
version: pkg.version,
|
|
2080
|
+
direct: directNames.size > 0 ? directNames.has(this.normalizePipName(pkg.name)) : true,
|
|
2081
|
+
ecosystem: "uv",
|
|
2082
|
+
purl: this.buildPurl(pkg.name, pkg.version)
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
return {
|
|
2086
|
+
projectPath,
|
|
2087
|
+
ecosystem: "uv",
|
|
2088
|
+
dependencies,
|
|
2089
|
+
lockfilePath,
|
|
2090
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Parses uv.lock by splitting on [[package]] blocks.
|
|
2095
|
+
* Lightweight parser that handles the regular structure
|
|
2096
|
+
* without needing a full TOML library.
|
|
2097
|
+
*/
|
|
2098
|
+
parseLockfile(content, lockfilePath) {
|
|
2099
|
+
const packages = [];
|
|
2100
|
+
const blocks = content.split(/^\[\[package\]\]$/m);
|
|
2101
|
+
for (const block of blocks) {
|
|
2102
|
+
if (!block.trim()) continue;
|
|
2103
|
+
const name = this.extractField(block, "name");
|
|
2104
|
+
const version = this.extractField(block, "version");
|
|
2105
|
+
if (name && version) {
|
|
2106
|
+
const isEditable = /source\s*=\s*\{[^}]*editable\s*=/.test(block) || /source\s*=\s*\{[^}]*virtual\s*=/.test(block);
|
|
2107
|
+
packages.push({ name, version, isEditable });
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
if (packages.length === 0 && content.includes("[[package]]")) {
|
|
2111
|
+
throw new LockfileParseError(lockfilePath, "Failed to parse any packages from uv.lock");
|
|
2112
|
+
}
|
|
2113
|
+
return packages;
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Extracts a string field value from a TOML block.
|
|
2117
|
+
* Handles: `name = "value"` format.
|
|
2118
|
+
*/
|
|
2119
|
+
extractField(block, fieldName) {
|
|
2120
|
+
const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
|
|
2121
|
+
const match = block.match(regex);
|
|
2122
|
+
return match ? match[1] : null;
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Extracts the project name from `pyproject.toml`.
|
|
2126
|
+
* Looks for `name = "..."` under `[project]`.
|
|
2127
|
+
*/
|
|
2128
|
+
extractProjectName(content) {
|
|
2129
|
+
let inProjectSection = false;
|
|
2130
|
+
for (const rawLine of content.split("\n")) {
|
|
2131
|
+
const line = rawLine.trim();
|
|
2132
|
+
if (line.startsWith("[")) {
|
|
2133
|
+
inProjectSection = line === "[project]";
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2136
|
+
if (inProjectSection) {
|
|
2137
|
+
const match = line.match(/^name\s*=\s*"([^"]*)"/);
|
|
2138
|
+
if (match) return match[1];
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
return null;
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Parses `pyproject.toml` to extract direct dependency names.
|
|
2145
|
+
*
|
|
2146
|
+
* Looks for:
|
|
2147
|
+
* - `[project]` → `dependencies = [...]` (PEP 621)
|
|
2148
|
+
* - `[project.optional-dependencies]` (extras)
|
|
2149
|
+
* - `[dependency-groups]` (PEP 735, used by uv for dev deps)
|
|
2150
|
+
*
|
|
2151
|
+
* Dependency strings follow PEP 508:
|
|
2152
|
+
* - `"requests>=2.31.0"`
|
|
2153
|
+
* - `"flask[dotenv]>=3.0"`
|
|
2154
|
+
* - `"black"` (bare name)
|
|
2155
|
+
*/
|
|
2156
|
+
parsePyprojectDeps(content) {
|
|
2157
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
2158
|
+
this.extractInlineArray(content, directNames);
|
|
2159
|
+
this.extractDependencyGroups(content, directNames);
|
|
2160
|
+
return directNames;
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Extracts dependency names from PEP 621 `dependencies = [...]` arrays
|
|
2164
|
+
* and `[project.optional-dependencies]` sections.
|
|
2165
|
+
*/
|
|
2166
|
+
extractInlineArray(content, directNames) {
|
|
2167
|
+
const arrayRegex = /(?:^dependencies|^[a-zA-Z0-9_-]+)\s*=\s*\[([^\]]*)\]/gm;
|
|
2168
|
+
let match;
|
|
2169
|
+
while ((match = arrayRegex.exec(content)) !== null) {
|
|
2170
|
+
const arrayContent = match[1];
|
|
2171
|
+
this.extractPepNames(arrayContent, directNames);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Extracts dependency names from [dependency-groups] sections.
|
|
2176
|
+
* Format:
|
|
2177
|
+
* ```toml
|
|
2178
|
+
* [dependency-groups]
|
|
2179
|
+
* dev = ["pytest>=7.0", "black"]
|
|
2180
|
+
* ```
|
|
2181
|
+
*/
|
|
2182
|
+
extractDependencyGroups(content, directNames) {
|
|
2183
|
+
let inDepGroups = false;
|
|
2184
|
+
for (const rawLine of content.split("\n")) {
|
|
2185
|
+
const line = rawLine.trim();
|
|
2186
|
+
if (line.startsWith("[")) {
|
|
2187
|
+
inDepGroups = line === "[dependency-groups]";
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
if (inDepGroups && line && !line.startsWith("#")) {
|
|
2191
|
+
const arrayMatch = line.match(/^[a-zA-Z0-9_-]+\s*=\s*\[([^\]]*)\]/);
|
|
2192
|
+
if (arrayMatch) {
|
|
2193
|
+
this.extractPepNames(arrayMatch[1], directNames);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Extracts PEP 508 package names from a comma-separated
|
|
2200
|
+
* list of quoted dependency strings.
|
|
2201
|
+
*/
|
|
2202
|
+
extractPepNames(content, directNames) {
|
|
2203
|
+
const depStrings = content.match(/"([^"]*)"/g);
|
|
2204
|
+
if (!depStrings) return;
|
|
2205
|
+
for (const quoted of depStrings) {
|
|
2206
|
+
const depStr = quoted.replace(/"/g, "").trim();
|
|
2207
|
+
if (!depStr) continue;
|
|
2208
|
+
const nameMatch = depStr.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)/);
|
|
2209
|
+
if (nameMatch && nameMatch[1]) {
|
|
2210
|
+
directNames.add(this.normalizePipName(nameMatch[1]));
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Normalizes a pip package name per PEP 503.
|
|
2216
|
+
* Converts to lowercase and replaces any run of [-_.] with a single hyphen.
|
|
2217
|
+
*/
|
|
2218
|
+
normalizePipName(name) {
|
|
2219
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Builds a purl for a PyPI package.
|
|
2223
|
+
* Per purl spec, the type is "pypi" (not "uv").
|
|
2224
|
+
*/
|
|
2225
|
+
buildPurl(name, version) {
|
|
2226
|
+
return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
|
|
2227
|
+
}
|
|
2228
|
+
};
|
|
2229
|
+
|
|
2230
|
+
// src/scanners/registry.ts
|
|
2231
|
+
var ScannerRegistry = class {
|
|
2232
|
+
scanners;
|
|
2233
|
+
constructor() {
|
|
2234
|
+
this.scanners = [
|
|
2235
|
+
new NpmScanner(),
|
|
2236
|
+
new NugetScanner(),
|
|
2237
|
+
new CargoScanner(),
|
|
2238
|
+
new PipScanner(),
|
|
2239
|
+
new PoetryScanner(),
|
|
2240
|
+
new UvScanner(),
|
|
2241
|
+
new MavenScanner(),
|
|
2242
|
+
new GoScanner(),
|
|
2243
|
+
new RubyScanner(),
|
|
2244
|
+
new ComposerScanner(),
|
|
2245
|
+
new YarnScanner(),
|
|
2246
|
+
new PnpmScanner(),
|
|
2247
|
+
new DenoScanner()
|
|
2248
|
+
];
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* Auto-detects the project's ecosystem and scans dependencies.
|
|
2252
|
+
* Tries each registered scanner in order until one matches.
|
|
2253
|
+
*/
|
|
2254
|
+
async detectAndScan(projectPath) {
|
|
2255
|
+
for (const scanner of this.scanners) {
|
|
2256
|
+
const lockfilePath = await scanner.detect(projectPath);
|
|
2257
|
+
if (lockfilePath) {
|
|
2258
|
+
return scanner.scan(projectPath, lockfilePath);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
throw new NoLockfileError(projectPath);
|
|
2262
|
+
}
|
|
2263
|
+
/** Returns a specific scanner by ecosystem name */
|
|
2264
|
+
getScanner(ecosystem) {
|
|
2265
|
+
return this.scanners.find((s) => s.ecosystem === ecosystem);
|
|
2266
|
+
}
|
|
2267
|
+
/** Lists all registered ecosystems */
|
|
2268
|
+
listEcosystems() {
|
|
2269
|
+
return this.scanners.map((s) => s.ecosystem);
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
|
|
2273
|
+
// src/sbom/cyclonedx.ts
|
|
2274
|
+
var import_crypto4 = require("crypto");
|
|
2275
|
+
var SCHEMA_URLS = {
|
|
2276
|
+
"1.4": "http://cyclonedx.org/schema/bom-1.4.schema.json",
|
|
2277
|
+
"1.5": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
2278
|
+
"1.6": "http://cyclonedx.org/schema/bom-1.6.schema.json",
|
|
2279
|
+
"1.7": "http://cyclonedx.org/schema/bom-1.7.schema.json"
|
|
2280
|
+
};
|
|
2281
|
+
var CycloneDxGenerator = class {
|
|
2282
|
+
constructor(specVersion = "1.7") {
|
|
2283
|
+
this.specVersion = specVersion;
|
|
2284
|
+
}
|
|
2285
|
+
format = "cyclonedx-json";
|
|
2286
|
+
generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
|
|
2287
|
+
const bom = this.buildBom(scanResult, toolVersion);
|
|
2288
|
+
const content = JSON.stringify(bom, null, 2);
|
|
2289
|
+
return {
|
|
2290
|
+
format: "cyclonedx-json",
|
|
2291
|
+
specVersion: this.specVersion,
|
|
2292
|
+
content,
|
|
2293
|
+
componentCount: scanResult.dependencies.length,
|
|
2294
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
buildBom(scanResult, toolVersion) {
|
|
2298
|
+
const projectName = extractProjectName(scanResult.projectPath);
|
|
2299
|
+
return {
|
|
2300
|
+
$schema: SCHEMA_URLS[this.specVersion],
|
|
2301
|
+
bomFormat: "CycloneDX",
|
|
2302
|
+
specVersion: this.specVersion,
|
|
2303
|
+
serialNumber: `urn:uuid:${(0, import_crypto4.randomUUID)()}`,
|
|
2304
|
+
version: 1,
|
|
2305
|
+
metadata: {
|
|
2306
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2307
|
+
tools: this.buildTools(toolVersion),
|
|
2308
|
+
// NTIA: metadata.supplier — the org supplying the root software
|
|
2309
|
+
supplier: {
|
|
2310
|
+
name: projectName
|
|
2311
|
+
},
|
|
2312
|
+
component: {
|
|
2313
|
+
type: "application",
|
|
2314
|
+
name: projectName,
|
|
2315
|
+
"bom-ref": "root-component",
|
|
2316
|
+
supplier: { name: projectName }
|
|
2317
|
+
}
|
|
2318
|
+
},
|
|
2319
|
+
components: scanResult.dependencies.map((dep) => this.toComponent(dep)),
|
|
2320
|
+
dependencies: this.buildDependencyGraph(scanResult)
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
/** Converts a Verimu Dependency to a CycloneDX component */
|
|
2324
|
+
toComponent(dep) {
|
|
2325
|
+
return {
|
|
2326
|
+
type: "library",
|
|
2327
|
+
name: dep.name,
|
|
2328
|
+
version: dep.version,
|
|
2329
|
+
purl: dep.purl,
|
|
2330
|
+
"bom-ref": dep.purl,
|
|
2331
|
+
scope: dep.direct ? "required" : "optional",
|
|
2332
|
+
// NTIA: component.supplier — derived from npm scope or package name
|
|
2333
|
+
supplier: {
|
|
2334
|
+
name: deriveSupplierName(dep.name)
|
|
2335
|
+
}
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Builds the dependency graph section of the SBOM.
|
|
2340
|
+
*
|
|
2341
|
+
* The root component depends on all dependencies (direct + transitive).
|
|
2342
|
+
* This ensures a single root node in the graph, which NTIA validators expect.
|
|
2343
|
+
*
|
|
2344
|
+
* We include ALL deps under root (not just direct) because from a flat lockfile
|
|
2345
|
+
* we can't reliably reconstruct which transitive dep belongs to which direct dep.
|
|
2346
|
+
* This is still valid per the CycloneDX spec — it represents a complete but flat
|
|
2347
|
+
* dependency relationship.
|
|
2348
|
+
*/
|
|
2349
|
+
buildDependencyGraph(scanResult) {
|
|
2350
|
+
const allDepPurls = scanResult.dependencies.map((d) => d.purl);
|
|
2351
|
+
return [
|
|
2352
|
+
{
|
|
2353
|
+
ref: "root-component",
|
|
2354
|
+
dependsOn: allDepPurls
|
|
2355
|
+
}
|
|
2356
|
+
];
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Builds the tools metadata section.
|
|
2360
|
+
*
|
|
2361
|
+
* CycloneDX 1.4: tools is a flat array of { vendor, name, version, ... }
|
|
2362
|
+
* CycloneDX 1.5+: tools is an object { components: [...] }
|
|
2363
|
+
*/
|
|
2364
|
+
buildTools(toolVersion) {
|
|
2365
|
+
if (this.specVersion === "1.4") {
|
|
2366
|
+
return [
|
|
2367
|
+
{
|
|
2368
|
+
vendor: "Verimu",
|
|
2369
|
+
name: VERIMU_TOOL_NAME,
|
|
2370
|
+
version: toolVersion,
|
|
2371
|
+
externalReferences: [{ type: "website", url: VERIMU_TOOL_WEBSITE }]
|
|
2372
|
+
}
|
|
2373
|
+
];
|
|
2374
|
+
}
|
|
2375
|
+
return {
|
|
2376
|
+
components: [
|
|
2377
|
+
{
|
|
2378
|
+
type: "application",
|
|
2379
|
+
name: VERIMU_TOOL_NAME,
|
|
2380
|
+
version: toolVersion,
|
|
2381
|
+
description: VERIMU_TOOL_DESCRIPTION,
|
|
2382
|
+
supplier: { name: "Verimu" },
|
|
2383
|
+
externalReferences: [{ type: "website", url: VERIMU_TOOL_WEBSITE }]
|
|
2384
|
+
}
|
|
2385
|
+
]
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
};
|
|
2389
|
+
|
|
2390
|
+
// src/sbom/spdx.ts
|
|
2391
|
+
var import_crypto5 = require("crypto");
|
|
2392
|
+
var SPDX_VERSION2 = "2.3";
|
|
2393
|
+
var SpdxJsonGenerator = class {
|
|
2394
|
+
format = "spdx-json";
|
|
2395
|
+
generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
|
|
2396
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2397
|
+
const projectName = extractProjectName(scanResult.projectPath);
|
|
2398
|
+
const rootPackageId = "SPDXRef-Package-root";
|
|
2399
|
+
const dependencies = normalizeDependencies(scanResult.dependencies);
|
|
2400
|
+
const document = {
|
|
2401
|
+
spdxVersion: `SPDX-${SPDX_VERSION2}`,
|
|
2402
|
+
dataLicense: "CC0-1.0",
|
|
2403
|
+
SPDXID: "SPDXRef-DOCUMENT",
|
|
2404
|
+
name: `${projectName}-sbom`,
|
|
2405
|
+
documentNamespace: `https://verimu.com/spdxdocs/${projectName}-${(0, import_crypto5.randomUUID)()}`,
|
|
2406
|
+
creationInfo: {
|
|
2407
|
+
created: timestamp,
|
|
2408
|
+
creators: [`Tool: ${VERIMU_TOOL_NAME}@${toolVersion}`]
|
|
2409
|
+
},
|
|
2410
|
+
documentDescribes: [rootPackageId],
|
|
2411
|
+
packages: [
|
|
2412
|
+
{
|
|
2413
|
+
name: projectName,
|
|
2414
|
+
SPDXID: rootPackageId,
|
|
2415
|
+
versionInfo: "NOASSERTION",
|
|
2416
|
+
supplier: `Organization: ${projectName}`,
|
|
2417
|
+
downloadLocation: "NOASSERTION",
|
|
2418
|
+
filesAnalyzed: false,
|
|
2419
|
+
licenseConcluded: "NOASSERTION",
|
|
2420
|
+
licenseDeclared: "NOASSERTION",
|
|
2421
|
+
primaryPackagePurpose: "APPLICATION"
|
|
2422
|
+
},
|
|
2423
|
+
...dependencies.map((dep, index) => ({
|
|
2424
|
+
name: dep.name,
|
|
2425
|
+
SPDXID: `SPDXRef-Package-${index + 1}`,
|
|
2426
|
+
versionInfo: dep.version,
|
|
2427
|
+
supplier: `Organization: ${dep.supplierName}`,
|
|
2428
|
+
downloadLocation: "NOASSERTION",
|
|
2429
|
+
filesAnalyzed: false,
|
|
2430
|
+
licenseConcluded: "NOASSERTION",
|
|
2431
|
+
licenseDeclared: "NOASSERTION",
|
|
2432
|
+
primaryPackagePurpose: "LIBRARY",
|
|
2433
|
+
externalRefs: [
|
|
2434
|
+
{
|
|
2435
|
+
referenceCategory: "PACKAGE-MANAGER",
|
|
2436
|
+
referenceType: "purl",
|
|
2437
|
+
referenceLocator: dep.purl
|
|
2438
|
+
}
|
|
2439
|
+
]
|
|
2440
|
+
}))
|
|
2441
|
+
],
|
|
2442
|
+
relationships: [
|
|
2443
|
+
{
|
|
2444
|
+
spdxElementId: "SPDXRef-DOCUMENT",
|
|
2445
|
+
relationshipType: "DESCRIBES",
|
|
2446
|
+
relatedSpdxElement: rootPackageId
|
|
2447
|
+
},
|
|
2448
|
+
...dependencies.map((_dep, index) => ({
|
|
2449
|
+
spdxElementId: rootPackageId,
|
|
2450
|
+
relationshipType: "DEPENDS_ON",
|
|
2451
|
+
relatedSpdxElement: `SPDXRef-Package-${index + 1}`
|
|
2452
|
+
}))
|
|
2453
|
+
]
|
|
2454
|
+
};
|
|
2455
|
+
return {
|
|
2456
|
+
format: "spdx-json",
|
|
2457
|
+
specVersion: SPDX_VERSION2,
|
|
2458
|
+
content: JSON.stringify(document, null, 2),
|
|
2459
|
+
componentCount: scanResult.dependencies.length,
|
|
2460
|
+
generatedAt: timestamp
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
};
|
|
2464
|
+
|
|
2465
|
+
// src/sbom/swid.ts
|
|
2466
|
+
var import_crypto6 = require("crypto");
|
|
2467
|
+
var SWID_SPEC_VERSION2 = "ISO/IEC 19770-2:2015";
|
|
2468
|
+
var SwidTagGenerator = class {
|
|
2469
|
+
format = "swid-xml";
|
|
2470
|
+
generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
|
|
2471
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2472
|
+
const projectName = extractProjectName(scanResult.projectPath);
|
|
2473
|
+
const tagId = `com.verimu:${sanitizeTagId2(projectName)}:${DEFAULT_SWID_VERSION}:${(0, import_crypto6.randomUUID)()}`;
|
|
2474
|
+
const content = [
|
|
2475
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
2476
|
+
"<SoftwareIdentity",
|
|
2477
|
+
' xmlns="http://standards.iso.org/iso/19770/-2/2015/schema.xsd"',
|
|
2478
|
+
` name="${escapeXml2(projectName)}"`,
|
|
2479
|
+
` tagId="${escapeXml2(tagId)}"`,
|
|
2480
|
+
' tagVersion="1"',
|
|
2481
|
+
` version="${DEFAULT_SWID_VERSION}"`,
|
|
2482
|
+
' versionScheme="semver">',
|
|
2483
|
+
` <Entity name="${escapeXml2(projectName)}" role="softwareCreator" />`,
|
|
2484
|
+
' <Entity name="Verimu" role="tagCreator" />',
|
|
2485
|
+
` <Meta product="${escapeXml2(projectName)}" generator="${VERIMU_TOOL_NAME}" toolVersion="${toolVersion}" generated="${timestamp}" />`,
|
|
2486
|
+
" <!-- TODO: Consider adding dependency/package evidence if we need richer SWID coverage. -->",
|
|
2487
|
+
' <Link rel="describedby" href="https://verimu.com" />',
|
|
2488
|
+
"</SoftwareIdentity>"
|
|
2489
|
+
].join("\n");
|
|
2490
|
+
return {
|
|
2491
|
+
format: "swid-xml",
|
|
2492
|
+
specVersion: SWID_SPEC_VERSION2,
|
|
2493
|
+
content,
|
|
2494
|
+
componentCount: 1,
|
|
2495
|
+
generatedAt: timestamp
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
};
|
|
2499
|
+
function escapeXml2(value) {
|
|
2500
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
2501
|
+
}
|
|
2502
|
+
function sanitizeTagId2(value) {
|
|
2503
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// src/sbom/artifacts.ts
|
|
2507
|
+
function generateSbomArtifacts(scanResult, toolVersion = DEFAULT_TOOL_VERSION, cyclonedxVersion = "1.7") {
|
|
2508
|
+
return {
|
|
2509
|
+
cyclonedx: new CycloneDxGenerator(cyclonedxVersion).generate(scanResult, toolVersion),
|
|
2510
|
+
spdx: new SpdxJsonGenerator().generate(scanResult, toolVersion),
|
|
2511
|
+
swid: new SwidTagGenerator().generate(scanResult, toolVersion)
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
|
|
1245
2515
|
// src/cve/osv.ts
|
|
1246
2516
|
var OSV_API_BASE = "https://api.osv.dev/v1";
|
|
1247
2517
|
var BATCH_SIZE = 1e3;
|
|
@@ -1509,9 +2779,13 @@ var OsvSource = class {
|
|
|
1509
2779
|
cargo: "crates.io",
|
|
1510
2780
|
maven: "Maven",
|
|
1511
2781
|
pip: "PyPI",
|
|
2782
|
+
poetry: "PyPI",
|
|
2783
|
+
uv: "PyPI",
|
|
1512
2784
|
go: "Go",
|
|
1513
2785
|
ruby: "RubyGems",
|
|
1514
|
-
composer: "Packagist"
|
|
2786
|
+
composer: "Packagist",
|
|
2787
|
+
deno: "JSR"
|
|
2788
|
+
// JSR packages (Deno registry)
|
|
1515
2789
|
};
|
|
1516
2790
|
return map[ecosystem] ?? ecosystem;
|
|
1517
2791
|
}
|
|
@@ -1618,6 +2892,9 @@ var ConsoleReporter = class {
|
|
|
1618
2892
|
lines.push("");
|
|
1619
2893
|
lines.push(` \u2713 SBOM generated (${result.sbom.format}, ${result.sbom.specVersion})`);
|
|
1620
2894
|
lines.push(` Components: ${result.sbom.componentCount}`);
|
|
2895
|
+
if (result.artifacts) {
|
|
2896
|
+
lines.push(` Also wrote: ${result.artifacts.spdx.format}, ${result.artifacts.swid.format}`);
|
|
2897
|
+
}
|
|
1621
2898
|
lines.push("");
|
|
1622
2899
|
const vulns = result.cveCheck.vulnerabilities;
|
|
1623
2900
|
if (vulns.length === 0) {
|
|
@@ -1707,18 +2984,22 @@ var VerimuApiClient = class {
|
|
|
1707
2984
|
return res.json();
|
|
1708
2985
|
}
|
|
1709
2986
|
/**
|
|
1710
|
-
* Upload a
|
|
2987
|
+
* Upload a software inventory artifact payload to a project and trigger CVE scanning.
|
|
2988
|
+
*
|
|
2989
|
+
* Backward-compatible:
|
|
2990
|
+
* - string payloads are treated as legacy raw CycloneDX JSON
|
|
2991
|
+
* - object payloads can include CycloneDX + SPDX + SWID together
|
|
1711
2992
|
*/
|
|
1712
|
-
async uploadSbom(projectId,
|
|
1713
|
-
const
|
|
2993
|
+
async uploadSbom(projectId, payload) {
|
|
2994
|
+
const body = typeof payload === "string" ? JSON.stringify(JSON.parse(payload)) : JSON.stringify(payload);
|
|
1714
2995
|
const res = await fetch(`${this.baseUrl}/api/projects/${projectId}/scan`, {
|
|
1715
2996
|
method: "POST",
|
|
1716
2997
|
headers: this.headers(),
|
|
1717
|
-
body
|
|
2998
|
+
body
|
|
1718
2999
|
});
|
|
1719
3000
|
if (!res.ok) {
|
|
1720
|
-
const
|
|
1721
|
-
throw new Error(`Verimu API: upload SBOM failed (${res.status}): ${
|
|
3001
|
+
const body2 = await res.text();
|
|
3002
|
+
throw new Error(`Verimu API: upload SBOM failed (${res.status}): ${body2}`);
|
|
1722
3003
|
}
|
|
1723
3004
|
return res.json();
|
|
1724
3005
|
}
|
|
@@ -1736,12 +3017,15 @@ var VerimuApiClient = class {
|
|
|
1736
3017
|
const map = {
|
|
1737
3018
|
npm: "npm",
|
|
1738
3019
|
pip: "pip",
|
|
3020
|
+
poetry: "poetry",
|
|
3021
|
+
uv: "uv",
|
|
1739
3022
|
maven: "maven",
|
|
1740
3023
|
nuget: "nuget",
|
|
1741
3024
|
go: "gomod",
|
|
1742
3025
|
cargo: "cargo",
|
|
1743
3026
|
ruby: "bundler",
|
|
1744
|
-
composer: "composer"
|
|
3027
|
+
composer: "composer",
|
|
3028
|
+
deno: "deno"
|
|
1745
3029
|
};
|
|
1746
3030
|
return map[eco] ?? eco;
|
|
1747
3031
|
}
|
|
@@ -1752,13 +3036,19 @@ async function scan(config) {
|
|
|
1752
3036
|
const {
|
|
1753
3037
|
projectPath,
|
|
1754
3038
|
sbomOutput = "./sbom.cdx.json",
|
|
1755
|
-
skipCveCheck = false
|
|
3039
|
+
skipCveCheck = false,
|
|
3040
|
+
cyclonedxVersion = "1.7"
|
|
1756
3041
|
} = config;
|
|
1757
3042
|
const registry = new ScannerRegistry();
|
|
1758
3043
|
const scanResult = await registry.detectAndScan(projectPath);
|
|
1759
|
-
const
|
|
1760
|
-
const sbom =
|
|
1761
|
-
|
|
3044
|
+
const artifacts = generateSbomArtifacts(scanResult, void 0, cyclonedxVersion);
|
|
3045
|
+
const sbom = artifacts.cyclonedx;
|
|
3046
|
+
const outputPaths = deriveArtifactOutputPaths(sbomOutput);
|
|
3047
|
+
await Promise.all([
|
|
3048
|
+
(0, import_promises14.writeFile)(outputPaths.cyclonedx, artifacts.cyclonedx.content, "utf-8"),
|
|
3049
|
+
(0, import_promises14.writeFile)(outputPaths.spdx, artifacts.spdx.content, "utf-8"),
|
|
3050
|
+
(0, import_promises14.writeFile)(outputPaths.swid, artifacts.swid.content, "utf-8")
|
|
3051
|
+
]);
|
|
1762
3052
|
let cveCheck;
|
|
1763
3053
|
if (skipCveCheck) {
|
|
1764
3054
|
cveCheck = {
|
|
@@ -1787,6 +3077,7 @@ async function scan(config) {
|
|
|
1787
3077
|
dependencyCount: scanResult.dependencies.length
|
|
1788
3078
|
},
|
|
1789
3079
|
sbom,
|
|
3080
|
+
artifacts,
|
|
1790
3081
|
cveCheck,
|
|
1791
3082
|
summary,
|
|
1792
3083
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -1805,13 +3096,13 @@ async function uploadToVerimu(report, config) {
|
|
|
1805
3096
|
throw new Error("API key required for upload");
|
|
1806
3097
|
}
|
|
1807
3098
|
const client = new VerimuApiClient(config.apiKey, config.apiBaseUrl);
|
|
1808
|
-
const projectName = (0,
|
|
3099
|
+
const projectName = (0, import_path14.basename)(config.projectPath);
|
|
1809
3100
|
const upsertRes = await client.upsertProject({
|
|
1810
3101
|
name: projectName,
|
|
1811
3102
|
ecosystem: report.project.ecosystem
|
|
1812
3103
|
});
|
|
1813
3104
|
const projectId = upsertRes.project.id;
|
|
1814
|
-
const scanRes = await client.uploadSbom(projectId, report
|
|
3105
|
+
const scanRes = await client.uploadSbom(projectId, buildUploadPayload(report));
|
|
1815
3106
|
return {
|
|
1816
3107
|
projectId,
|
|
1817
3108
|
projectCreated: upsertRes.created,
|
|
@@ -1838,6 +3129,28 @@ function printReport(report) {
|
|
|
1838
3129
|
const reporter = new ConsoleReporter();
|
|
1839
3130
|
console.log(reporter.report(report));
|
|
1840
3131
|
}
|
|
3132
|
+
function deriveArtifactOutputPaths(cycloneDxOutput) {
|
|
3133
|
+
const parsed = (0, import_path14.parse)(cycloneDxOutput);
|
|
3134
|
+
let baseName = parsed.name;
|
|
3135
|
+
if (parsed.ext === ".json" && baseName.endsWith(".cdx")) {
|
|
3136
|
+
baseName = baseName.slice(0, -4);
|
|
3137
|
+
}
|
|
3138
|
+
return {
|
|
3139
|
+
cyclonedx: cycloneDxOutput,
|
|
3140
|
+
spdx: (0, import_path14.join)(parsed.dir, `${baseName}.spdx.json`),
|
|
3141
|
+
swid: (0, import_path14.join)(parsed.dir, `${baseName}.swid.xml`)
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
function buildUploadPayload(report) {
|
|
3145
|
+
if (!report.artifacts) {
|
|
3146
|
+
return report.sbom.content;
|
|
3147
|
+
}
|
|
3148
|
+
return {
|
|
3149
|
+
cyclonedx: JSON.parse(report.artifacts.cyclonedx.content),
|
|
3150
|
+
spdx: JSON.parse(report.artifacts.spdx.content),
|
|
3151
|
+
swid: report.artifacts.swid.content
|
|
3152
|
+
};
|
|
3153
|
+
}
|
|
1841
3154
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1842
3155
|
0 && (module.exports = {
|
|
1843
3156
|
ApiKeyRequiredError,
|
|
@@ -1847,6 +3160,7 @@ function printReport(report) {
|
|
|
1847
3160
|
CveAggregator,
|
|
1848
3161
|
CveSourceError,
|
|
1849
3162
|
CycloneDxGenerator,
|
|
3163
|
+
DenoScanner,
|
|
1850
3164
|
GoScanner,
|
|
1851
3165
|
LockfileParseError,
|
|
1852
3166
|
MavenScanner,
|
|
@@ -1855,11 +3169,18 @@ function printReport(report) {
|
|
|
1855
3169
|
NugetScanner,
|
|
1856
3170
|
OsvSource,
|
|
1857
3171
|
PipScanner,
|
|
3172
|
+
PnpmScanner,
|
|
1858
3173
|
RubyScanner,
|
|
1859
3174
|
ScannerRegistry,
|
|
3175
|
+
SpdxJsonGenerator,
|
|
3176
|
+
SwidTagGenerator,
|
|
1860
3177
|
VerimuApiClient,
|
|
1861
3178
|
VerimuError,
|
|
3179
|
+
YarnScanner,
|
|
1862
3180
|
generateSbom,
|
|
3181
|
+
generateSbomArtifacts,
|
|
3182
|
+
generateSpdxSbom,
|
|
3183
|
+
generateSwidTag,
|
|
1863
3184
|
printReport,
|
|
1864
3185
|
scan,
|
|
1865
3186
|
shouldFailCi,
|