verimu 0.0.19 → 0.0.20

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/dist/cli.mjs CHANGED
@@ -14178,9 +14178,10 @@ var NpmScanner = class {
14178
14178
  if (lockfile.packages) {
14179
14179
  for (const [pkgPath, pkgInfo] of Object.entries(lockfile.packages)) {
14180
14180
  if (pkgPath === "") continue;
14181
+ if (!pkgPath.startsWith("node_modules/")) continue;
14182
+ if (pkgInfo.link) continue;
14181
14183
  const name = this.extractPackageName(pkgPath);
14182
14184
  if (!name || !pkgInfo.version) continue;
14183
- if (pkgInfo.link) continue;
14184
14185
  deps.push({
14185
14186
  name,
14186
14187
  version: pkgInfo.version,
@@ -15468,8 +15469,8 @@ var PnpmScanner = class {
15468
15469
  * "/pkg@1.0.0(dep@2.0.0)" → name: "pkg", version: "1.0.0"
15469
15470
  */
15470
15471
  parsePackagePath(pkgPath, lockfileVersion) {
15471
- const path14 = pkgPath.startsWith("/") ? pkgPath.slice(1) : pkgPath;
15472
- const cleanPath = path14.split("_")[0].split("(")[0];
15472
+ const path15 = pkgPath.startsWith("/") ? pkgPath.slice(1) : pkgPath;
15473
+ const cleanPath = path15.split("_")[0].split("(")[0];
15473
15474
  if (!cleanPath) {
15474
15475
  return { name: null, version: null };
15475
15476
  }
@@ -15481,30 +15482,30 @@ var PnpmScanner = class {
15481
15482
  /**
15482
15483
  * Parses v6+ format: "express@4.18.2" or "@types/node@20.11.5"
15483
15484
  */
15484
- parseV6Format(path14) {
15485
- if (path14.startsWith("@")) {
15486
- const lastAtIndex = path14.lastIndexOf("@");
15485
+ parseV6Format(path15) {
15486
+ if (path15.startsWith("@")) {
15487
+ const lastAtIndex = path15.lastIndexOf("@");
15487
15488
  if (lastAtIndex <= 0) {
15488
15489
  return { name: null, version: null };
15489
15490
  }
15490
- const name2 = path14.substring(0, lastAtIndex);
15491
- const version2 = path14.substring(lastAtIndex + 1);
15491
+ const name2 = path15.substring(0, lastAtIndex);
15492
+ const version2 = path15.substring(lastAtIndex + 1);
15492
15493
  return { name: name2, version: version2 };
15493
15494
  }
15494
- const atIndex = path14.indexOf("@");
15495
+ const atIndex = path15.indexOf("@");
15495
15496
  if (atIndex < 0) {
15496
15497
  return { name: null, version: null };
15497
15498
  }
15498
- const name = path14.substring(0, atIndex);
15499
- const version = path14.substring(atIndex + 1);
15499
+ const name = path15.substring(0, atIndex);
15500
+ const version = path15.substring(atIndex + 1);
15500
15501
  return { name, version };
15501
15502
  }
15502
15503
  /**
15503
15504
  * Parses v5.x format: "express/4.18.2" or "@types/node/20.11.5"
15504
15505
  */
15505
- parseV5Format(path14) {
15506
- if (path14.startsWith("@")) {
15507
- const parts = path14.split("/");
15506
+ parseV5Format(path15) {
15507
+ if (path15.startsWith("@")) {
15508
+ const parts = path15.split("/");
15508
15509
  if (parts.length < 3) {
15509
15510
  return { name: null, version: null };
15510
15511
  }
@@ -15512,12 +15513,12 @@ var PnpmScanner = class {
15512
15513
  const version2 = parts[2];
15513
15514
  return { name: name2, version: version2 };
15514
15515
  }
15515
- const slashIndex = path14.indexOf("/");
15516
+ const slashIndex = path15.indexOf("/");
15516
15517
  if (slashIndex < 0) {
15517
15518
  return { name: null, version: null };
15518
15519
  }
15519
- const name = path14.substring(0, slashIndex);
15520
- const version = path14.substring(slashIndex + 1);
15520
+ const name = path15.substring(0, slashIndex);
15521
+ const version = path15.substring(slashIndex + 1);
15521
15522
  return { name, version };
15522
15523
  }
15523
15524
  /**
@@ -16825,7 +16826,8 @@ var VerimuApiClient = class {
16825
16826
  name: opts.name,
16826
16827
  ecosystem: this.mapEcosystem(opts.ecosystem),
16827
16828
  repository_url: opts.repositoryUrl ?? null,
16828
- platform: opts.platform ?? null
16829
+ platform: opts.platform ?? null,
16830
+ group_name: opts.groupName ?? null
16829
16831
  })
16830
16832
  });
16831
16833
  if (!res.ok) {
@@ -17128,34 +17130,34 @@ var JsAstAnalyzer = class {
17128
17130
  matchCandidates.push({ packageKey: packageKey3, line, matchKind, calledSymbol, confidence });
17129
17131
  };
17130
17132
  traverseFn(ast, {
17131
- ImportDeclaration: (path14) => {
17132
- const source = path14.node.source;
17133
+ ImportDeclaration: (path15) => {
17134
+ const source = path15.node.source;
17133
17135
  if (!(0, import_types.isStringLiteral)(source)) return;
17134
17136
  const pkgKey = findPackageKey(resolveImportTarget(source.value), packageMap.byName);
17135
17137
  if (!pkgKey) return;
17136
- addMatch(pkgKey, path14.node.loc?.start.line ?? 1, "import", void 0, 0.95);
17137
- for (const specifier of path14.node.specifiers) {
17138
+ addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "import", void 0, 0.95);
17139
+ for (const specifier of path15.node.specifiers) {
17138
17140
  if ((0, import_types.isImportDefaultSpecifier)(specifier) || (0, import_types.isImportNamespaceSpecifier)(specifier) || (0, import_types.isImportSpecifier)(specifier)) {
17139
17141
  symbolToPackage.set(specifier.local.name, pkgKey);
17140
17142
  }
17141
17143
  }
17142
17144
  },
17143
- ExportNamedDeclaration: (path14) => {
17144
- const source = path14.node.source;
17145
+ ExportNamedDeclaration: (path15) => {
17146
+ const source = path15.node.source;
17145
17147
  if (!source || !(0, import_types.isStringLiteral)(source)) return;
17146
17148
  const pkgKey = findPackageKey(resolveImportTarget(source.value), packageMap.byName);
17147
17149
  if (!pkgKey) return;
17148
- addMatch(pkgKey, path14.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
17150
+ addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
17149
17151
  },
17150
- ExportAllDeclaration: (path14) => {
17151
- const source = path14.node.source;
17152
+ ExportAllDeclaration: (path15) => {
17153
+ const source = path15.node.source;
17152
17154
  if (!(0, import_types.isStringLiteral)(source)) return;
17153
17155
  const pkgKey = findPackageKey(resolveImportTarget(source.value), packageMap.byName);
17154
17156
  if (!pkgKey) return;
17155
- addMatch(pkgKey, path14.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
17157
+ addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "export_from", void 0, 0.85);
17156
17158
  },
17157
- VariableDeclarator: (path14) => {
17158
- const node = path14.node;
17159
+ VariableDeclarator: (path15) => {
17160
+ const node = path15.node;
17159
17161
  if (!(0, import_types.isVariableDeclarator)(node)) return;
17160
17162
  if (!node.init || !(0, import_types.isCallExpression)(node.init)) return;
17161
17163
  if (!(0, import_types.isIdentifier)(node.init.callee, { name: "require" })) return;
@@ -17168,8 +17170,8 @@ var JsAstAnalyzer = class {
17168
17170
  symbolToPackage.set(identifier, pkgKey);
17169
17171
  }
17170
17172
  },
17171
- CallExpression: (path14) => {
17172
- const node = path14.node;
17173
+ CallExpression: (path15) => {
17174
+ const node = path15.node;
17173
17175
  if ((0, import_types.isIdentifier)(node.callee, { name: "require" })) {
17174
17176
  const firstArg = node.arguments[0];
17175
17177
  if (firstArg && (0, import_types.isStringLiteral)(firstArg)) {
@@ -17190,12 +17192,12 @@ var JsAstAnalyzer = class {
17190
17192
  0.75
17191
17193
  );
17192
17194
  },
17193
- ImportExpression: (path14) => {
17194
- const source = path14.node.source;
17195
+ ImportExpression: (path15) => {
17196
+ const source = path15.node.source;
17195
17197
  if (!(0, import_types.isStringLiteral)(source)) return;
17196
17198
  const pkgKey = findPackageKey(resolveImportTarget(source.value), packageMap.byName);
17197
17199
  if (!pkgKey) return;
17198
- addMatch(pkgKey, path14.node.loc?.start.line ?? 1, "dynamic_import", void 0, 0.9);
17200
+ addMatch(pkgKey, path15.node.loc?.start.line ?? 1, "dynamic_import", void 0, 0.9);
17199
17201
  }
17200
17202
  });
17201
17203
  for (const candidate of matchCandidates) {
@@ -18743,7 +18745,8 @@ async function uploadToVerimu(report, config) {
18743
18745
  const projectName = basename(config.projectPath);
18744
18746
  const upsertRes = await client.upsertProject({
18745
18747
  name: projectName,
18746
- ecosystem: report.project.ecosystem
18748
+ ecosystem: report.project.ecosystem,
18749
+ groupName: config.groupName
18747
18750
  });
18748
18751
  const projectId = upsertRes.project.id;
18749
18752
  const scanRes = await client.uploadSbom(projectId, buildUploadPayload(report));
@@ -18945,6 +18948,325 @@ function severityBadge2(severity) {
18945
18948
  return badges[severity] ?? "[???] ";
18946
18949
  }
18947
18950
 
18951
+ // src/discovery/lockfile-discovery.ts
18952
+ import { readdir as readdir3, stat } from "fs/promises";
18953
+ import { existsSync as existsSync14 } from "fs";
18954
+ import path14 from "path";
18955
+ var LOCKFILE_MAP = {
18956
+ "pnpm-lock.yaml": { ecosystem: "npm", scanner: "pnpm" },
18957
+ "yarn.lock": { ecosystem: "npm", scanner: "yarn" },
18958
+ "package-lock.json": { ecosystem: "npm", scanner: "npm" },
18959
+ "deno.lock": { ecosystem: "npm", scanner: "deno" },
18960
+ "Cargo.lock": { ecosystem: "cargo", scanner: "cargo" },
18961
+ "go.sum": { ecosystem: "go", scanner: "go" },
18962
+ "Gemfile.lock": { ecosystem: "ruby", scanner: "ruby" },
18963
+ "composer.lock": { ecosystem: "composer", scanner: "composer" },
18964
+ "packages.lock.json": { ecosystem: "nuget", scanner: "nuget" },
18965
+ "poetry.lock": { ecosystem: "poetry", scanner: "poetry" },
18966
+ "uv.lock": { ecosystem: "uv", scanner: "uv" },
18967
+ "Pipfile.lock": { ecosystem: "pip", scanner: "pip" },
18968
+ "requirements.txt": { ecosystem: "pip", scanner: "pip" },
18969
+ "pom.xml": { ecosystem: "maven", scanner: "maven" }
18970
+ };
18971
+ var DEFAULT_EXCLUDES = [
18972
+ "node_modules",
18973
+ ".git",
18974
+ ".hg",
18975
+ ".svn",
18976
+ "vendor",
18977
+ "target",
18978
+ "dist",
18979
+ "build",
18980
+ ".next",
18981
+ ".nuxt",
18982
+ "__pycache__",
18983
+ ".venv",
18984
+ "venv",
18985
+ ".tox",
18986
+ "coverage",
18987
+ ".cache",
18988
+ "out",
18989
+ ".output"
18990
+ ];
18991
+ var LockfileDiscovery = class {
18992
+ lockfileNames = Object.keys(LOCKFILE_MAP);
18993
+ /**
18994
+ * Discovers all lockfiles recursively starting from rootPath.
18995
+ * Returns a list of projects that can be scanned.
18996
+ */
18997
+ async discover(options) {
18998
+ const { rootPath, exclude, maxDepth } = options;
18999
+ const absoluteRoot = path14.resolve(rootPath);
19000
+ const discovered = [];
19001
+ const excludePatterns = this.buildExcludePatterns(exclude);
19002
+ await this.walkDirectory(absoluteRoot, absoluteRoot, discovered, {
19003
+ excludePatterns,
19004
+ maxDepth: maxDepth ?? Infinity,
19005
+ //not set, infinite for now, can add as a cli-flag later if needed
19006
+ currentDepth: 0
19007
+ });
19008
+ discovered.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
19009
+ return discovered;
19010
+ }
19011
+ /**
19012
+ * Recursively walks directories looking for lockfiles.
19013
+ * Stops descending into a directory once a lockfile is found
19014
+ * (to avoid scanning nested node_modules, etc.)
19015
+ */
19016
+ async walkDirectory(currentPath, rootPath, results, options) {
19017
+ const { excludePatterns, maxDepth, currentDepth } = options;
19018
+ if (currentDepth > maxDepth) return;
19019
+ const relativePath = path14.relative(rootPath, currentPath) || ".";
19020
+ if (this.matchesAnyPattern(relativePath, excludePatterns)) {
19021
+ return;
19022
+ }
19023
+ const foundLockfile = await this.findLockfileInDir(currentPath);
19024
+ if (foundLockfile) {
19025
+ results.push({
19026
+ projectPath: currentPath,
19027
+ relativePath,
19028
+ lockfile: {
19029
+ name: foundLockfile.name,
19030
+ path: path14.join(currentPath, foundLockfile.name)
19031
+ },
19032
+ ecosystem: foundLockfile.ecosystem,
19033
+ scannerType: foundLockfile.scanner
19034
+ });
19035
+ return;
19036
+ }
19037
+ let entries;
19038
+ try {
19039
+ entries = await readdir3(currentPath);
19040
+ } catch {
19041
+ return;
19042
+ }
19043
+ for (const entry of entries) {
19044
+ const entryPath = path14.join(currentPath, entry);
19045
+ try {
19046
+ const stats = await stat(entryPath);
19047
+ if (stats.isDirectory()) {
19048
+ if (this.isDefaultExclude(entry)) continue;
19049
+ await this.walkDirectory(entryPath, rootPath, results, {
19050
+ ...options,
19051
+ currentDepth: currentDepth + 1
19052
+ });
19053
+ }
19054
+ } catch {
19055
+ }
19056
+ }
19057
+ }
19058
+ /**
19059
+ * Looks for a lockfile in the given directory.
19060
+ * Returns the first match in priority order.
19061
+ */
19062
+ async findLockfileInDir(dirPath) {
19063
+ const priorityOrder = [
19064
+ "pnpm-lock.yaml",
19065
+ "yarn.lock",
19066
+ "package-lock.json",
19067
+ "deno.lock",
19068
+ "Cargo.lock",
19069
+ "go.sum",
19070
+ "poetry.lock",
19071
+ "uv.lock",
19072
+ "Pipfile.lock",
19073
+ "composer.lock",
19074
+ "Gemfile.lock",
19075
+ "packages.lock.json",
19076
+ "pom.xml",
19077
+ "requirements.txt"
19078
+ ];
19079
+ for (const lockfileName of priorityOrder) {
19080
+ const lockfilePath = path14.join(dirPath, lockfileName);
19081
+ if (existsSync14(lockfilePath)) {
19082
+ const info = LOCKFILE_MAP[lockfileName];
19083
+ return { name: lockfileName, ...info };
19084
+ }
19085
+ }
19086
+ return null;
19087
+ }
19088
+ /**
19089
+ * Builds exclude patterns from user input + defaults.
19090
+ */
19091
+ buildExcludePatterns(userExcludes) {
19092
+ const patterns = [];
19093
+ for (const dir of DEFAULT_EXCLUDES) {
19094
+ patterns.push(`**/${dir}`);
19095
+ patterns.push(`**/${dir}/**`);
19096
+ }
19097
+ if (userExcludes) {
19098
+ patterns.push(...userExcludes);
19099
+ }
19100
+ return patterns;
19101
+ }
19102
+ /**
19103
+ * Quick check if a directory name is in the default exclude list.
19104
+ */
19105
+ isDefaultExclude(dirName) {
19106
+ return DEFAULT_EXCLUDES.includes(dirName);
19107
+ }
19108
+ /**
19109
+ * Checks if a path matches any of the given glob patterns.
19110
+ * Uses simple glob matching (supports *, **, ?).
19111
+ */
19112
+ matchesAnyPattern(relativePath, patterns) {
19113
+ const normalized = relativePath.replace(/\\/g, "/");
19114
+ for (const pattern of patterns) {
19115
+ if (this.matchGlob(normalized, pattern)) {
19116
+ return true;
19117
+ }
19118
+ }
19119
+ return false;
19120
+ }
19121
+ /**
19122
+ * Simple glob matcher supporting:
19123
+ * - * (matches any characters except /)
19124
+ * - ** (matches any characters including /)
19125
+ * - ? (matches single character)
19126
+ */
19127
+ matchGlob(str, pattern) {
19128
+ let regex = pattern.replace(/\\/g, "/").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/{{GLOBSTAR}}/g, ".*").replace(/\?/g, ".");
19129
+ regex = `^${regex}$`;
19130
+ return new RegExp(regex).test(str);
19131
+ }
19132
+ };
19133
+
19134
+ // src/discovery/orchestrator.ts
19135
+ import { basename as basename2 } from "path";
19136
+ var MultiProjectOrchestrator = class {
19137
+ discovery = new LockfileDiscovery();
19138
+ reporter = new ConsoleReporter();
19139
+ /**
19140
+ * Gets a display name for a project (uses directory name instead of "." for root)
19141
+ */
19142
+ getDisplayName(project, rootPath) {
19143
+ if (project.relativePath === ".") {
19144
+ return basename2(rootPath);
19145
+ }
19146
+ return project.relativePath;
19147
+ }
19148
+ /**
19149
+ * Discovers and scans all projects in a directory tree.
19150
+ */
19151
+ async scanAll(config) {
19152
+ const startTime = Date.now();
19153
+ const apiKey = config.apiKey;
19154
+ const apiBaseUrl = config.apiBaseUrl;
19155
+ console.log(`
19156
+ Discovering projects in ${config.projectPath}...`);
19157
+ const projects = await this.discovery.discover({
19158
+ rootPath: config.projectPath,
19159
+ exclude: config.exclude
19160
+ });
19161
+ if (projects.length === 0) {
19162
+ console.log("No projects with lockfiles found.");
19163
+ return {
19164
+ totalDiscovered: 0,
19165
+ successful: [],
19166
+ failed: [],
19167
+ skipped: [],
19168
+ durationMs: Date.now() - startTime
19169
+ };
19170
+ }
19171
+ const isSingleProject = projects.length === 1;
19172
+ const groupName = isSingleProject ? config.groupName : config.groupName || basename2(config.projectPath);
19173
+ console.log(`Found ${projects.length} project(s):
19174
+ `);
19175
+ for (const p of projects) {
19176
+ const displayName = this.getDisplayName(p, config.projectPath);
19177
+ console.log(` \u2022 ${displayName} (${p.scannerType})`);
19178
+ }
19179
+ if (!isSingleProject) {
19180
+ if (!config.groupName) {
19181
+ console.log(`
19182
+ \u2139 Auto-grouping projects as: "${groupName}"`);
19183
+ console.log(" (Use --group to specify a custom group name)\n");
19184
+ } else {
19185
+ console.log(`
19186
+ \u2139 Grouping projects as: "${groupName}"
19187
+ `);
19188
+ }
19189
+ } else {
19190
+ console.log("");
19191
+ }
19192
+ const successful = [];
19193
+ const failed = [];
19194
+ for (let i = 0; i < projects.length; i++) {
19195
+ const project = projects[i];
19196
+ const displayName = this.getDisplayName(project, config.projectPath);
19197
+ console.log("\u2500".repeat(60));
19198
+ console.log(`[${i + 1}/${projects.length}] Scanning: ${displayName}`);
19199
+ console.log("\u2500".repeat(60));
19200
+ console.log("");
19201
+ try {
19202
+ const sbomOutput = this.deriveSbomPath(project, config.sbomOutput);
19203
+ const report = await scan({
19204
+ ...config,
19205
+ projectPath: project.projectPath,
19206
+ sbomOutput,
19207
+ groupName,
19208
+ // Use auto-derived or user-provided group name
19209
+ apiKey: void 0
19210
+ // Don't upload in scan, do it separately
19211
+ });
19212
+ console.log(this.reporter.report(report));
19213
+ if (apiKey) {
19214
+ console.log("");
19215
+ console.log(` Syncing ${displayName} to Verimu platform...`);
19216
+ try {
19217
+ const uploadConfig = {
19218
+ ...config,
19219
+ projectPath: project.projectPath,
19220
+ groupName,
19221
+ // Use auto-derived or user-provided group name
19222
+ apiKey,
19223
+ apiBaseUrl
19224
+ };
19225
+ const uploadResult = await uploadToVerimu(report, uploadConfig);
19226
+ if (uploadResult.projectCreated) {
19227
+ console.log(` \u2713 Project created: ${displayName}`);
19228
+ }
19229
+ console.log(` \u2713 ${uploadResult.totalDependencies} dependencies tracked`);
19230
+ console.log(renderPlatformScan(displayName, uploadResult));
19231
+ console.log(` \u2713 Dashboard: ${uploadResult.dashboardUrl}`);
19232
+ } catch (err) {
19233
+ const msg = err instanceof Error ? err.message : String(err);
19234
+ console.log(` \u26A0 Platform sync failed: ${msg}`);
19235
+ console.log(" Your SBOM was still generated locally. You can upload it manually.");
19236
+ }
19237
+ }
19238
+ console.log("");
19239
+ successful.push({ project, report });
19240
+ } catch (error) {
19241
+ const errorMsg = error instanceof Error ? error.message : String(error);
19242
+ console.log(` \u2717 Failed: ${errorMsg}`);
19243
+ console.log("");
19244
+ failed.push({ project, error: errorMsg });
19245
+ throw error;
19246
+ }
19247
+ }
19248
+ return {
19249
+ totalDiscovered: projects.length,
19250
+ successful,
19251
+ failed,
19252
+ skipped: [],
19253
+ durationMs: Date.now() - startTime
19254
+ };
19255
+ }
19256
+ /**
19257
+ * Derives SBOM output path for a project.
19258
+ * Places SBOMs in project directories by default.
19259
+ */
19260
+ deriveSbomPath(project, configOutput) {
19261
+ if (configOutput) {
19262
+ const base = configOutput.replace(/\.cdx\.json$/, "");
19263
+ const sanitized = project.relativePath.replace(/[/\\]/g, "-");
19264
+ return `${base}.${sanitized}.cdx.json`;
19265
+ }
19266
+ return `${project.projectPath}/sbom.cdx.json`;
19267
+ }
19268
+ };
19269
+
18948
19270
  // src/cli.ts
18949
19271
  var require2 = createRequire(import.meta.url);
18950
19272
  var pkg = require2("../package.json");
@@ -18977,7 +19299,11 @@ function parseArgs(argv) {
18977
19299
  skipCveCheck: false,
18978
19300
  skipUpload: false,
18979
19301
  cyclonedxVersion: "1.7",
18980
- contextLines: void 0
19302
+ contextLines: void 0,
19303
+ groupName: void 0,
19304
+ recursive: true,
19305
+ // Recursive by default
19306
+ exclude: void 0
18981
19307
  };
18982
19308
  let i = 0;
18983
19309
  while (i < args.length) {
@@ -19023,6 +19349,20 @@ function parseArgs(argv) {
19023
19349
  throw new Error(`Invalid CycloneDX version: ${val}`);
19024
19350
  }
19025
19351
  result.cyclonedxVersion = val;
19352
+ } else if (arg === "--group-name" || arg.startsWith("--group-name=")) {
19353
+ const val = arg.startsWith("--group-name=") ? arg.split("=")[1] : args[++i];
19354
+ if (!val || val.startsWith("--")) {
19355
+ throw new Error("--group-name requires a value");
19356
+ }
19357
+ result.groupName = val;
19358
+ } else if (arg === "--no-recursive" || arg === "--not-recursive") {
19359
+ result.recursive = false;
19360
+ } else if (arg === "--exclude") {
19361
+ const val = args[++i];
19362
+ if (!val || val.startsWith("--")) {
19363
+ throw new Error("--exclude requires a comma-separated list of patterns");
19364
+ }
19365
+ result.exclude = val.split(",").map((p) => p.trim());
19026
19366
  }
19027
19367
  i++;
19028
19368
  }
@@ -19065,8 +19405,32 @@ async function main() {
19065
19405
  // Don't pass apiKey to scan() if --skip-upload — we'll handle upload separately for better logging
19066
19406
  apiKey: apiKey && !args.skipUpload ? void 0 : void 0,
19067
19407
  apiBaseUrl,
19068
- numContextLines: args.contextLines
19408
+ numContextLines: args.contextLines,
19409
+ groupName: args.groupName
19069
19410
  };
19411
+ if (args.recursive) {
19412
+ const orchestrator = new MultiProjectOrchestrator();
19413
+ let result;
19414
+ try {
19415
+ result = await orchestrator.scanAll({
19416
+ ...config,
19417
+ recursive: true,
19418
+ exclude: args.exclude,
19419
+ // Pass API key for platform uploads
19420
+ apiKey: apiKey && !args.skipUpload ? apiKey : void 0,
19421
+ apiBaseUrl
19422
+ });
19423
+ } catch (err) {
19424
+ const msg = err instanceof Error ? err.message : String(err);
19425
+ logError(msg);
19426
+ process.exit(2);
19427
+ }
19428
+ printMultiProjectSummary(result);
19429
+ if (result.failed.length > 0) {
19430
+ process.exit(1);
19431
+ }
19432
+ return;
19433
+ }
19070
19434
  let report;
19071
19435
  try {
19072
19436
  report = await scan(config);
@@ -19107,12 +19471,50 @@ async function main() {
19107
19471
  process.exit(1);
19108
19472
  }
19109
19473
  }
19474
+ function printMultiProjectSummary(result) {
19475
+ console.log("\n" + "\u2500".repeat(60));
19476
+ console.log("Multi-Project Scan Summary");
19477
+ console.log("\u2500".repeat(60));
19478
+ console.log(`
19479
+ Projects discovered: ${result.totalDiscovered}`);
19480
+ console.log(` \u2713 Successful: ${result.successful.length}`);
19481
+ console.log(` \u2717 Failed: ${result.failed.length}`);
19482
+ if (result.successful.length > 0) {
19483
+ const totalDeps = result.successful.reduce(
19484
+ (sum, r) => sum + r.report.summary.totalDependencies,
19485
+ 0
19486
+ );
19487
+ const totalVulns = result.successful.reduce(
19488
+ (sum, r) => sum + r.report.summary.totalVulnerabilities,
19489
+ 0
19490
+ );
19491
+ console.log(`
19492
+ Total dependencies: ${totalDeps}`);
19493
+ console.log(`Total vulnerabilities: ${totalVulns}`);
19494
+ const critical = result.successful.reduce((sum, r) => sum + r.report.summary.critical, 0);
19495
+ const high = result.successful.reduce((sum, r) => sum + r.report.summary.high, 0);
19496
+ const medium = result.successful.reduce((sum, r) => sum + r.report.summary.medium, 0);
19497
+ const low = result.successful.reduce((sum, r) => sum + r.report.summary.low, 0);
19498
+ if (totalVulns > 0) {
19499
+ console.log(` Critical: ${critical}, High: ${high}, Medium: ${medium}, Low: ${low}`);
19500
+ }
19501
+ }
19502
+ if (result.failed.length > 0) {
19503
+ console.log("\nFailed projects:");
19504
+ for (const f of result.failed) {
19505
+ console.log(` \u2022 ${f.project.relativePath}: ${f.error}`);
19506
+ }
19507
+ }
19508
+ console.log(`
19509
+ Completed in ${(result.durationMs / 1e3).toFixed(2)}s`);
19510
+ console.log("");
19511
+ }
19110
19512
  function printHelp() {
19111
19513
  console.log(`
19112
19514
  Verimu \u2014 CRA Compliance Scanner
19113
19515
 
19114
19516
  Usage:
19115
- verimu Scan current directory
19517
+ verimu Scan current directory (recursively)
19116
19518
  verimu scan [options] Full scan (SBOM + CVE check)
19117
19519
  verimu generate-sbom [options] Generate SBOM only (no CVE check)
19118
19520
  verimu help Show this help
@@ -19121,23 +19523,35 @@ function printHelp() {
19121
19523
  Options:
19122
19524
  --path, -p <dir> Project directory to scan (default: .)
19123
19525
  --output, -o <file> CycloneDX output path (SPDX/SWID are written alongside it)
19526
+ --group-name <name> Group name for organizing related projects in dashboard
19124
19527
  --fail-on <severity> Exit 1 if vulns at or above: CRITICAL, HIGH, MEDIUM, LOW
19125
19528
  --skip-cve Skip CVE vulnerability checking
19126
19529
  --skip-upload Don't sync to Verimu platform (even if API key is set)
19127
19530
  --context-lines <n> Snippet context lines around matches (default: 4, clamped to 0..20)
19128
19531
  --cdx-version <ver> CycloneDX spec: 1.4, 1.5, 1.6, 1.7 (default: 1.7)
19129
19532
 
19533
+ Project Discovery:
19534
+ --no-recursive Disable recursive discovery (scan only root directory)
19535
+ --exclude <patterns> Exclude paths matching patterns (comma-separated globs)
19536
+
19537
+ Note: Verimu automatically discovers all projects recursively by default.
19538
+ For monorepos with multiple lockfiles, projects are auto-grouped by directory name.
19539
+ Single lockfile projects are treated normally without grouping.
19540
+
19130
19541
  Environment:
19131
19542
  VERIMU_API_KEY API key for Verimu platform (from app.verimu.com)
19132
19543
  VERIMU_API_URL Custom API URL (default: https://api.verimu.com)
19133
19544
 
19134
19545
  Examples:
19135
- npx verimu # Quick scan
19546
+ npx verimu # Scan all projects recursively
19136
19547
  VERIMU_API_KEY=vmu_xxx npx verimu # Scan + sync to platform
19137
19548
  npx verimu scan --fail-on HIGH # Fail CI on HIGH+ vulns
19549
+ npx verimu scan --group-name my-app # Group projects with custom name
19138
19550
  npx verimu scan --context-lines 8 # Wider context around usage snippets
19139
19551
  npx verimu scan --cdx-version 1.5 # Specify CycloneDX version
19140
19552
  npx verimu scan --path ./backend --output ./reports/sbom.json
19553
+ npx verimu scan --no-recursive # Scan only root directory
19554
+ npx verimu scan --exclude "legacy/*" # Exclude legacy projects
19141
19555
 
19142
19556
  Supported ecosystems:
19143
19557
  npm (package-lock.json) pip (requirements.txt)