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/{cli.mjs → cli.js}
RENAMED
|
@@ -6,7 +6,7 @@ import { createRequire } from "module";
|
|
|
6
6
|
|
|
7
7
|
// src/scan.ts
|
|
8
8
|
import { writeFile } from "fs/promises";
|
|
9
|
-
import { basename } from "path";
|
|
9
|
+
import { basename, join, parse } from "path";
|
|
10
10
|
|
|
11
11
|
// src/scanners/npm/npm-scanner.ts
|
|
12
12
|
import { readFile } from "fs/promises";
|
|
@@ -24,7 +24,7 @@ var VerimuError = class extends Error {
|
|
|
24
24
|
var NoLockfileError = class extends VerimuError {
|
|
25
25
|
constructor(projectPath) {
|
|
26
26
|
super(
|
|
27
|
-
`No supported lockfile found in ${projectPath}. Supported: package-lock.json (npm), packages.lock.json (NuGet), Cargo.lock (Rust), requirements.txt / Pipfile.lock (
|
|
27
|
+
`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)`,
|
|
28
28
|
"NO_LOCKFILE"
|
|
29
29
|
);
|
|
30
30
|
this.name = "NoLockfileError";
|
|
@@ -926,6 +926,993 @@ var ComposerScanner = class {
|
|
|
926
926
|
}
|
|
927
927
|
};
|
|
928
928
|
|
|
929
|
+
// src/scanners/yarn/yarn-scanner.ts
|
|
930
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
931
|
+
import { existsSync as existsSync9 } from "fs";
|
|
932
|
+
import path9 from "path";
|
|
933
|
+
import { parse as parseYaml } from "yaml";
|
|
934
|
+
var YarnScanner = class {
|
|
935
|
+
ecosystem = "npm";
|
|
936
|
+
lockfileNames = ["yarn.lock"];
|
|
937
|
+
async detect(projectPath) {
|
|
938
|
+
const lockfilePath = path9.join(projectPath, "yarn.lock");
|
|
939
|
+
return existsSync9(lockfilePath) ? lockfilePath : null;
|
|
940
|
+
}
|
|
941
|
+
async scan(projectPath, lockfilePath) {
|
|
942
|
+
const [lockfileRaw, packageJsonRaw] = await Promise.all([
|
|
943
|
+
readFile9(lockfilePath, "utf-8"),
|
|
944
|
+
readFile9(path9.join(projectPath, "package.json"), "utf-8").catch(() => null)
|
|
945
|
+
]);
|
|
946
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
947
|
+
if (packageJsonRaw) {
|
|
948
|
+
try {
|
|
949
|
+
const pkg2 = JSON.parse(packageJsonRaw);
|
|
950
|
+
for (const name of Object.keys(pkg2.dependencies ?? {})) {
|
|
951
|
+
directNames.add(name);
|
|
952
|
+
}
|
|
953
|
+
for (const name of Object.keys(pkg2.devDependencies ?? {})) {
|
|
954
|
+
directNames.add(name);
|
|
955
|
+
}
|
|
956
|
+
} catch {
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const dependencies = this.parseLockfile(lockfileRaw, lockfilePath, directNames);
|
|
960
|
+
return {
|
|
961
|
+
projectPath,
|
|
962
|
+
ecosystem: "npm",
|
|
963
|
+
dependencies,
|
|
964
|
+
lockfilePath,
|
|
965
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Parses yarn.lock file and extracts dependencies.
|
|
970
|
+
* Automatically detects and handles both v1 (Classic) and v2+ (Berry) formats.
|
|
971
|
+
*/
|
|
972
|
+
parseLockfile(content, lockfilePath, directNames) {
|
|
973
|
+
try {
|
|
974
|
+
const isV2Plus = this.isYarnV2Plus(content);
|
|
975
|
+
if (isV2Plus) {
|
|
976
|
+
return this.parseLockfileV2Plus(content, lockfilePath, directNames);
|
|
977
|
+
} else {
|
|
978
|
+
return this.parseLockfileV1(content, lockfilePath, directNames);
|
|
979
|
+
}
|
|
980
|
+
} catch (err) {
|
|
981
|
+
throw new LockfileParseError(
|
|
982
|
+
lockfilePath,
|
|
983
|
+
`Failed to parse yarn.lock: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Detects if the lockfile is Yarn v2+ (Berry) format.
|
|
989
|
+
* v2+ uses YAML format and contains __metadata section.
|
|
990
|
+
*/
|
|
991
|
+
isYarnV2Plus(content) {
|
|
992
|
+
return content.startsWith("__metadata:") || content.includes("\n__metadata:");
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Parses Yarn v2+ (Berry) lockfile format.
|
|
996
|
+
*
|
|
997
|
+
* Yarn v2+ format (YAML):
|
|
998
|
+
* ```yaml
|
|
999
|
+
* __metadata:
|
|
1000
|
+
* version: 6
|
|
1001
|
+
*
|
|
1002
|
+
* "package-name@npm:^1.0.0":
|
|
1003
|
+
* version: 1.2.3
|
|
1004
|
+
* resolution: "package-name@npm:1.2.3"
|
|
1005
|
+
* dependencies:
|
|
1006
|
+
* dep1: ^2.0.0
|
|
1007
|
+
* checksum: ...
|
|
1008
|
+
* languageName: node
|
|
1009
|
+
* linkType: hard
|
|
1010
|
+
* ```
|
|
1011
|
+
*/
|
|
1012
|
+
parseLockfileV2Plus(content, lockfilePath, directNames) {
|
|
1013
|
+
const deps = [];
|
|
1014
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1015
|
+
try {
|
|
1016
|
+
const parsed = parseYaml(content);
|
|
1017
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1018
|
+
throw new Error("Invalid YAML format");
|
|
1019
|
+
}
|
|
1020
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1021
|
+
if (key === "__metadata" || key.includes("@workspace:")) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (typeof value !== "object" || value === null) {
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
const entry = value;
|
|
1028
|
+
let name = null;
|
|
1029
|
+
if (entry.resolution && typeof entry.resolution === "string") {
|
|
1030
|
+
name = this.extractPackageNameFromResolution(entry.resolution);
|
|
1031
|
+
}
|
|
1032
|
+
if (!name) {
|
|
1033
|
+
name = this.extractPackageNameV2Plus(key);
|
|
1034
|
+
}
|
|
1035
|
+
const version = entry.version;
|
|
1036
|
+
if (!name || !version || typeof version !== "string") {
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const depKey = `${name}@${version}`;
|
|
1040
|
+
if (seen.has(depKey)) {
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
seen.set(depKey, true);
|
|
1044
|
+
deps.push({
|
|
1045
|
+
name,
|
|
1046
|
+
version,
|
|
1047
|
+
direct: directNames.has(name),
|
|
1048
|
+
ecosystem: "npm",
|
|
1049
|
+
purl: this.buildPurl(name, version)
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
throw new Error(`Failed to parse Yarn v2+ lockfile: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1054
|
+
}
|
|
1055
|
+
return deps;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Extracts package name from Yarn v2+ resolution field.
|
|
1059
|
+
* The resolution field contains the real package name.
|
|
1060
|
+
* Examples:
|
|
1061
|
+
* "express@npm:4.18.2" → "express"
|
|
1062
|
+
* "@types/node@npm:20.11.5" → "@types/node"
|
|
1063
|
+
* "lodash@npm:4.17.21" → "lodash"
|
|
1064
|
+
*/
|
|
1065
|
+
extractPackageNameFromResolution(resolution) {
|
|
1066
|
+
if (resolution.startsWith("@")) {
|
|
1067
|
+
const match2 = resolution.match(/^(@[^@]+\/[^@]+)@/);
|
|
1068
|
+
if (match2) {
|
|
1069
|
+
return match2[1];
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const match = resolution.match(/^([^@]+)@/);
|
|
1073
|
+
if (match) {
|
|
1074
|
+
return match[1];
|
|
1075
|
+
}
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Extracts package name from Yarn v2+ package key.
|
|
1080
|
+
* Examples:
|
|
1081
|
+
* "express@npm:^4.18.0" → "express"
|
|
1082
|
+
* "@types/node@npm:^20.0.0" → "@types/node"
|
|
1083
|
+
* "pkg@npm:other@npm:^1.0.0" → "pkg" (aliased packages)
|
|
1084
|
+
*/
|
|
1085
|
+
extractPackageNameV2Plus(key) {
|
|
1086
|
+
if (key.startsWith("@")) {
|
|
1087
|
+
const match2 = key.match(/^(@[^@]+\/[^@]+)@/);
|
|
1088
|
+
if (match2) {
|
|
1089
|
+
return match2[1];
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const match = key.match(/^([^@]+)@/);
|
|
1093
|
+
if (match) {
|
|
1094
|
+
return match[1];
|
|
1095
|
+
}
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Parses Yarn v1 (Classic) lockfile format.
|
|
1100
|
+
*
|
|
1101
|
+
* Yarn v1 format:
|
|
1102
|
+
* ```
|
|
1103
|
+
* "package-name@^1.0.0":
|
|
1104
|
+
* version "1.2.3"
|
|
1105
|
+
* resolved "https://..."
|
|
1106
|
+
* integrity sha512-...
|
|
1107
|
+
* dependencies:
|
|
1108
|
+
* dep1 "^2.0.0"
|
|
1109
|
+
* ```
|
|
1110
|
+
*/
|
|
1111
|
+
parseLockfileV1(content, lockfilePath, directNames) {
|
|
1112
|
+
const deps = [];
|
|
1113
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1114
|
+
const lines = content.split("\n");
|
|
1115
|
+
let currentPackage = null;
|
|
1116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1117
|
+
const line = lines[i];
|
|
1118
|
+
if (line.trim().startsWith("#") || line.trim() === "") {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (line.match(/^["\w@]/) && line.includes(":") && !line.startsWith(" ")) {
|
|
1122
|
+
if (currentPackage?.version) {
|
|
1123
|
+
this.addDependency(currentPackage, directNames, seen, deps);
|
|
1124
|
+
}
|
|
1125
|
+
const pkgLine = line.substring(0, line.lastIndexOf(":"));
|
|
1126
|
+
const names = pkgLine.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).map((s) => this.extractPackageName(s)).filter((s) => !!s);
|
|
1127
|
+
currentPackage = { names, version: void 0 };
|
|
1128
|
+
} else if (line.trim().startsWith("version ") && currentPackage) {
|
|
1129
|
+
const match = line.match(/version\s+"([^"]+)"/);
|
|
1130
|
+
if (match) {
|
|
1131
|
+
currentPackage.version = match[1];
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (currentPackage?.version) {
|
|
1136
|
+
this.addDependency(currentPackage, directNames, seen, deps);
|
|
1137
|
+
}
|
|
1138
|
+
return deps;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Adds a dependency to the result list (deduplicates by name@version)
|
|
1142
|
+
*/
|
|
1143
|
+
addDependency(pkg2, directNames, seen, deps) {
|
|
1144
|
+
if (!pkg2.version) return;
|
|
1145
|
+
const name = pkg2.names[0];
|
|
1146
|
+
if (!name) return;
|
|
1147
|
+
const key = `${name}@${pkg2.version}`;
|
|
1148
|
+
if (seen.has(key)) return;
|
|
1149
|
+
seen.set(key, true);
|
|
1150
|
+
deps.push({
|
|
1151
|
+
name,
|
|
1152
|
+
version: pkg2.version,
|
|
1153
|
+
direct: directNames.has(name),
|
|
1154
|
+
ecosystem: "npm",
|
|
1155
|
+
purl: this.buildPurl(name, pkg2.version)
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Extracts package name from yarn.lock package declaration.
|
|
1160
|
+
* Examples:
|
|
1161
|
+
* "express@^4.18.0" → "express"
|
|
1162
|
+
* "@types/node@^20.0.0" → "@types/node"
|
|
1163
|
+
* "pkg@npm:other@^1.0.0" → "pkg" (aliased packages)
|
|
1164
|
+
*/
|
|
1165
|
+
extractPackageName(pkgDeclaration) {
|
|
1166
|
+
if (pkgDeclaration.includes("@npm:")) {
|
|
1167
|
+
const beforeAlias = pkgDeclaration.split("@npm:")[0];
|
|
1168
|
+
return beforeAlias || null;
|
|
1169
|
+
}
|
|
1170
|
+
if (pkgDeclaration.startsWith("@")) {
|
|
1171
|
+
const parts = pkgDeclaration.split("@");
|
|
1172
|
+
if (parts.length >= 3) {
|
|
1173
|
+
return `@${parts[1]}`;
|
|
1174
|
+
}
|
|
1175
|
+
} else {
|
|
1176
|
+
const atIndex = pkgDeclaration.indexOf("@");
|
|
1177
|
+
if (atIndex > 0) {
|
|
1178
|
+
return pkgDeclaration.substring(0, atIndex);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Builds a purl (Package URL) for an npm package.
|
|
1185
|
+
*
|
|
1186
|
+
* Per the purl spec:
|
|
1187
|
+
* "The npm scope @ sign prefix is always percent encoded."
|
|
1188
|
+
*
|
|
1189
|
+
* So @types/node@20.11.5 → pkg:npm/%40types/node@20.11.5
|
|
1190
|
+
* And express@4.18.2 → pkg:npm/express@4.18.2
|
|
1191
|
+
*/
|
|
1192
|
+
buildPurl(name, version) {
|
|
1193
|
+
if (name.startsWith("@")) {
|
|
1194
|
+
return `pkg:npm/%40${name.slice(1)}@${version}`;
|
|
1195
|
+
}
|
|
1196
|
+
return `pkg:npm/${name}@${version}`;
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
// src/scanners/pnpm/pnpm-scanner.ts
|
|
1201
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
1202
|
+
import { existsSync as existsSync10 } from "fs";
|
|
1203
|
+
import path10 from "path";
|
|
1204
|
+
import { parse as parseYaml2 } from "yaml";
|
|
1205
|
+
var PnpmScanner = class {
|
|
1206
|
+
ecosystem = "npm";
|
|
1207
|
+
lockfileNames = ["pnpm-lock.yaml"];
|
|
1208
|
+
async detect(projectPath) {
|
|
1209
|
+
const lockfilePath = path10.join(projectPath, "pnpm-lock.yaml");
|
|
1210
|
+
return existsSync10(lockfilePath) ? lockfilePath : null;
|
|
1211
|
+
}
|
|
1212
|
+
async scan(projectPath, lockfilePath) {
|
|
1213
|
+
const lockfileRaw = await readFile10(lockfilePath, "utf-8");
|
|
1214
|
+
const dependencies = this.parseLockfile(lockfileRaw, lockfilePath);
|
|
1215
|
+
return {
|
|
1216
|
+
projectPath,
|
|
1217
|
+
ecosystem: "npm",
|
|
1218
|
+
dependencies,
|
|
1219
|
+
lockfilePath,
|
|
1220
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Parses pnpm-lock.yaml file and extracts dependencies.
|
|
1225
|
+
*
|
|
1226
|
+
* pnpm-lock.yaml format (v5.4+):
|
|
1227
|
+
* ```yaml
|
|
1228
|
+
* lockfileVersion: 5.4
|
|
1229
|
+
*
|
|
1230
|
+
* dependencies:
|
|
1231
|
+
* express: 4.18.2
|
|
1232
|
+
*
|
|
1233
|
+
* devDependencies:
|
|
1234
|
+
* typescript: 5.0.0
|
|
1235
|
+
*
|
|
1236
|
+
* packages:
|
|
1237
|
+
* /express/4.18.2:
|
|
1238
|
+
* resolution: {integrity: sha512-...}
|
|
1239
|
+
* dependencies:
|
|
1240
|
+
* accepts: 1.3.8
|
|
1241
|
+
* /@types/node/20.11.5:
|
|
1242
|
+
* resolution: {integrity: sha512-...}
|
|
1243
|
+
* dev: true
|
|
1244
|
+
* ```
|
|
1245
|
+
*
|
|
1246
|
+
* pnpm-lock.yaml format (v6.0+):
|
|
1247
|
+
* ```yaml
|
|
1248
|
+
* lockfileVersion: '6.0'
|
|
1249
|
+
*
|
|
1250
|
+
* dependencies:
|
|
1251
|
+
* express:
|
|
1252
|
+
* specifier: ^4.18.0
|
|
1253
|
+
* version: 4.18.2
|
|
1254
|
+
*
|
|
1255
|
+
* packages:
|
|
1256
|
+
* /express@4.18.2:
|
|
1257
|
+
* resolution: {integrity: sha512-...}
|
|
1258
|
+
* ```
|
|
1259
|
+
*/
|
|
1260
|
+
parseLockfile(content, lockfilePath) {
|
|
1261
|
+
try {
|
|
1262
|
+
const parsed = parseYaml2(content);
|
|
1263
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1264
|
+
throw new Error("Invalid YAML format");
|
|
1265
|
+
}
|
|
1266
|
+
const lockfile = parsed;
|
|
1267
|
+
const lockfileVersion = this.parseLockfileVersion(lockfile.lockfileVersion);
|
|
1268
|
+
const directNames = this.extractDirectDependencies(lockfile);
|
|
1269
|
+
return this.extractDependencies(lockfile, lockfileVersion, directNames);
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
throw new LockfileParseError(
|
|
1272
|
+
lockfilePath,
|
|
1273
|
+
`Failed to parse pnpm-lock.yaml: ${err instanceof Error ? err.message : "Unknown error"}`
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Extracts direct dependency names from pnpm lockfile.
|
|
1279
|
+
*
|
|
1280
|
+
* Supports both formats:
|
|
1281
|
+
* - pnpm v5.x: root-level dependencies/devDependencies
|
|
1282
|
+
* - pnpm v6+: importers['.'].dependencies/devDependencies
|
|
1283
|
+
*/
|
|
1284
|
+
extractDirectDependencies(lockfile) {
|
|
1285
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
1286
|
+
if (lockfile.importers && typeof lockfile.importers === "object") {
|
|
1287
|
+
const rootImporter = lockfile.importers["."];
|
|
1288
|
+
if (rootImporter && typeof rootImporter === "object") {
|
|
1289
|
+
if (rootImporter.dependencies && typeof rootImporter.dependencies === "object") {
|
|
1290
|
+
for (const name of Object.keys(rootImporter.dependencies)) {
|
|
1291
|
+
directNames.add(name);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (rootImporter.devDependencies && typeof rootImporter.devDependencies === "object") {
|
|
1295
|
+
for (const name of Object.keys(rootImporter.devDependencies)) {
|
|
1296
|
+
directNames.add(name);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (directNames.size === 0) {
|
|
1302
|
+
if (lockfile.dependencies && typeof lockfile.dependencies === "object") {
|
|
1303
|
+
for (const name of Object.keys(lockfile.dependencies)) {
|
|
1304
|
+
directNames.add(name);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (lockfile.devDependencies && typeof lockfile.devDependencies === "object") {
|
|
1308
|
+
for (const name of Object.keys(lockfile.devDependencies)) {
|
|
1309
|
+
directNames.add(name);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return directNames;
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Parses lockfile version (can be string or number)
|
|
1317
|
+
*/
|
|
1318
|
+
parseLockfileVersion(version) {
|
|
1319
|
+
if (typeof version === "number") {
|
|
1320
|
+
return version;
|
|
1321
|
+
}
|
|
1322
|
+
if (typeof version === "string") {
|
|
1323
|
+
const parsed = parseFloat(version);
|
|
1324
|
+
return isNaN(parsed) ? 5.4 : parsed;
|
|
1325
|
+
}
|
|
1326
|
+
return 5.4;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Extracts dependencies from the lockfile packages section
|
|
1330
|
+
*/
|
|
1331
|
+
extractDependencies(lockfile, lockfileVersion, directNames) {
|
|
1332
|
+
const deps = [];
|
|
1333
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1334
|
+
if (!lockfile.packages || typeof lockfile.packages !== "object") {
|
|
1335
|
+
return deps;
|
|
1336
|
+
}
|
|
1337
|
+
for (const [pkgPath, pkgInfo] of Object.entries(lockfile.packages)) {
|
|
1338
|
+
if (!pkgInfo || typeof pkgInfo !== "object") {
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
if (pkgPath.includes("@workspace:")) {
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
const { name, version } = this.parsePackagePath(pkgPath, lockfileVersion);
|
|
1345
|
+
if (!name || !version) {
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const depKey = `${name}@${version}`;
|
|
1349
|
+
if (seen.has(depKey)) {
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
seen.set(depKey, true);
|
|
1353
|
+
deps.push({
|
|
1354
|
+
name,
|
|
1355
|
+
version,
|
|
1356
|
+
direct: directNames.has(name),
|
|
1357
|
+
ecosystem: "npm",
|
|
1358
|
+
purl: this.buildPurl(name, version)
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
return deps;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Parses package path to extract name and version.
|
|
1365
|
+
*
|
|
1366
|
+
* pnpm v5.x format:
|
|
1367
|
+
* "/express/4.18.2" → name: "express", version: "4.18.2"
|
|
1368
|
+
* "/@types/node/20.11.5" → name: "@types/node", version: "20.11.5"
|
|
1369
|
+
* "/accepts/1.3.8" → name: "accepts", version: "1.3.8"
|
|
1370
|
+
*
|
|
1371
|
+
* pnpm v6+ format:
|
|
1372
|
+
* "/express@4.18.2" → name: "express", version: "4.18.2"
|
|
1373
|
+
* "/@types/node@20.11.5" → name: "@types/node", version: "20.11.5"
|
|
1374
|
+
* "/accepts@1.3.8" → name: "accepts", version: "1.3.8"
|
|
1375
|
+
*
|
|
1376
|
+
* Also handles peer dependency suffixes:
|
|
1377
|
+
* "/pkg@1.0.0_dep@2.0.0" → name: "pkg", version: "1.0.0"
|
|
1378
|
+
* "/pkg@1.0.0(dep@2.0.0)" → name: "pkg", version: "1.0.0"
|
|
1379
|
+
*/
|
|
1380
|
+
parsePackagePath(pkgPath, lockfileVersion) {
|
|
1381
|
+
const path14 = pkgPath.startsWith("/") ? pkgPath.slice(1) : pkgPath;
|
|
1382
|
+
const cleanPath = path14.split("_")[0].split("(")[0];
|
|
1383
|
+
if (!cleanPath) {
|
|
1384
|
+
return { name: null, version: null };
|
|
1385
|
+
}
|
|
1386
|
+
if (lockfileVersion >= 6) {
|
|
1387
|
+
return this.parseV6Format(cleanPath);
|
|
1388
|
+
}
|
|
1389
|
+
return this.parseV5Format(cleanPath);
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Parses v6+ format: "express@4.18.2" or "@types/node@20.11.5"
|
|
1393
|
+
*/
|
|
1394
|
+
parseV6Format(path14) {
|
|
1395
|
+
if (path14.startsWith("@")) {
|
|
1396
|
+
const lastAtIndex = path14.lastIndexOf("@");
|
|
1397
|
+
if (lastAtIndex <= 0) {
|
|
1398
|
+
return { name: null, version: null };
|
|
1399
|
+
}
|
|
1400
|
+
const name2 = path14.substring(0, lastAtIndex);
|
|
1401
|
+
const version2 = path14.substring(lastAtIndex + 1);
|
|
1402
|
+
return { name: name2, version: version2 };
|
|
1403
|
+
}
|
|
1404
|
+
const atIndex = path14.indexOf("@");
|
|
1405
|
+
if (atIndex < 0) {
|
|
1406
|
+
return { name: null, version: null };
|
|
1407
|
+
}
|
|
1408
|
+
const name = path14.substring(0, atIndex);
|
|
1409
|
+
const version = path14.substring(atIndex + 1);
|
|
1410
|
+
return { name, version };
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Parses v5.x format: "express/4.18.2" or "@types/node/20.11.5"
|
|
1414
|
+
*/
|
|
1415
|
+
parseV5Format(path14) {
|
|
1416
|
+
if (path14.startsWith("@")) {
|
|
1417
|
+
const parts = path14.split("/");
|
|
1418
|
+
if (parts.length < 3) {
|
|
1419
|
+
return { name: null, version: null };
|
|
1420
|
+
}
|
|
1421
|
+
const name2 = `${parts[0]}/${parts[1]}`;
|
|
1422
|
+
const version2 = parts[2];
|
|
1423
|
+
return { name: name2, version: version2 };
|
|
1424
|
+
}
|
|
1425
|
+
const slashIndex = path14.indexOf("/");
|
|
1426
|
+
if (slashIndex < 0) {
|
|
1427
|
+
return { name: null, version: null };
|
|
1428
|
+
}
|
|
1429
|
+
const name = path14.substring(0, slashIndex);
|
|
1430
|
+
const version = path14.substring(slashIndex + 1);
|
|
1431
|
+
return { name, version };
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Builds a purl (Package URL) for an npm package.
|
|
1435
|
+
*
|
|
1436
|
+
* Per the purl spec:
|
|
1437
|
+
* "The npm scope @ sign prefix is always percent encoded."
|
|
1438
|
+
*
|
|
1439
|
+
* So @types/node@20.11.5 → pkg:npm/%40types/node@20.11.5
|
|
1440
|
+
* And express@4.18.2 → pkg:npm/express@4.18.2
|
|
1441
|
+
*/
|
|
1442
|
+
buildPurl(name, version) {
|
|
1443
|
+
if (name.startsWith("@")) {
|
|
1444
|
+
return `pkg:npm/%40${name.slice(1)}@${version}`;
|
|
1445
|
+
}
|
|
1446
|
+
return `pkg:npm/${name}@${version}`;
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
// src/scanners/deno/deno-scanner.ts
|
|
1451
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
1452
|
+
import { existsSync as existsSync11 } from "fs";
|
|
1453
|
+
import path11 from "path";
|
|
1454
|
+
var DenoScanner = class {
|
|
1455
|
+
ecosystem = "deno";
|
|
1456
|
+
lockfileNames = ["deno.lock"];
|
|
1457
|
+
async detect(projectPath) {
|
|
1458
|
+
for (const name of this.lockfileNames) {
|
|
1459
|
+
const lockfilePath = path11.join(projectPath, name);
|
|
1460
|
+
if (existsSync11(lockfilePath)) return lockfilePath;
|
|
1461
|
+
}
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
async scan(projectPath, lockfilePath) {
|
|
1465
|
+
const lockfileRaw = await readFile11(lockfilePath, "utf-8");
|
|
1466
|
+
let lockfile;
|
|
1467
|
+
try {
|
|
1468
|
+
lockfile = JSON.parse(lockfileRaw);
|
|
1469
|
+
} catch {
|
|
1470
|
+
throw new LockfileParseError(lockfilePath, "Invalid JSON");
|
|
1471
|
+
}
|
|
1472
|
+
const dependencies = this.parseLockfile(lockfile);
|
|
1473
|
+
return {
|
|
1474
|
+
projectPath,
|
|
1475
|
+
ecosystem: "deno",
|
|
1476
|
+
dependencies,
|
|
1477
|
+
lockfilePath,
|
|
1478
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Parses the lockfile and extracts dependencies from both
|
|
1483
|
+
* npm and jsr package registries.
|
|
1484
|
+
*
|
|
1485
|
+
* Supports v3 (packages nested under `packages`), v4, and v5 (top-level jsr/npm sections).
|
|
1486
|
+
* Uses lockfile specifiers to determine direct vs transitive dependencies.
|
|
1487
|
+
*/
|
|
1488
|
+
parseLockfile(lockfile) {
|
|
1489
|
+
const deps = [];
|
|
1490
|
+
const directNames = this.extractDirectDependencies(lockfile);
|
|
1491
|
+
const jsrPackages = lockfile.jsr ?? lockfile.packages?.jsr ?? {};
|
|
1492
|
+
const npmPackages = lockfile.npm ?? lockfile.packages?.npm ?? {};
|
|
1493
|
+
for (const key of Object.keys(jsrPackages)) {
|
|
1494
|
+
const parsed = this.parsePackageKey(key);
|
|
1495
|
+
if (!parsed) continue;
|
|
1496
|
+
deps.push({
|
|
1497
|
+
name: parsed.name,
|
|
1498
|
+
version: parsed.version,
|
|
1499
|
+
direct: directNames.has(`jsr:${parsed.name}`),
|
|
1500
|
+
ecosystem: "deno",
|
|
1501
|
+
// JSR packages belong to Deno ecosystem
|
|
1502
|
+
purl: this.buildJsrPurl(parsed.name, parsed.version)
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
for (const key of Object.keys(npmPackages)) {
|
|
1506
|
+
const parsed = this.parsePackageKey(key);
|
|
1507
|
+
if (!parsed) continue;
|
|
1508
|
+
deps.push({
|
|
1509
|
+
name: parsed.name,
|
|
1510
|
+
version: parsed.version,
|
|
1511
|
+
direct: directNames.has(`npm:${parsed.name}`),
|
|
1512
|
+
ecosystem: "npm",
|
|
1513
|
+
// npm packages belong to npm ecosystem (for CVE tracking)
|
|
1514
|
+
purl: this.buildNpmPurl(parsed.name, parsed.version)
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
return deps;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Extracts direct dependency names from lockfile specifiers.
|
|
1521
|
+
*
|
|
1522
|
+
* Returns a set of ecosystem-qualified package names like "jsr:@std/assert" or "npm:express".
|
|
1523
|
+
* The ecosystem prefix prevents collisions if the same package name exists in both registries.
|
|
1524
|
+
*/
|
|
1525
|
+
extractDirectDependencies(lockfile) {
|
|
1526
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
1527
|
+
const specifiers = lockfile.specifiers ?? lockfile.packages?.specifiers ?? {};
|
|
1528
|
+
for (const [constraint, resolved] of Object.entries(specifiers)) {
|
|
1529
|
+
if (resolved.startsWith("file:") || resolved.startsWith("https:") || resolved.startsWith("http:") || resolved.startsWith("data:")) {
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
let ecosystem = null;
|
|
1533
|
+
if (constraint.startsWith("jsr:")) {
|
|
1534
|
+
ecosystem = "jsr";
|
|
1535
|
+
} else if (constraint.startsWith("npm:")) {
|
|
1536
|
+
ecosystem = "npm";
|
|
1537
|
+
}
|
|
1538
|
+
if (!ecosystem) continue;
|
|
1539
|
+
let resolvedKey = resolved;
|
|
1540
|
+
if (resolved.startsWith("jsr:") || resolved.startsWith("npm:")) {
|
|
1541
|
+
resolvedKey = resolved.replace(/^(jsr:|npm:)/, "");
|
|
1542
|
+
const parsed = this.parsePackageKey(resolvedKey);
|
|
1543
|
+
if (parsed) {
|
|
1544
|
+
directNames.add(`${ecosystem}:${parsed.name}`);
|
|
1545
|
+
}
|
|
1546
|
+
} else {
|
|
1547
|
+
const name = this.extractNameFromSpecifier(constraint);
|
|
1548
|
+
if (name) {
|
|
1549
|
+
directNames.add(`${ecosystem}:${name}`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return directNames;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Parses a package key like "@std/assert@1.0.10" or "express@4.21.2"
|
|
1557
|
+
* into { name, version }.
|
|
1558
|
+
*
|
|
1559
|
+
* Handles scoped packages where the name starts with @ (e.g., @std/assert).
|
|
1560
|
+
* In that case the version separator is the LAST @ sign.
|
|
1561
|
+
*/
|
|
1562
|
+
parsePackageKey(key) {
|
|
1563
|
+
const lastAtIndex = key.lastIndexOf("@");
|
|
1564
|
+
if (lastAtIndex <= 0) return null;
|
|
1565
|
+
const name = key.slice(0, lastAtIndex);
|
|
1566
|
+
const version = key.slice(lastAtIndex + 1);
|
|
1567
|
+
if (!name || !version) return null;
|
|
1568
|
+
return { name, version };
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Extracts the package name from a Deno import specifier.
|
|
1572
|
+
*
|
|
1573
|
+
* Examples:
|
|
1574
|
+
* "jsr:@std/assert@^1.0.0" → "@std/assert"
|
|
1575
|
+
* "npm:express@^4.18.0" → "express"
|
|
1576
|
+
* "npm:@hono/hono@^4.0.0" → "@hono/hono"
|
|
1577
|
+
* "lodash" (bare) → "lodash"
|
|
1578
|
+
*/
|
|
1579
|
+
extractNameFromSpecifier(specifier) {
|
|
1580
|
+
const withoutPrefix = specifier.replace(/^(jsr:|npm:)/, "");
|
|
1581
|
+
if (!withoutPrefix) return null;
|
|
1582
|
+
if (withoutPrefix.startsWith("@")) {
|
|
1583
|
+
const slashIndex = withoutPrefix.indexOf("/");
|
|
1584
|
+
if (slashIndex === -1) return null;
|
|
1585
|
+
const afterSlash = withoutPrefix.indexOf("@", slashIndex);
|
|
1586
|
+
if (afterSlash === -1) return withoutPrefix;
|
|
1587
|
+
return withoutPrefix.slice(0, afterSlash);
|
|
1588
|
+
}
|
|
1589
|
+
const atIndex = withoutPrefix.indexOf("@");
|
|
1590
|
+
if (atIndex === -1) return withoutPrefix;
|
|
1591
|
+
return withoutPrefix.slice(0, atIndex);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Builds a purl for a JSR package.
|
|
1595
|
+
*
|
|
1596
|
+
* JSR packages use the "jsr" purl type (non-standard but descriptive).
|
|
1597
|
+
* For scoped packages, both @ and all / characters are percent-encoded.
|
|
1598
|
+
* Example: `pkg:jsr/%40std%2Fassert@1.0.10`
|
|
1599
|
+
*/
|
|
1600
|
+
buildJsrPurl(name, version) {
|
|
1601
|
+
if (name.startsWith("@")) {
|
|
1602
|
+
const encoded = "%40" + name.slice(1).replace(/\//g, "%2F");
|
|
1603
|
+
return `pkg:jsr/${encoded}@${version}`;
|
|
1604
|
+
}
|
|
1605
|
+
return `pkg:jsr/${name}@${version}`;
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Builds a purl for an npm package used via Deno.
|
|
1609
|
+
*
|
|
1610
|
+
* Uses the standard npm purl type since these are npm packages.
|
|
1611
|
+
* Per npm purl spec, only @ is encoded, / remains as namespace separator.
|
|
1612
|
+
* Example: `pkg:npm/%40std/assert@1.0.10` or `pkg:npm/express@4.21.2`
|
|
1613
|
+
*/
|
|
1614
|
+
buildNpmPurl(name, version) {
|
|
1615
|
+
if (name.startsWith("@")) {
|
|
1616
|
+
return `pkg:npm/%40${name.slice(1)}@${version}`;
|
|
1617
|
+
}
|
|
1618
|
+
return `pkg:npm/${name}@${version}`;
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
|
|
1622
|
+
// src/scanners/poetry/poetry-scanner.ts
|
|
1623
|
+
import { readFile as readFile12 } from "fs/promises";
|
|
1624
|
+
import { existsSync as existsSync12 } from "fs";
|
|
1625
|
+
import path12 from "path";
|
|
1626
|
+
var PoetryScanner = class {
|
|
1627
|
+
ecosystem = "poetry";
|
|
1628
|
+
lockfileNames = ["poetry.lock"];
|
|
1629
|
+
async detect(projectPath) {
|
|
1630
|
+
const lockfilePath = path12.join(projectPath, "poetry.lock");
|
|
1631
|
+
return existsSync12(lockfilePath) ? lockfilePath : null;
|
|
1632
|
+
}
|
|
1633
|
+
async scan(projectPath, lockfilePath) {
|
|
1634
|
+
const [lockfileRaw, pyprojectRaw] = await Promise.all([
|
|
1635
|
+
readFile12(lockfilePath, "utf-8"),
|
|
1636
|
+
readFile12(path12.join(projectPath, "pyproject.toml"), "utf-8").catch(() => null)
|
|
1637
|
+
]);
|
|
1638
|
+
const packages = this.parseLockfile(lockfileRaw, lockfilePath);
|
|
1639
|
+
const directNames = pyprojectRaw ? this.parsePyprojectToml(pyprojectRaw) : /* @__PURE__ */ new Set();
|
|
1640
|
+
const dependencies = [];
|
|
1641
|
+
for (const pkg2 of packages) {
|
|
1642
|
+
dependencies.push({
|
|
1643
|
+
name: this.normalizePipName(pkg2.name),
|
|
1644
|
+
version: pkg2.version,
|
|
1645
|
+
direct: directNames.size > 0 ? directNames.has(this.normalizePipName(pkg2.name)) : true,
|
|
1646
|
+
ecosystem: "poetry",
|
|
1647
|
+
purl: this.buildPurl(pkg2.name, pkg2.version)
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
return {
|
|
1651
|
+
projectPath,
|
|
1652
|
+
ecosystem: "poetry",
|
|
1653
|
+
dependencies,
|
|
1654
|
+
lockfilePath,
|
|
1655
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Parses poetry.lock by splitting on [[package]] blocks.
|
|
1660
|
+
* Lightweight parser that handles the regular structure
|
|
1661
|
+
* without needing a full TOML library.
|
|
1662
|
+
*/
|
|
1663
|
+
parseLockfile(content, lockfilePath) {
|
|
1664
|
+
const packages = [];
|
|
1665
|
+
const blocks = content.split(/^\[\[package\]\]$/m);
|
|
1666
|
+
for (const block of blocks) {
|
|
1667
|
+
if (!block.trim()) continue;
|
|
1668
|
+
const name = this.extractField(block, "name");
|
|
1669
|
+
const version = this.extractField(block, "version");
|
|
1670
|
+
if (name && version) {
|
|
1671
|
+
packages.push({ name, version });
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (packages.length === 0 && content.includes("[[package]]")) {
|
|
1675
|
+
throw new LockfileParseError(lockfilePath, "Failed to parse any packages from poetry.lock");
|
|
1676
|
+
}
|
|
1677
|
+
return packages;
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Extracts a string field value from a TOML block.
|
|
1681
|
+
* Handles: `name = "value"` format.
|
|
1682
|
+
*/
|
|
1683
|
+
extractField(block, fieldName) {
|
|
1684
|
+
const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
|
|
1685
|
+
const match = block.match(regex);
|
|
1686
|
+
return match ? match[1] : null;
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Parses `pyproject.toml` to extract direct dependency names.
|
|
1690
|
+
*
|
|
1691
|
+
* Looks for:
|
|
1692
|
+
* - `[tool.poetry.dependencies]` — main dependencies
|
|
1693
|
+
* - `[tool.poetry.group.dev.dependencies]` — dev dependencies
|
|
1694
|
+
* - `[tool.poetry.group.*.dependencies]` — other groups
|
|
1695
|
+
*
|
|
1696
|
+
* Supports formats:
|
|
1697
|
+
* - `requests = "^2.31.0"`
|
|
1698
|
+
* - `requests = { version = "^2.31.0", optional = true }`
|
|
1699
|
+
* - `python = "^3.12"` — skipped (the Python interpreter itself)
|
|
1700
|
+
*/
|
|
1701
|
+
parsePyprojectToml(content) {
|
|
1702
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
1703
|
+
let inDepsSection = false;
|
|
1704
|
+
for (const rawLine of content.split("\n")) {
|
|
1705
|
+
const line = rawLine.trim();
|
|
1706
|
+
if (line.startsWith("[")) {
|
|
1707
|
+
inDepsSection = line === "[tool.poetry.dependencies]" || /^\[tool\.poetry\.group\.[^\]]+\.dependencies\]$/.test(line);
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
if (inDepsSection && line && !line.startsWith("#")) {
|
|
1711
|
+
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*=/);
|
|
1712
|
+
if (match && match[1]) {
|
|
1713
|
+
const name = this.normalizePipName(match[1]);
|
|
1714
|
+
if (name !== "python") {
|
|
1715
|
+
directNames.add(name);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
return directNames;
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Normalizes a pip package name per PEP 503.
|
|
1724
|
+
* Converts to lowercase and replaces any run of [-_.] with a single hyphen.
|
|
1725
|
+
*/
|
|
1726
|
+
normalizePipName(name) {
|
|
1727
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Builds a purl for a PyPI package.
|
|
1731
|
+
* Per purl spec, the type is "pypi" (not "poetry").
|
|
1732
|
+
*/
|
|
1733
|
+
buildPurl(name, version) {
|
|
1734
|
+
return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
// src/scanners/uv/uv-scanner.ts
|
|
1739
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
1740
|
+
import { existsSync as existsSync13 } from "fs";
|
|
1741
|
+
import path13 from "path";
|
|
1742
|
+
var UvScanner = class {
|
|
1743
|
+
ecosystem = "uv";
|
|
1744
|
+
lockfileNames = ["uv.lock"];
|
|
1745
|
+
async detect(projectPath) {
|
|
1746
|
+
const lockfilePath = path13.join(projectPath, "uv.lock");
|
|
1747
|
+
return existsSync13(lockfilePath) ? lockfilePath : null;
|
|
1748
|
+
}
|
|
1749
|
+
async scan(projectPath, lockfilePath) {
|
|
1750
|
+
const [lockfileRaw, pyprojectRaw] = await Promise.all([
|
|
1751
|
+
readFile13(lockfilePath, "utf-8"),
|
|
1752
|
+
readFile13(path13.join(projectPath, "pyproject.toml"), "utf-8").catch(() => null)
|
|
1753
|
+
]);
|
|
1754
|
+
const packages = this.parseLockfile(lockfileRaw, lockfilePath);
|
|
1755
|
+
const projectName = pyprojectRaw ? this.extractProjectName(pyprojectRaw) : null;
|
|
1756
|
+
const directNames = pyprojectRaw ? this.parsePyprojectDeps(pyprojectRaw) : /* @__PURE__ */ new Set();
|
|
1757
|
+
const dependencies = [];
|
|
1758
|
+
for (const pkg2 of packages) {
|
|
1759
|
+
if (pkg2.isEditable) continue;
|
|
1760
|
+
if (projectName && this.normalizePipName(pkg2.name) === this.normalizePipName(projectName)) {
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
dependencies.push({
|
|
1764
|
+
name: this.normalizePipName(pkg2.name),
|
|
1765
|
+
version: pkg2.version,
|
|
1766
|
+
direct: directNames.size > 0 ? directNames.has(this.normalizePipName(pkg2.name)) : true,
|
|
1767
|
+
ecosystem: "uv",
|
|
1768
|
+
purl: this.buildPurl(pkg2.name, pkg2.version)
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
return {
|
|
1772
|
+
projectPath,
|
|
1773
|
+
ecosystem: "uv",
|
|
1774
|
+
dependencies,
|
|
1775
|
+
lockfilePath,
|
|
1776
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Parses uv.lock by splitting on [[package]] blocks.
|
|
1781
|
+
* Lightweight parser that handles the regular structure
|
|
1782
|
+
* without needing a full TOML library.
|
|
1783
|
+
*/
|
|
1784
|
+
parseLockfile(content, lockfilePath) {
|
|
1785
|
+
const packages = [];
|
|
1786
|
+
const blocks = content.split(/^\[\[package\]\]$/m);
|
|
1787
|
+
for (const block of blocks) {
|
|
1788
|
+
if (!block.trim()) continue;
|
|
1789
|
+
const name = this.extractField(block, "name");
|
|
1790
|
+
const version = this.extractField(block, "version");
|
|
1791
|
+
if (name && version) {
|
|
1792
|
+
const isEditable = /source\s*=\s*\{[^}]*editable\s*=/.test(block) || /source\s*=\s*\{[^}]*virtual\s*=/.test(block);
|
|
1793
|
+
packages.push({ name, version, isEditable });
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (packages.length === 0 && content.includes("[[package]]")) {
|
|
1797
|
+
throw new LockfileParseError(lockfilePath, "Failed to parse any packages from uv.lock");
|
|
1798
|
+
}
|
|
1799
|
+
return packages;
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Extracts a string field value from a TOML block.
|
|
1803
|
+
* Handles: `name = "value"` format.
|
|
1804
|
+
*/
|
|
1805
|
+
extractField(block, fieldName) {
|
|
1806
|
+
const regex = new RegExp(`^${fieldName}\\s*=\\s*"([^"]*)"`, "m");
|
|
1807
|
+
const match = block.match(regex);
|
|
1808
|
+
return match ? match[1] : null;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Extracts the project name from `pyproject.toml`.
|
|
1812
|
+
* Looks for `name = "..."` under `[project]`.
|
|
1813
|
+
*/
|
|
1814
|
+
extractProjectName(content) {
|
|
1815
|
+
let inProjectSection = false;
|
|
1816
|
+
for (const rawLine of content.split("\n")) {
|
|
1817
|
+
const line = rawLine.trim();
|
|
1818
|
+
if (line.startsWith("[")) {
|
|
1819
|
+
inProjectSection = line === "[project]";
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
if (inProjectSection) {
|
|
1823
|
+
const match = line.match(/^name\s*=\s*"([^"]*)"/);
|
|
1824
|
+
if (match) return match[1];
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Parses `pyproject.toml` to extract direct dependency names.
|
|
1831
|
+
*
|
|
1832
|
+
* Looks for:
|
|
1833
|
+
* - `[project]` → `dependencies = [...]` (PEP 621)
|
|
1834
|
+
* - `[project.optional-dependencies]` (extras)
|
|
1835
|
+
* - `[dependency-groups]` (PEP 735, used by uv for dev deps)
|
|
1836
|
+
*
|
|
1837
|
+
* Dependency strings follow PEP 508:
|
|
1838
|
+
* - `"requests>=2.31.0"`
|
|
1839
|
+
* - `"flask[dotenv]>=3.0"`
|
|
1840
|
+
* - `"black"` (bare name)
|
|
1841
|
+
*/
|
|
1842
|
+
parsePyprojectDeps(content) {
|
|
1843
|
+
const directNames = /* @__PURE__ */ new Set();
|
|
1844
|
+
this.extractInlineArray(content, directNames);
|
|
1845
|
+
this.extractDependencyGroups(content, directNames);
|
|
1846
|
+
return directNames;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Extracts dependency names from PEP 621 `dependencies = [...]` arrays
|
|
1850
|
+
* and `[project.optional-dependencies]` sections.
|
|
1851
|
+
*/
|
|
1852
|
+
extractInlineArray(content, directNames) {
|
|
1853
|
+
const arrayRegex = /(?:^dependencies|^[a-zA-Z0-9_-]+)\s*=\s*\[([^\]]*)\]/gm;
|
|
1854
|
+
let match;
|
|
1855
|
+
while ((match = arrayRegex.exec(content)) !== null) {
|
|
1856
|
+
const arrayContent = match[1];
|
|
1857
|
+
this.extractPepNames(arrayContent, directNames);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Extracts dependency names from [dependency-groups] sections.
|
|
1862
|
+
* Format:
|
|
1863
|
+
* ```toml
|
|
1864
|
+
* [dependency-groups]
|
|
1865
|
+
* dev = ["pytest>=7.0", "black"]
|
|
1866
|
+
* ```
|
|
1867
|
+
*/
|
|
1868
|
+
extractDependencyGroups(content, directNames) {
|
|
1869
|
+
let inDepGroups = false;
|
|
1870
|
+
for (const rawLine of content.split("\n")) {
|
|
1871
|
+
const line = rawLine.trim();
|
|
1872
|
+
if (line.startsWith("[")) {
|
|
1873
|
+
inDepGroups = line === "[dependency-groups]";
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
if (inDepGroups && line && !line.startsWith("#")) {
|
|
1877
|
+
const arrayMatch = line.match(/^[a-zA-Z0-9_-]+\s*=\s*\[([^\]]*)\]/);
|
|
1878
|
+
if (arrayMatch) {
|
|
1879
|
+
this.extractPepNames(arrayMatch[1], directNames);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Extracts PEP 508 package names from a comma-separated
|
|
1886
|
+
* list of quoted dependency strings.
|
|
1887
|
+
*/
|
|
1888
|
+
extractPepNames(content, directNames) {
|
|
1889
|
+
const depStrings = content.match(/"([^"]*)"/g);
|
|
1890
|
+
if (!depStrings) return;
|
|
1891
|
+
for (const quoted of depStrings) {
|
|
1892
|
+
const depStr = quoted.replace(/"/g, "").trim();
|
|
1893
|
+
if (!depStr) continue;
|
|
1894
|
+
const nameMatch = depStr.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)/);
|
|
1895
|
+
if (nameMatch && nameMatch[1]) {
|
|
1896
|
+
directNames.add(this.normalizePipName(nameMatch[1]));
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Normalizes a pip package name per PEP 503.
|
|
1902
|
+
* Converts to lowercase and replaces any run of [-_.] with a single hyphen.
|
|
1903
|
+
*/
|
|
1904
|
+
normalizePipName(name) {
|
|
1905
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Builds a purl for a PyPI package.
|
|
1909
|
+
* Per purl spec, the type is "pypi" (not "uv").
|
|
1910
|
+
*/
|
|
1911
|
+
buildPurl(name, version) {
|
|
1912
|
+
return `pkg:pypi/${this.normalizePipName(name)}@${version}`;
|
|
1913
|
+
}
|
|
1914
|
+
};
|
|
1915
|
+
|
|
929
1916
|
// src/scanners/registry.ts
|
|
930
1917
|
var ScannerRegistry = class {
|
|
931
1918
|
scanners;
|
|
@@ -935,10 +1922,15 @@ var ScannerRegistry = class {
|
|
|
935
1922
|
new NugetScanner(),
|
|
936
1923
|
new CargoScanner(),
|
|
937
1924
|
new PipScanner(),
|
|
1925
|
+
new PoetryScanner(),
|
|
1926
|
+
new UvScanner(),
|
|
938
1927
|
new MavenScanner(),
|
|
939
1928
|
new GoScanner(),
|
|
940
1929
|
new RubyScanner(),
|
|
941
|
-
new ComposerScanner()
|
|
1930
|
+
new ComposerScanner(),
|
|
1931
|
+
new YarnScanner(),
|
|
1932
|
+
new PnpmScanner(),
|
|
1933
|
+
new DenoScanner()
|
|
942
1934
|
];
|
|
943
1935
|
}
|
|
944
1936
|
/**
|
|
@@ -964,48 +1956,88 @@ var ScannerRegistry = class {
|
|
|
964
1956
|
}
|
|
965
1957
|
};
|
|
966
1958
|
|
|
1959
|
+
// src/sbom/shared.ts
|
|
1960
|
+
var VERIMU_TOOL_NAME = "verimu";
|
|
1961
|
+
var VERIMU_TOOL_WEBSITE = "https://verimu.com";
|
|
1962
|
+
var VERIMU_TOOL_DESCRIPTION = "Verimu CRA Compliance Scanner";
|
|
1963
|
+
var DEFAULT_TOOL_VERSION = "0.1.0";
|
|
1964
|
+
var DEFAULT_SWID_VERSION = "0.0.0";
|
|
1965
|
+
var PURL_TYPE_MAP = {
|
|
1966
|
+
npm: "npm",
|
|
1967
|
+
nuget: "nuget",
|
|
1968
|
+
cargo: "cargo",
|
|
1969
|
+
maven: "maven",
|
|
1970
|
+
pip: "pypi",
|
|
1971
|
+
poetry: "pypi",
|
|
1972
|
+
uv: "pypi",
|
|
1973
|
+
go: "golang",
|
|
1974
|
+
ruby: "gem",
|
|
1975
|
+
composer: "composer",
|
|
1976
|
+
deno: "deno"
|
|
1977
|
+
};
|
|
1978
|
+
function buildPurl(name, version, ecosystem) {
|
|
1979
|
+
const type = PURL_TYPE_MAP[ecosystem] || ecosystem;
|
|
1980
|
+
if (ecosystem === "npm" && name.startsWith("@")) {
|
|
1981
|
+
return `pkg:${type}/%40${name.slice(1)}@${version}`;
|
|
1982
|
+
}
|
|
1983
|
+
return `pkg:${type}/${name}@${version}`;
|
|
1984
|
+
}
|
|
1985
|
+
function deriveSupplierName(packageName) {
|
|
1986
|
+
if (packageName.startsWith("@")) {
|
|
1987
|
+
return packageName.split("/")[0];
|
|
1988
|
+
}
|
|
1989
|
+
return packageName;
|
|
1990
|
+
}
|
|
1991
|
+
function extractProjectName(projectPath) {
|
|
1992
|
+
const parts = projectPath.replace(/\\/g, "/").split("/");
|
|
1993
|
+
return parts[parts.length - 1] || "unknown-project";
|
|
1994
|
+
}
|
|
1995
|
+
function normalizeDependencies(dependencies) {
|
|
1996
|
+
return dependencies.map((dep) => ({
|
|
1997
|
+
name: dep.name,
|
|
1998
|
+
version: dep.version,
|
|
1999
|
+
ecosystem: dep.ecosystem,
|
|
2000
|
+
direct: dep.direct ?? true,
|
|
2001
|
+
purl: dep.purl ?? buildPurl(dep.name, dep.version, dep.ecosystem),
|
|
2002
|
+
supplierName: deriveSupplierName(dep.name)
|
|
2003
|
+
}));
|
|
2004
|
+
}
|
|
2005
|
+
|
|
967
2006
|
// src/sbom/cyclonedx.ts
|
|
968
2007
|
import { randomUUID } from "crypto";
|
|
2008
|
+
var SCHEMA_URLS = {
|
|
2009
|
+
"1.4": "http://cyclonedx.org/schema/bom-1.4.schema.json",
|
|
2010
|
+
"1.5": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
2011
|
+
"1.6": "http://cyclonedx.org/schema/bom-1.6.schema.json",
|
|
2012
|
+
"1.7": "http://cyclonedx.org/schema/bom-1.7.schema.json"
|
|
2013
|
+
};
|
|
969
2014
|
var CycloneDxGenerator = class {
|
|
2015
|
+
constructor(specVersion = "1.7") {
|
|
2016
|
+
this.specVersion = specVersion;
|
|
2017
|
+
}
|
|
970
2018
|
format = "cyclonedx-json";
|
|
971
|
-
generate(scanResult, toolVersion =
|
|
2019
|
+
generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
|
|
972
2020
|
const bom = this.buildBom(scanResult, toolVersion);
|
|
973
2021
|
const content = JSON.stringify(bom, null, 2);
|
|
974
2022
|
return {
|
|
975
2023
|
format: "cyclonedx-json",
|
|
976
|
-
specVersion:
|
|
2024
|
+
specVersion: this.specVersion,
|
|
977
2025
|
content,
|
|
978
2026
|
componentCount: scanResult.dependencies.length,
|
|
979
2027
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
980
2028
|
};
|
|
981
2029
|
}
|
|
982
2030
|
buildBom(scanResult, toolVersion) {
|
|
983
|
-
const projectName =
|
|
2031
|
+
const projectName = extractProjectName(scanResult.projectPath);
|
|
984
2032
|
return {
|
|
985
|
-
$schema:
|
|
2033
|
+
$schema: SCHEMA_URLS[this.specVersion],
|
|
986
2034
|
bomFormat: "CycloneDX",
|
|
987
|
-
specVersion:
|
|
2035
|
+
specVersion: this.specVersion,
|
|
988
2036
|
serialNumber: `urn:uuid:${randomUUID()}`,
|
|
989
2037
|
version: 1,
|
|
990
2038
|
metadata: {
|
|
991
2039
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
992
|
-
tools:
|
|
993
|
-
components: [
|
|
994
|
-
{
|
|
995
|
-
type: "application",
|
|
996
|
-
name: "verimu",
|
|
997
|
-
version: toolVersion,
|
|
998
|
-
description: "Verimu CRA Compliance Scanner",
|
|
999
|
-
supplier: { name: "Verimu" },
|
|
1000
|
-
externalReferences: [
|
|
1001
|
-
{
|
|
1002
|
-
type: "website",
|
|
1003
|
-
url: "https://verimu.com"
|
|
1004
|
-
}
|
|
1005
|
-
]
|
|
1006
|
-
}
|
|
1007
|
-
]
|
|
1008
|
-
},
|
|
2040
|
+
tools: this.buildTools(toolVersion),
|
|
1009
2041
|
// NTIA: metadata.supplier — the org supplying the root software
|
|
1010
2042
|
supplier: {
|
|
1011
2043
|
name: projectName
|
|
@@ -1032,26 +2064,10 @@ var CycloneDxGenerator = class {
|
|
|
1032
2064
|
scope: dep.direct ? "required" : "optional",
|
|
1033
2065
|
// NTIA: component.supplier — derived from npm scope or package name
|
|
1034
2066
|
supplier: {
|
|
1035
|
-
name:
|
|
2067
|
+
name: deriveSupplierName(dep.name)
|
|
1036
2068
|
}
|
|
1037
2069
|
};
|
|
1038
2070
|
}
|
|
1039
|
-
/**
|
|
1040
|
-
* Derives a supplier name from a package name.
|
|
1041
|
-
*
|
|
1042
|
-
* For scoped packages like "@vue/reactivity" → "@vue"
|
|
1043
|
-
* For unscoped packages like "express" → "express"
|
|
1044
|
-
*
|
|
1045
|
-
* This is the same heuristic used by Syft, Trivy, and other SBOM tools
|
|
1046
|
-
* when registry metadata (author/publisher) isn't available from the lockfile.
|
|
1047
|
-
*/
|
|
1048
|
-
deriveSupplierName(packageName) {
|
|
1049
|
-
if (packageName.startsWith("@")) {
|
|
1050
|
-
const scope = packageName.split("/")[0];
|
|
1051
|
-
return scope;
|
|
1052
|
-
}
|
|
1053
|
-
return packageName;
|
|
1054
|
-
}
|
|
1055
2071
|
/**
|
|
1056
2072
|
* Builds the dependency graph section of the SBOM.
|
|
1057
2073
|
*
|
|
@@ -1072,12 +2088,162 @@ var CycloneDxGenerator = class {
|
|
|
1072
2088
|
}
|
|
1073
2089
|
];
|
|
1074
2090
|
}
|
|
1075
|
-
/**
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
2091
|
+
/**
|
|
2092
|
+
* Builds the tools metadata section.
|
|
2093
|
+
*
|
|
2094
|
+
* CycloneDX 1.4: tools is a flat array of { vendor, name, version, ... }
|
|
2095
|
+
* CycloneDX 1.5+: tools is an object { components: [...] }
|
|
2096
|
+
*/
|
|
2097
|
+
buildTools(toolVersion) {
|
|
2098
|
+
if (this.specVersion === "1.4") {
|
|
2099
|
+
return [
|
|
2100
|
+
{
|
|
2101
|
+
vendor: "Verimu",
|
|
2102
|
+
name: VERIMU_TOOL_NAME,
|
|
2103
|
+
version: toolVersion,
|
|
2104
|
+
externalReferences: [{ type: "website", url: VERIMU_TOOL_WEBSITE }]
|
|
2105
|
+
}
|
|
2106
|
+
];
|
|
2107
|
+
}
|
|
2108
|
+
return {
|
|
2109
|
+
components: [
|
|
2110
|
+
{
|
|
2111
|
+
type: "application",
|
|
2112
|
+
name: VERIMU_TOOL_NAME,
|
|
2113
|
+
version: toolVersion,
|
|
2114
|
+
description: VERIMU_TOOL_DESCRIPTION,
|
|
2115
|
+
supplier: { name: "Verimu" },
|
|
2116
|
+
externalReferences: [{ type: "website", url: VERIMU_TOOL_WEBSITE }]
|
|
2117
|
+
}
|
|
2118
|
+
]
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
};
|
|
2122
|
+
|
|
2123
|
+
// src/sbom/spdx.ts
|
|
2124
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2125
|
+
var SPDX_VERSION = "2.3";
|
|
2126
|
+
var SpdxJsonGenerator = class {
|
|
2127
|
+
format = "spdx-json";
|
|
2128
|
+
generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
|
|
2129
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2130
|
+
const projectName = extractProjectName(scanResult.projectPath);
|
|
2131
|
+
const rootPackageId = "SPDXRef-Package-root";
|
|
2132
|
+
const dependencies = normalizeDependencies(scanResult.dependencies);
|
|
2133
|
+
const document = {
|
|
2134
|
+
spdxVersion: `SPDX-${SPDX_VERSION}`,
|
|
2135
|
+
dataLicense: "CC0-1.0",
|
|
2136
|
+
SPDXID: "SPDXRef-DOCUMENT",
|
|
2137
|
+
name: `${projectName}-sbom`,
|
|
2138
|
+
documentNamespace: `https://verimu.com/spdxdocs/${projectName}-${randomUUID2()}`,
|
|
2139
|
+
creationInfo: {
|
|
2140
|
+
created: timestamp,
|
|
2141
|
+
creators: [`Tool: ${VERIMU_TOOL_NAME}@${toolVersion}`]
|
|
2142
|
+
},
|
|
2143
|
+
documentDescribes: [rootPackageId],
|
|
2144
|
+
packages: [
|
|
2145
|
+
{
|
|
2146
|
+
name: projectName,
|
|
2147
|
+
SPDXID: rootPackageId,
|
|
2148
|
+
versionInfo: "NOASSERTION",
|
|
2149
|
+
supplier: `Organization: ${projectName}`,
|
|
2150
|
+
downloadLocation: "NOASSERTION",
|
|
2151
|
+
filesAnalyzed: false,
|
|
2152
|
+
licenseConcluded: "NOASSERTION",
|
|
2153
|
+
licenseDeclared: "NOASSERTION",
|
|
2154
|
+
primaryPackagePurpose: "APPLICATION"
|
|
2155
|
+
},
|
|
2156
|
+
...dependencies.map((dep, index) => ({
|
|
2157
|
+
name: dep.name,
|
|
2158
|
+
SPDXID: `SPDXRef-Package-${index + 1}`,
|
|
2159
|
+
versionInfo: dep.version,
|
|
2160
|
+
supplier: `Organization: ${dep.supplierName}`,
|
|
2161
|
+
downloadLocation: "NOASSERTION",
|
|
2162
|
+
filesAnalyzed: false,
|
|
2163
|
+
licenseConcluded: "NOASSERTION",
|
|
2164
|
+
licenseDeclared: "NOASSERTION",
|
|
2165
|
+
primaryPackagePurpose: "LIBRARY",
|
|
2166
|
+
externalRefs: [
|
|
2167
|
+
{
|
|
2168
|
+
referenceCategory: "PACKAGE-MANAGER",
|
|
2169
|
+
referenceType: "purl",
|
|
2170
|
+
referenceLocator: dep.purl
|
|
2171
|
+
}
|
|
2172
|
+
]
|
|
2173
|
+
}))
|
|
2174
|
+
],
|
|
2175
|
+
relationships: [
|
|
2176
|
+
{
|
|
2177
|
+
spdxElementId: "SPDXRef-DOCUMENT",
|
|
2178
|
+
relationshipType: "DESCRIBES",
|
|
2179
|
+
relatedSpdxElement: rootPackageId
|
|
2180
|
+
},
|
|
2181
|
+
...dependencies.map((_dep, index) => ({
|
|
2182
|
+
spdxElementId: rootPackageId,
|
|
2183
|
+
relationshipType: "DEPENDS_ON",
|
|
2184
|
+
relatedSpdxElement: `SPDXRef-Package-${index + 1}`
|
|
2185
|
+
}))
|
|
2186
|
+
]
|
|
2187
|
+
};
|
|
2188
|
+
return {
|
|
2189
|
+
format: "spdx-json",
|
|
2190
|
+
specVersion: SPDX_VERSION,
|
|
2191
|
+
content: JSON.stringify(document, null, 2),
|
|
2192
|
+
componentCount: scanResult.dependencies.length,
|
|
2193
|
+
generatedAt: timestamp
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
|
|
2198
|
+
// src/sbom/swid.ts
|
|
2199
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2200
|
+
var SWID_SPEC_VERSION = "ISO/IEC 19770-2:2015";
|
|
2201
|
+
var SwidTagGenerator = class {
|
|
2202
|
+
format = "swid-xml";
|
|
2203
|
+
generate(scanResult, toolVersion = DEFAULT_TOOL_VERSION) {
|
|
2204
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2205
|
+
const projectName = extractProjectName(scanResult.projectPath);
|
|
2206
|
+
const tagId = `com.verimu:${sanitizeTagId(projectName)}:${DEFAULT_SWID_VERSION}:${randomUUID3()}`;
|
|
2207
|
+
const content = [
|
|
2208
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
2209
|
+
"<SoftwareIdentity",
|
|
2210
|
+
' xmlns="http://standards.iso.org/iso/19770/-2/2015/schema.xsd"',
|
|
2211
|
+
` name="${escapeXml(projectName)}"`,
|
|
2212
|
+
` tagId="${escapeXml(tagId)}"`,
|
|
2213
|
+
' tagVersion="1"',
|
|
2214
|
+
` version="${DEFAULT_SWID_VERSION}"`,
|
|
2215
|
+
' versionScheme="semver">',
|
|
2216
|
+
` <Entity name="${escapeXml(projectName)}" role="softwareCreator" />`,
|
|
2217
|
+
' <Entity name="Verimu" role="tagCreator" />',
|
|
2218
|
+
` <Meta product="${escapeXml(projectName)}" generator="${VERIMU_TOOL_NAME}" toolVersion="${toolVersion}" generated="${timestamp}" />`,
|
|
2219
|
+
" <!-- TODO: Consider adding dependency/package evidence if we need richer SWID coverage. -->",
|
|
2220
|
+
' <Link rel="describedby" href="https://verimu.com" />',
|
|
2221
|
+
"</SoftwareIdentity>"
|
|
2222
|
+
].join("\n");
|
|
2223
|
+
return {
|
|
2224
|
+
format: "swid-xml",
|
|
2225
|
+
specVersion: SWID_SPEC_VERSION,
|
|
2226
|
+
content,
|
|
2227
|
+
componentCount: 1,
|
|
2228
|
+
generatedAt: timestamp
|
|
2229
|
+
};
|
|
1079
2230
|
}
|
|
1080
2231
|
};
|
|
2232
|
+
function escapeXml(value) {
|
|
2233
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
2234
|
+
}
|
|
2235
|
+
function sanitizeTagId(value) {
|
|
2236
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// src/sbom/artifacts.ts
|
|
2240
|
+
function generateSbomArtifacts(scanResult, toolVersion = DEFAULT_TOOL_VERSION, cyclonedxVersion = "1.7") {
|
|
2241
|
+
return {
|
|
2242
|
+
cyclonedx: new CycloneDxGenerator(cyclonedxVersion).generate(scanResult, toolVersion),
|
|
2243
|
+
spdx: new SpdxJsonGenerator().generate(scanResult, toolVersion),
|
|
2244
|
+
swid: new SwidTagGenerator().generate(scanResult, toolVersion)
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
1081
2247
|
|
|
1082
2248
|
// src/cve/osv.ts
|
|
1083
2249
|
var OSV_API_BASE = "https://api.osv.dev/v1";
|
|
@@ -1346,9 +2512,13 @@ var OsvSource = class {
|
|
|
1346
2512
|
cargo: "crates.io",
|
|
1347
2513
|
maven: "Maven",
|
|
1348
2514
|
pip: "PyPI",
|
|
2515
|
+
poetry: "PyPI",
|
|
2516
|
+
uv: "PyPI",
|
|
1349
2517
|
go: "Go",
|
|
1350
2518
|
ruby: "RubyGems",
|
|
1351
|
-
composer: "Packagist"
|
|
2519
|
+
composer: "Packagist",
|
|
2520
|
+
deno: "JSR"
|
|
2521
|
+
// JSR packages (Deno registry)
|
|
1352
2522
|
};
|
|
1353
2523
|
return map[ecosystem] ?? ecosystem;
|
|
1354
2524
|
}
|
|
@@ -1455,6 +2625,9 @@ var ConsoleReporter = class {
|
|
|
1455
2625
|
lines.push("");
|
|
1456
2626
|
lines.push(` \u2713 SBOM generated (${result.sbom.format}, ${result.sbom.specVersion})`);
|
|
1457
2627
|
lines.push(` Components: ${result.sbom.componentCount}`);
|
|
2628
|
+
if (result.artifacts) {
|
|
2629
|
+
lines.push(` Also wrote: ${result.artifacts.spdx.format}, ${result.artifacts.swid.format}`);
|
|
2630
|
+
}
|
|
1458
2631
|
lines.push("");
|
|
1459
2632
|
const vulns = result.cveCheck.vulnerabilities;
|
|
1460
2633
|
if (vulns.length === 0) {
|
|
@@ -1544,18 +2717,22 @@ var VerimuApiClient = class {
|
|
|
1544
2717
|
return res.json();
|
|
1545
2718
|
}
|
|
1546
2719
|
/**
|
|
1547
|
-
* Upload a
|
|
2720
|
+
* Upload a software inventory artifact payload to a project and trigger CVE scanning.
|
|
2721
|
+
*
|
|
2722
|
+
* Backward-compatible:
|
|
2723
|
+
* - string payloads are treated as legacy raw CycloneDX JSON
|
|
2724
|
+
* - object payloads can include CycloneDX + SPDX + SWID together
|
|
1548
2725
|
*/
|
|
1549
|
-
async uploadSbom(projectId,
|
|
1550
|
-
const
|
|
2726
|
+
async uploadSbom(projectId, payload) {
|
|
2727
|
+
const body = typeof payload === "string" ? JSON.stringify(JSON.parse(payload)) : JSON.stringify(payload);
|
|
1551
2728
|
const res = await fetch(`${this.baseUrl}/api/projects/${projectId}/scan`, {
|
|
1552
2729
|
method: "POST",
|
|
1553
2730
|
headers: this.headers(),
|
|
1554
|
-
body
|
|
2731
|
+
body
|
|
1555
2732
|
});
|
|
1556
2733
|
if (!res.ok) {
|
|
1557
|
-
const
|
|
1558
|
-
throw new Error(`Verimu API: upload SBOM failed (${res.status}): ${
|
|
2734
|
+
const body2 = await res.text();
|
|
2735
|
+
throw new Error(`Verimu API: upload SBOM failed (${res.status}): ${body2}`);
|
|
1559
2736
|
}
|
|
1560
2737
|
return res.json();
|
|
1561
2738
|
}
|
|
@@ -1573,12 +2750,15 @@ var VerimuApiClient = class {
|
|
|
1573
2750
|
const map = {
|
|
1574
2751
|
npm: "npm",
|
|
1575
2752
|
pip: "pip",
|
|
2753
|
+
poetry: "poetry",
|
|
2754
|
+
uv: "uv",
|
|
1576
2755
|
maven: "maven",
|
|
1577
2756
|
nuget: "nuget",
|
|
1578
2757
|
go: "gomod",
|
|
1579
2758
|
cargo: "cargo",
|
|
1580
2759
|
ruby: "bundler",
|
|
1581
|
-
composer: "composer"
|
|
2760
|
+
composer: "composer",
|
|
2761
|
+
deno: "deno"
|
|
1582
2762
|
};
|
|
1583
2763
|
return map[eco] ?? eco;
|
|
1584
2764
|
}
|
|
@@ -1589,13 +2769,19 @@ async function scan(config) {
|
|
|
1589
2769
|
const {
|
|
1590
2770
|
projectPath,
|
|
1591
2771
|
sbomOutput = "./sbom.cdx.json",
|
|
1592
|
-
skipCveCheck = false
|
|
2772
|
+
skipCveCheck = false,
|
|
2773
|
+
cyclonedxVersion = "1.7"
|
|
1593
2774
|
} = config;
|
|
1594
2775
|
const registry = new ScannerRegistry();
|
|
1595
2776
|
const scanResult = await registry.detectAndScan(projectPath);
|
|
1596
|
-
const
|
|
1597
|
-
const sbom =
|
|
1598
|
-
|
|
2777
|
+
const artifacts = generateSbomArtifacts(scanResult, void 0, cyclonedxVersion);
|
|
2778
|
+
const sbom = artifacts.cyclonedx;
|
|
2779
|
+
const outputPaths = deriveArtifactOutputPaths(sbomOutput);
|
|
2780
|
+
await Promise.all([
|
|
2781
|
+
writeFile(outputPaths.cyclonedx, artifacts.cyclonedx.content, "utf-8"),
|
|
2782
|
+
writeFile(outputPaths.spdx, artifacts.spdx.content, "utf-8"),
|
|
2783
|
+
writeFile(outputPaths.swid, artifacts.swid.content, "utf-8")
|
|
2784
|
+
]);
|
|
1599
2785
|
let cveCheck;
|
|
1600
2786
|
if (skipCveCheck) {
|
|
1601
2787
|
cveCheck = {
|
|
@@ -1624,6 +2810,7 @@ async function scan(config) {
|
|
|
1624
2810
|
dependencyCount: scanResult.dependencies.length
|
|
1625
2811
|
},
|
|
1626
2812
|
sbom,
|
|
2813
|
+
artifacts,
|
|
1627
2814
|
cveCheck,
|
|
1628
2815
|
summary,
|
|
1629
2816
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -1648,7 +2835,7 @@ async function uploadToVerimu(report, config) {
|
|
|
1648
2835
|
ecosystem: report.project.ecosystem
|
|
1649
2836
|
});
|
|
1650
2837
|
const projectId = upsertRes.project.id;
|
|
1651
|
-
const scanRes = await client.uploadSbom(projectId, report
|
|
2838
|
+
const scanRes = await client.uploadSbom(projectId, buildUploadPayload(report));
|
|
1652
2839
|
return {
|
|
1653
2840
|
projectId,
|
|
1654
2841
|
projectCreated: upsertRes.created,
|
|
@@ -1671,6 +2858,28 @@ function shouldFailCi(report, threshold) {
|
|
|
1671
2858
|
(v) => severityOrder3[v.severity] <= thresholdLevel
|
|
1672
2859
|
);
|
|
1673
2860
|
}
|
|
2861
|
+
function deriveArtifactOutputPaths(cycloneDxOutput) {
|
|
2862
|
+
const parsed = parse(cycloneDxOutput);
|
|
2863
|
+
let baseName = parsed.name;
|
|
2864
|
+
if (parsed.ext === ".json" && baseName.endsWith(".cdx")) {
|
|
2865
|
+
baseName = baseName.slice(0, -4);
|
|
2866
|
+
}
|
|
2867
|
+
return {
|
|
2868
|
+
cyclonedx: cycloneDxOutput,
|
|
2869
|
+
spdx: join(parsed.dir, `${baseName}.spdx.json`),
|
|
2870
|
+
swid: join(parsed.dir, `${baseName}.swid.xml`)
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
function buildUploadPayload(report) {
|
|
2874
|
+
if (!report.artifacts) {
|
|
2875
|
+
return report.sbom.content;
|
|
2876
|
+
}
|
|
2877
|
+
return {
|
|
2878
|
+
cyclonedx: JSON.parse(report.artifacts.cyclonedx.content),
|
|
2879
|
+
spdx: JSON.parse(report.artifacts.spdx.content),
|
|
2880
|
+
swid: report.artifacts.swid.content
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
1674
2883
|
|
|
1675
2884
|
// src/reporters/platform.ts
|
|
1676
2885
|
function renderPlatformScan(projectPath, result) {
|
|
@@ -1696,7 +2905,7 @@ function renderPlatformScan(projectPath, result) {
|
|
|
1696
2905
|
const fix = vuln.fixedVersion ? ` \u2192 fix: ${vuln.fixedVersion}` : "";
|
|
1697
2906
|
lines.push(` ${severityBadge2(vuln.severity)} ${vuln.cveId}`);
|
|
1698
2907
|
lines.push(` ${vuln.dependencyName}@${vuln.version}${fix}`);
|
|
1699
|
-
lines.push(` ${vuln.summary.slice(0, 100)}`);
|
|
2908
|
+
lines.push(` ${(vuln.summary ?? "").slice(0, 100)}`);
|
|
1700
2909
|
lines.push("");
|
|
1701
2910
|
}
|
|
1702
2911
|
}
|
|
@@ -1812,7 +3021,8 @@ function parseArgs(argv) {
|
|
|
1812
3021
|
sbomOutput: "./sbom.cdx.json",
|
|
1813
3022
|
failOnSeverity: null,
|
|
1814
3023
|
skipCveCheck: false,
|
|
1815
|
-
skipUpload: false
|
|
3024
|
+
skipUpload: false,
|
|
3025
|
+
cyclonedxVersion: "1.7"
|
|
1816
3026
|
};
|
|
1817
3027
|
let i = 0;
|
|
1818
3028
|
while (i < args.length) {
|
|
@@ -1839,13 +3049,30 @@ function parseArgs(argv) {
|
|
|
1839
3049
|
result.skipCveCheck = true;
|
|
1840
3050
|
} else if (arg === "--skip-upload" || arg === "--offline") {
|
|
1841
3051
|
result.skipUpload = true;
|
|
3052
|
+
} else if (arg === "--cdx-version" || arg.startsWith("--cdx-version=")) {
|
|
3053
|
+
const val = arg === "--cdx-version" ? args[++i] : arg.split("=")[1];
|
|
3054
|
+
if (!val || val.startsWith("--")) {
|
|
3055
|
+
throw new Error("--cdx-version requires a value");
|
|
3056
|
+
}
|
|
3057
|
+
if (!["1.4", "1.5", "1.6", "1.7"].includes(val)) {
|
|
3058
|
+
throw new Error(`Invalid CycloneDX version: ${val}`);
|
|
3059
|
+
}
|
|
3060
|
+
result.cyclonedxVersion = val;
|
|
1842
3061
|
}
|
|
1843
3062
|
i++;
|
|
1844
3063
|
}
|
|
1845
3064
|
return result;
|
|
1846
3065
|
}
|
|
1847
3066
|
async function main() {
|
|
1848
|
-
|
|
3067
|
+
let args;
|
|
3068
|
+
try {
|
|
3069
|
+
args = parseArgs(process.argv);
|
|
3070
|
+
} catch (err) {
|
|
3071
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3072
|
+
logError(msg);
|
|
3073
|
+
log("Run 'npx verimu --help' for usage information");
|
|
3074
|
+
process.exit(2);
|
|
3075
|
+
}
|
|
1849
3076
|
if (args.command === "version") {
|
|
1850
3077
|
console.log(`verimu ${VERSION}`);
|
|
1851
3078
|
return;
|
|
@@ -1869,6 +3096,7 @@ async function main() {
|
|
|
1869
3096
|
projectPath: resolve(args.projectPath),
|
|
1870
3097
|
sbomOutput: args.sbomOutput,
|
|
1871
3098
|
skipCveCheck: args.skipCveCheck,
|
|
3099
|
+
cyclonedxVersion: args.cyclonedxVersion,
|
|
1872
3100
|
// Don't pass apiKey to scan() if --skip-upload — we'll handle upload separately for better logging
|
|
1873
3101
|
apiKey: apiKey && !args.skipUpload ? void 0 : void 0,
|
|
1874
3102
|
apiBaseUrl
|
|
@@ -1906,7 +3134,7 @@ async function main() {
|
|
|
1906
3134
|
}
|
|
1907
3135
|
}
|
|
1908
3136
|
console.log("");
|
|
1909
|
-
log("Thanks for using Verimu \u2014
|
|
3137
|
+
log("Thanks for using Verimu \u2014 helping your team with CRA readiness");
|
|
1910
3138
|
console.log("");
|
|
1911
3139
|
if (args.failOnSeverity && shouldFailCi(report, args.failOnSeverity)) {
|
|
1912
3140
|
logError(`Vulnerabilities found at or above ${args.failOnSeverity} severity`);
|
|
@@ -1926,10 +3154,11 @@ function printHelp() {
|
|
|
1926
3154
|
|
|
1927
3155
|
Options:
|
|
1928
3156
|
--path, -p <dir> Project directory to scan (default: .)
|
|
1929
|
-
--output, -o <file>
|
|
3157
|
+
--output, -o <file> CycloneDX output path (SPDX/SWID are written alongside it)
|
|
1930
3158
|
--fail-on <severity> Exit 1 if vulns at or above: CRITICAL, HIGH, MEDIUM, LOW
|
|
1931
3159
|
--skip-cve Skip CVE vulnerability checking
|
|
1932
3160
|
--skip-upload Don't sync to Verimu platform (even if API key is set)
|
|
3161
|
+
--cdx-version <ver> CycloneDX spec: 1.4, 1.5, 1.6, 1.7 (default: 1.7)
|
|
1933
3162
|
|
|
1934
3163
|
Environment:
|
|
1935
3164
|
VERIMU_API_KEY API key for Verimu platform (from app.verimu.com)
|
|
@@ -1939,6 +3168,7 @@ function printHelp() {
|
|
|
1939
3168
|
npx verimu # Quick scan
|
|
1940
3169
|
VERIMU_API_KEY=vmu_xxx npx verimu # Scan + sync to platform
|
|
1941
3170
|
npx verimu scan --fail-on HIGH # Fail CI on HIGH+ vulns
|
|
3171
|
+
npx verimu scan --cdx-version 1.5 # Specify CycloneDX version
|
|
1942
3172
|
npx verimu scan --path ./backend --output ./reports/sbom.json
|
|
1943
3173
|
|
|
1944
3174
|
Supported ecosystems:
|
|
@@ -1955,4 +3185,4 @@ main().catch((err) => {
|
|
|
1955
3185
|
console.error("Fatal:", err);
|
|
1956
3186
|
process.exit(2);
|
|
1957
3187
|
});
|
|
1958
|
-
//# sourceMappingURL=cli.
|
|
3188
|
+
//# sourceMappingURL=cli.js.map
|