@quantracode/vibecheck 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -101,6 +101,15 @@ var CategorySchema = z4.enum([
101
101
  "uploads",
102
102
  "hallucinations",
103
103
  "abuse",
104
+ // Phase 4 categories
105
+ "correlation",
106
+ // Cross-pack correlation findings
107
+ "authorization",
108
+ // Role/ownership/privilege checks
109
+ "lifecycle",
110
+ // Create/update/delete symmetry
111
+ "supply-chain",
112
+ // Package.json/lockfile analysis
104
113
  "other"
105
114
  ]);
106
115
  var AbuseRiskSchema = z4.enum([
@@ -143,6 +152,40 @@ var AbuseClassificationSchema = z4.object({
143
152
  /** Heuristic confidence */
144
153
  confidence: z4.number().min(0).max(1)
145
154
  });
155
+ var CorrelationPatternSchema = z4.enum([
156
+ // PR #1 patterns
157
+ "auth_without_validation",
158
+ // Route has auth but no input validation
159
+ "middleware_bypass",
160
+ // Middleware exists but route not covered
161
+ "secret_in_unprotected",
162
+ // Secret used in unprotected endpoint
163
+ "validation_without_auth",
164
+ // Input validated but no auth on state-changing
165
+ "create_update_asymmetry",
166
+ // CREATE validates, UPDATE doesn't
167
+ "ownership_without_auth",
168
+ // Ownership check but no auth check
169
+ // PR #2 patterns
170
+ "middleware_upload_gap",
171
+ // Upload endpoint not covered by middleware
172
+ "network_auth_leak",
173
+ // Token forwarded to SSRF-prone fetch
174
+ "privacy_auth_context",
175
+ // Sensitive logging in authenticated context
176
+ "crypto_auth_gate",
177
+ // jwt.decode() on auth gate path
178
+ "hallucination_coverage_gap"
179
+ // Comment claims protection, proof trace disagrees
180
+ ]);
181
+ var CorrelationDataSchema = z4.object({
182
+ /** IDs of related findings that form this correlation */
183
+ relatedFindingIds: z4.array(z4.string()),
184
+ /** The correlation pattern detected */
185
+ pattern: CorrelationPatternSchema,
186
+ /** Brief explanation of the correlation */
187
+ explanation: z4.string()
188
+ });
146
189
  var RemediationSchema = z4.object({
147
190
  recommendedFix: z4.string(),
148
191
  patch: z4.string().optional()
@@ -168,150 +211,283 @@ var FindingSchema = z4.object({
168
211
  links: ReferenceLinksSchema.optional(),
169
212
  fingerprint: z4.string(),
170
213
  /** Optional compute abuse classification */
171
- abuseClassification: AbuseClassificationSchema.optional()
214
+ abuseClassification: AbuseClassificationSchema.optional(),
215
+ /** Optional correlation data for cross-pack findings (Phase 4) */
216
+ correlationData: CorrelationDataSchema.optional(),
217
+ /** References to related finding IDs/fingerprints (Phase 4) */
218
+ relatedFindings: z4.array(z4.string()).optional()
172
219
  });
173
220
 
174
- // ../schema/dist/schemas/artifact.js
221
+ // ../schema/dist/schemas/supply-chain.js
175
222
  import { z as z5 } from "zod";
176
- var ARTIFACT_VERSION = "0.2";
177
- var SUPPORTED_VERSIONS = ["0.1", "0.2"];
178
- var ArtifactVersionSchema = z5.enum(SUPPORTED_VERSIONS);
179
- var ToolInfoSchema = z5.object({
223
+ var DependencyRiskIndicatorSchema = z5.enum([
224
+ "unpinned_version",
225
+ // Uses ^ or ~ instead of exact version
226
+ "git_dependency",
227
+ // Uses git:// or github: URL
228
+ "postinstall_script",
229
+ // Has postinstall lifecycle hook
230
+ "preinstall_script",
231
+ // Has preinstall lifecycle hook
232
+ "file_dependency",
233
+ // Uses file: protocol
234
+ "link_dependency",
235
+ // Uses link: protocol
236
+ "deprecated_package"
237
+ // Package marked as deprecated in package.json
238
+ ]);
239
+ var DependencyInfoSchema = z5.object({
240
+ /** Package name */
180
241
  name: z5.string(),
181
- version: z5.string()
242
+ /** Declared version or range */
243
+ declaredVersion: z5.string(),
244
+ /** Resolved version from lockfile (if available) */
245
+ resolvedVersion: z5.string().optional(),
246
+ /** Whether this is a devDependency */
247
+ isDev: z5.boolean(),
248
+ /** Risk indicators found */
249
+ riskIndicators: z5.array(DependencyRiskIndicatorSchema),
250
+ /** Has lifecycle scripts */
251
+ hasLifecycleScripts: z5.boolean()
182
252
  });
183
- var GitInfoSchema = z5.object({
184
- branch: z5.string().optional(),
185
- commit: z5.string().optional(),
186
- remoteUrl: z5.string().optional(),
187
- isDirty: z5.boolean().optional()
253
+ var LockfileInfoSchema = z5.object({
254
+ /** Lockfile type detected */
255
+ type: z5.enum(["npm", "pnpm", "yarn", "yarn-berry", "bun", "none"]),
256
+ /** Lockfile path relative to repo root */
257
+ path: z5.string().optional(),
258
+ /** Whether lockfile exists */
259
+ exists: z5.boolean(),
260
+ /** SHA-256 hash of lockfile content for integrity tracking */
261
+ contentHash: z5.string().optional(),
262
+ /** Total number of resolved dependencies */
263
+ totalDependencies: z5.number().int().nonnegative().optional()
188
264
  });
189
- var RepoInfoSchema = z5.object({
190
- name: z5.string(),
191
- rootPathHash: z5.string(),
265
+ var PackageJsonInfoSchema = z5.object({
266
+ /** Path relative to repo root */
267
+ path: z5.string(),
268
+ /** Package name */
269
+ name: z5.string().optional(),
270
+ /** Package version */
271
+ version: z5.string().optional(),
272
+ /** Number of production dependencies */
273
+ dependencyCount: z5.number().int().nonnegative(),
274
+ /** Number of dev dependencies */
275
+ devDependencyCount: z5.number().int().nonnegative(),
276
+ /** Whether engines field is specified */
277
+ hasEngines: z5.boolean(),
278
+ /** Whether package-lock is disabled */
279
+ packageLockDisabled: z5.boolean()
280
+ });
281
+ var SupplyChainInfoSchema = z5.object({
282
+ /** Analysis timestamp */
283
+ analyzedAt: z5.string().datetime(),
284
+ /** Package.json info */
285
+ packageJson: PackageJsonInfoSchema,
286
+ /** Lockfile info */
287
+ lockfile: LockfileInfoSchema,
288
+ /** Dependencies with risk indicators (limited to those with issues) */
289
+ riskyDependencies: z5.array(DependencyInfoSchema),
290
+ /** Summary counts */
291
+ summary: z5.object({
292
+ totalDependencies: z5.number().int().nonnegative(),
293
+ unpinnedCount: z5.number().int().nonnegative(),
294
+ gitDependencyCount: z5.number().int().nonnegative(),
295
+ lifecycleScriptCount: z5.number().int().nonnegative()
296
+ })
297
+ });
298
+
299
+ // ../schema/dist/schemas/artifact.js
300
+ import { z as z6 } from "zod";
301
+ var ARTIFACT_VERSION = "0.3";
302
+ var SUPPORTED_VERSIONS = ["0.1", "0.2", "0.3"];
303
+ var ArtifactVersionSchema = z6.enum(SUPPORTED_VERSIONS);
304
+ var ToolInfoSchema = z6.object({
305
+ name: z6.string(),
306
+ version: z6.string()
307
+ });
308
+ var GitInfoSchema = z6.object({
309
+ branch: z6.string().optional(),
310
+ commit: z6.string().optional(),
311
+ remoteUrl: z6.string().optional(),
312
+ isDirty: z6.boolean().optional()
313
+ });
314
+ var RepoInfoSchema = z6.object({
315
+ name: z6.string(),
316
+ rootPathHash: z6.string(),
192
317
  git: GitInfoSchema.optional()
193
318
  });
194
- var SeverityCountsSchema = z5.object({
195
- critical: z5.number().int().nonnegative(),
196
- high: z5.number().int().nonnegative(),
197
- medium: z5.number().int().nonnegative(),
198
- low: z5.number().int().nonnegative(),
199
- info: z5.number().int().nonnegative()
319
+ var SeverityCountsSchema = z6.object({
320
+ critical: z6.number().int().nonnegative(),
321
+ high: z6.number().int().nonnegative(),
322
+ medium: z6.number().int().nonnegative(),
323
+ low: z6.number().int().nonnegative(),
324
+ info: z6.number().int().nonnegative()
200
325
  });
201
- var CategoryCountsSchema = z5.object({
202
- auth: z5.number().int().nonnegative(),
203
- validation: z5.number().int().nonnegative(),
204
- middleware: z5.number().int().nonnegative(),
205
- secrets: z5.number().int().nonnegative(),
206
- injection: z5.number().int().nonnegative(),
207
- privacy: z5.number().int().nonnegative(),
208
- config: z5.number().int().nonnegative(),
209
- network: z5.number().int().nonnegative(),
210
- crypto: z5.number().int().nonnegative(),
211
- uploads: z5.number().int().nonnegative(),
212
- hallucinations: z5.number().int().nonnegative(),
213
- abuse: z5.number().int().nonnegative(),
214
- other: z5.number().int().nonnegative()
326
+ var CategoryCountsSchema = z6.object({
327
+ auth: z6.number().int().nonnegative(),
328
+ validation: z6.number().int().nonnegative(),
329
+ middleware: z6.number().int().nonnegative(),
330
+ secrets: z6.number().int().nonnegative(),
331
+ injection: z6.number().int().nonnegative(),
332
+ privacy: z6.number().int().nonnegative(),
333
+ config: z6.number().int().nonnegative(),
334
+ network: z6.number().int().nonnegative(),
335
+ crypto: z6.number().int().nonnegative(),
336
+ uploads: z6.number().int().nonnegative(),
337
+ hallucinations: z6.number().int().nonnegative(),
338
+ abuse: z6.number().int().nonnegative(),
339
+ // Phase 4 categories
340
+ correlation: z6.number().int().nonnegative(),
341
+ authorization: z6.number().int().nonnegative(),
342
+ lifecycle: z6.number().int().nonnegative(),
343
+ "supply-chain": z6.number().int().nonnegative(),
344
+ other: z6.number().int().nonnegative()
215
345
  });
216
- var SummarySchema = z5.object({
217
- totalFindings: z5.number().int().nonnegative(),
346
+ var SummarySchema = z6.object({
347
+ totalFindings: z6.number().int().nonnegative(),
218
348
  bySeverity: SeverityCountsSchema,
219
349
  byCategory: CategoryCountsSchema
220
350
  });
221
- var RouteEntrySchema = z5.object({
222
- routeId: z5.string(),
223
- method: z5.string(),
224
- path: z5.string(),
225
- handler: z5.string().optional(),
226
- file: z5.string(),
227
- startLine: z5.number().int().positive().optional(),
228
- endLine: z5.number().int().positive().optional(),
229
- handlerSymbol: z5.string().optional(),
351
+ var RouteEntrySchema = z6.object({
352
+ routeId: z6.string(),
353
+ method: z6.string(),
354
+ path: z6.string(),
355
+ handler: z6.string().optional(),
356
+ file: z6.string(),
357
+ startLine: z6.number().int().positive().optional(),
358
+ endLine: z6.number().int().positive().optional(),
359
+ handlerSymbol: z6.string().optional(),
230
360
  // Deprecated: for backward compat with 0.1
231
- line: z5.number().int().positive().optional(),
232
- middleware: z5.array(z5.string()).optional()
361
+ line: z6.number().int().positive().optional(),
362
+ middleware: z6.array(z6.string()).optional()
233
363
  });
234
- var MiddlewareCoverageEntrySchema = z5.object({
235
- routeId: z5.string(),
236
- covered: z5.boolean(),
237
- reason: z5.string().optional()
364
+ var MiddlewareCoverageEntrySchema = z6.object({
365
+ routeId: z6.string(),
366
+ covered: z6.boolean(),
367
+ reason: z6.string().optional()
238
368
  });
239
- var MiddlewareEntrySchema = z5.object({
240
- name: z5.string().optional(),
241
- file: z5.string(),
242
- line: z5.number().int().positive().optional(),
243
- matcher: z5.array(z5.string()).optional(),
244
- appliesTo: z5.array(z5.string()).optional()
369
+ var MiddlewareEntrySchema = z6.object({
370
+ name: z6.string().optional(),
371
+ file: z6.string(),
372
+ line: z6.number().int().positive().optional(),
373
+ matcher: z6.array(z6.string()).optional(),
374
+ appliesTo: z6.array(z6.string()).optional()
245
375
  });
246
- var MiddlewareMapSchema = z5.object({
247
- middlewareFile: z5.string().optional(),
248
- matcher: z5.array(z5.string()),
249
- coverage: z5.array(MiddlewareCoverageEntrySchema)
376
+ var MiddlewareMapSchema = z6.object({
377
+ middlewareFile: z6.string().optional(),
378
+ matcher: z6.array(z6.string()),
379
+ coverage: z6.array(MiddlewareCoverageEntrySchema)
250
380
  });
251
- var IntentEntrySchema = z5.object({
252
- intentId: z5.string(),
381
+ var IntentEntrySchema = z6.object({
382
+ intentId: z6.string(),
253
383
  type: ClaimTypeSchema,
254
384
  scope: ClaimScopeSchema,
255
- targetRouteId: z5.string().optional(),
385
+ targetRouteId: z6.string().optional(),
256
386
  source: ClaimSourceSchema,
257
- location: z5.object({
258
- file: z5.string(),
259
- startLine: z5.number().int().positive(),
260
- endLine: z5.number().int().positive()
387
+ location: z6.object({
388
+ file: z6.string(),
389
+ startLine: z6.number().int().positive(),
390
+ endLine: z6.number().int().positive()
261
391
  }),
262
392
  strength: ClaimStrengthSchema,
263
- textEvidence: z5.string()
393
+ textEvidence: z6.string()
264
394
  });
265
- var IntentMapSchema = z5.object({
266
- intents: z5.array(IntentEntrySchema)
395
+ var IntentMapSchema = z6.object({
396
+ intents: z6.array(IntentEntrySchema)
267
397
  });
268
- var RouteMapSchema = z5.object({
269
- routes: z5.array(RouteEntrySchema)
398
+ var RouteMapSchema = z6.object({
399
+ routes: z6.array(RouteEntrySchema)
270
400
  });
271
- var CoverageMetricsSchema = z5.object({
272
- authCoverage: z5.object({
273
- totalStateChanging: z5.number().int().nonnegative(),
274
- protectedCount: z5.number().int().nonnegative(),
275
- unprotectedCount: z5.number().int().nonnegative()
401
+ var CoverageMetricsSchema = z6.object({
402
+ authCoverage: z6.object({
403
+ totalStateChanging: z6.number().int().nonnegative(),
404
+ protectedCount: z6.number().int().nonnegative(),
405
+ unprotectedCount: z6.number().int().nonnegative()
276
406
  }).optional(),
277
- validationCoverage: z5.object({
278
- totalStateChanging: z5.number().int().nonnegative(),
279
- validatedCount: z5.number().int().nonnegative()
407
+ validationCoverage: z6.object({
408
+ totalStateChanging: z6.number().int().nonnegative(),
409
+ validatedCount: z6.number().int().nonnegative()
280
410
  }).optional(),
281
- middlewareCoverage: z5.object({
282
- totalApiRoutes: z5.number().int().nonnegative(),
283
- coveredApiRoutes: z5.number().int().nonnegative()
411
+ middlewareCoverage: z6.object({
412
+ totalApiRoutes: z6.number().int().nonnegative(),
413
+ coveredApiRoutes: z6.number().int().nonnegative()
284
414
  }).optional()
285
415
  });
286
- var MetricsSchema = z5.object({
287
- filesScanned: z5.number().int().nonnegative(),
288
- linesOfCode: z5.number().int().nonnegative(),
289
- scanDurationMs: z5.number().nonnegative(),
290
- rulesExecuted: z5.number().int().nonnegative()
416
+ var MetricsSchema = z6.object({
417
+ filesScanned: z6.number().int().nonnegative(),
418
+ linesOfCode: z6.number().int().nonnegative(),
419
+ scanDurationMs: z6.number().nonnegative(),
420
+ rulesExecuted: z6.number().int().nonnegative()
291
421
  }).merge(CoverageMetricsSchema);
292
- var ScanArtifactSchema = z5.object({
422
+ var CIMetadataSchema = z6.object({
423
+ /** Security score (0-100) */
424
+ securityScore: z6.number().int().min(0).max(100),
425
+ /** Overall status for CI badge */
426
+ status: z6.enum(["pass", "warn", "fail"]),
427
+ /** Badge generation timestamp */
428
+ badgeGeneratedAt: z6.string().datetime().optional(),
429
+ /** SHA-256 hash for determinism certification */
430
+ artifactHash: z6.string().optional(),
431
+ /** Whether artifact passed determinism certification */
432
+ deterministicCertified: z6.boolean().optional()
433
+ });
434
+ var CorrelationSummarySchema = z6.object({
435
+ /** Total number of correlation findings generated */
436
+ totalCorrelations: z6.number().int().nonnegative(),
437
+ /** Count by correlation pattern */
438
+ byPattern: z6.record(z6.string(), z6.number().int().nonnegative()),
439
+ /** Correlation pass duration in ms */
440
+ correlationDurationMs: z6.number().nonnegative().optional()
441
+ });
442
+ var GraphNodeSchema = z6.object({
443
+ id: z6.string(),
444
+ type: z6.enum(["route", "middleware", "finding", "intent", "function"]),
445
+ label: z6.string(),
446
+ file: z6.string().optional(),
447
+ line: z6.number().int().positive().optional(),
448
+ metadata: z6.record(z6.string(), z6.unknown()).optional()
449
+ });
450
+ var GraphEdgeSchema = z6.object({
451
+ source: z6.string(),
452
+ target: z6.string(),
453
+ type: z6.enum(["calls", "protects", "validates", "correlates", "references"]),
454
+ label: z6.string().optional()
455
+ });
456
+ var ProofTraceGraphSchema = z6.object({
457
+ nodes: z6.array(GraphNodeSchema),
458
+ edges: z6.array(GraphEdgeSchema)
459
+ });
460
+ var ScanArtifactSchema = z6.object({
293
461
  artifactVersion: ArtifactVersionSchema,
294
- generatedAt: z5.string().datetime(),
462
+ generatedAt: z6.string().datetime(),
295
463
  tool: ToolInfoSchema,
296
464
  repo: RepoInfoSchema.optional(),
297
465
  summary: SummarySchema,
298
- findings: z5.array(FindingSchema),
466
+ findings: z6.array(FindingSchema),
299
467
  // Phase 3: Enhanced maps
300
- routeMap: z5.union([
301
- z5.array(RouteEntrySchema),
468
+ routeMap: z6.union([
469
+ z6.array(RouteEntrySchema),
302
470
  // Legacy format (0.1)
303
471
  RouteMapSchema
304
472
  // New format (0.2)
305
473
  ]).optional(),
306
- middlewareMap: z5.union([
307
- z5.array(MiddlewareEntrySchema),
474
+ middlewareMap: z6.union([
475
+ z6.array(MiddlewareEntrySchema),
308
476
  // Legacy format (0.1)
309
477
  MiddlewareMapSchema
310
478
  // New format (0.2)
311
479
  ]).optional(),
312
480
  intentMap: IntentMapSchema.optional(),
313
- proofTraces: z5.record(z5.string(), ProofTraceSchema).optional(),
314
- metrics: MetricsSchema.optional()
481
+ proofTraces: z6.record(z6.string(), ProofTraceSchema).optional(),
482
+ metrics: MetricsSchema.optional(),
483
+ // Phase 4: Supply chain analysis
484
+ supplyChainInfo: SupplyChainInfoSchema.optional(),
485
+ // Phase 4: CI badge metadata
486
+ ciMetadata: CIMetadataSchema.optional(),
487
+ // Phase 4: Correlation summary
488
+ correlationSummary: CorrelationSummarySchema.optional(),
489
+ // Phase 4: Proof trace graph for visualization
490
+ graph: ProofTraceGraphSchema.optional()
315
491
  });
316
492
  function computeSummary(findings) {
317
493
  const bySeverity = {
@@ -334,6 +510,11 @@ function computeSummary(findings) {
334
510
  uploads: 0,
335
511
  hallucinations: 0,
336
512
  abuse: 0,
513
+ // Phase 4 categories
514
+ correlation: 0,
515
+ authorization: 0,
516
+ lifecycle: 0,
517
+ "supply-chain": 0,
337
518
  other: 0
338
519
  };
339
520
  for (const finding of findings) {
@@ -365,6 +546,9 @@ function validateArtifact(json) {
365
546
  return result.data;
366
547
  }
367
548
 
549
+ // src/constants.ts
550
+ var CLI_VERSION = "0.2.3";
551
+
368
552
  // src/utils/file-utils.ts
369
553
  import fs from "fs";
370
554
  import path from "path";
@@ -3927,165 +4111,2385 @@ var abusePack = {
3927
4111
  scanners: [scanComputeAbuse]
3928
4112
  };
3929
4113
 
3930
- // src/phase3/proof-trace-builder.ts
3931
- import crypto3 from "crypto";
3932
- import path3 from "path";
3933
- import { SyntaxKind as SyntaxKind2 } from "ts-morph";
3934
- var MAX_TRACE_DEPTH = 2;
3935
- function generateRouteId(routePath, method, file) {
3936
- const normalized = `${method}:${routePath}:${file}`.toLowerCase();
3937
- return crypto3.createHash("sha256").update(normalized).digest("hex").slice(0, 12);
3938
- }
3939
- function filePathToRoutePath(filePath) {
4114
+ // src/scanners/authorization/admin-route-no-role-guard.ts
4115
+ var RULE_ID20 = "VC-AUTHZ-001";
4116
+ var ADMIN_PATH_PATTERNS = [
4117
+ /\/admin\//i,
4118
+ /\/admin$/i,
4119
+ /\/administrator/i,
4120
+ /\/superuser/i,
4121
+ /\/staff\//i,
4122
+ /\/internal\//i,
4123
+ /\/management\//i,
4124
+ /\/backoffice/i
4125
+ ];
4126
+ var ADMIN_FUNCTION_PATTERNS = [
4127
+ /admin/i,
4128
+ /superuser/i,
4129
+ /moderator/i,
4130
+ /staff/i
4131
+ ];
4132
+ var AUTH_CHECK_PATTERNS2 = [
4133
+ "getServerSession",
4134
+ "getSession",
4135
+ "auth",
4136
+ "requireAuth",
4137
+ "withAuth",
4138
+ "verifyJwt",
4139
+ "verifyToken",
4140
+ "authenticate",
4141
+ "isAuthenticated",
4142
+ "checkAuth",
4143
+ "validateSession",
4144
+ "getToken"
4145
+ ];
4146
+ var ROLE_CHECK_PATTERNS = [
4147
+ // Direct role checks
4148
+ /session\.user\.role\s*[!=]==?\s*["'](admin|moderator|staff|superuser)["']/i,
4149
+ /user\.role\s*[!=]==?\s*["'](admin|moderator|staff|superuser)["']/i,
4150
+ /\.role\s*[!=]==?\s*["'](admin|moderator|staff|superuser)["']/i,
4151
+ /\.isAdmin/i,
4152
+ /\.is_admin/i,
4153
+ // Role includes check: .includes(session.user.role) or ["admin", "mod"].includes(role)
4154
+ /\.includes\s*\(\s*session\.user\.role\s*\)/i,
4155
+ /\[.*["']admin["'].*\]\.includes\s*\(/i,
4156
+ // Role checking functions
4157
+ /checkRole\s*\(/i,
4158
+ /hasRole\s*\(/i,
4159
+ /isRole\s*\(/i,
4160
+ /requireRole\s*\(/i,
4161
+ /authorizeRole\s*\(/i,
4162
+ /verifyRole\s*\(/i,
4163
+ /assertRole\s*\(/i,
4164
+ // Admin check functions
4165
+ /requireAdmin/i,
4166
+ /isAdmin\s*\(/i,
4167
+ /checkAdmin/i,
4168
+ /assertAdmin/i,
4169
+ /adminOnly/i,
4170
+ // Permission checks
4171
+ /hasPermission\s*\(/i,
4172
+ /checkPermission\s*\(/i,
4173
+ /requirePermission\s*\(/i,
4174
+ /can\s*\(\s*["']admin/i,
4175
+ /abilities\./i,
4176
+ /permissions\./i,
4177
+ // RBAC/ABAC libraries
4178
+ /casl/i,
4179
+ /accesscontrol/i,
4180
+ /rbac/i
4181
+ ];
4182
+ function extractRoutePath2(filePath) {
3940
4183
  const normalized = filePath.replace(/\\/g, "/");
3941
- const appIndex = normalized.indexOf("/app/");
3942
- if (appIndex === -1) {
3943
- const appIndexAlt = normalized.indexOf("app/");
3944
- if (appIndexAlt === 0) {
3945
- return extractRoutePath2(normalized.slice(4));
3946
- }
3947
- return "/";
4184
+ const match = normalized.match(/(?:app|src\/app)(\/api\/[^/]+(?:\/[^/]+)*?)\/route\.[tj]sx?$/);
4185
+ if (match) {
4186
+ return match[1];
3948
4187
  }
3949
- return extractRoutePath2(normalized.slice(appIndex + 5));
4188
+ return normalized;
3950
4189
  }
3951
- function extractRoutePath2(routePart) {
3952
- const withoutRoute = routePart.replace(/\/route\.(ts|tsx|js|jsx)$/, "");
3953
- if (withoutRoute === "" || withoutRoute === "api") {
3954
- return withoutRoute === "" ? "/" : "/api";
3955
- }
3956
- return "/" + withoutRoute;
4190
+ function isAdminPath(routePath) {
4191
+ return ADMIN_PATH_PATTERNS.some((pattern) => pattern.test(routePath));
3957
4192
  }
3958
- function buildRouteMap(ctx) {
3959
- const routes = [];
3960
- for (const routeFile of ctx.fileIndex.routeFiles) {
3961
- const absolutePath = path3.join(ctx.repoRoot, routeFile);
3962
- const sourceFile = ctx.helpers.parseFile(absolutePath);
4193
+ function isAdminHandler(handlerName) {
4194
+ return ADMIN_FUNCTION_PATTERNS.some((pattern) => pattern.test(handlerName));
4195
+ }
4196
+ function hasAuthCheck(handlerText) {
4197
+ return AUTH_CHECK_PATTERNS2.some((pattern) => handlerText.includes(pattern));
4198
+ }
4199
+ function hasRoleCheck(handlerText) {
4200
+ return ROLE_CHECK_PATTERNS.some((pattern) => pattern.test(handlerText));
4201
+ }
4202
+ async function scanAdminRouteNoRoleGuard(context) {
4203
+ const { repoRoot, fileIndex, helpers, repoMeta } = context;
4204
+ const findings = [];
4205
+ if (repoMeta.framework !== "next") {
4206
+ return findings;
4207
+ }
4208
+ for (const relPath of fileIndex.apiRouteFiles) {
4209
+ const absPath = resolvePath(repoRoot, relPath);
4210
+ const sourceFile = helpers.parseFile(absPath);
3963
4211
  if (!sourceFile) continue;
3964
- const handlers = ctx.helpers.findRouteHandlers(sourceFile);
3965
- const relPath = routeFile.replace(/\\/g, "/");
3966
- const routePath = filePathToRoutePath(relPath);
4212
+ const routePath = extractRoutePath2(relPath);
4213
+ const handlers = helpers.findRouteHandlers(sourceFile);
3967
4214
  for (const handler of handlers) {
3968
- const routeId = generateRouteId(routePath, handler.method, relPath);
3969
- routes.push({
3970
- routeId,
3971
- method: handler.method,
3972
- path: routePath,
4215
+ const handlerText = helpers.getNodeText(handler.functionNode);
4216
+ const pathIsAdmin = isAdminPath(routePath);
4217
+ const handlerIsAdmin = isAdminHandler(handler.exportName);
4218
+ if (!pathIsAdmin && !handlerIsAdmin) {
4219
+ continue;
4220
+ }
4221
+ if (!hasAuthCheck(handlerText)) {
4222
+ continue;
4223
+ }
4224
+ if (hasRoleCheck(handlerText)) {
4225
+ continue;
4226
+ }
4227
+ const evidence = [
4228
+ {
4229
+ file: relPath,
4230
+ startLine: handler.startLine,
4231
+ endLine: handler.endLine,
4232
+ snippet: handlerText.slice(0, 300) + (handlerText.length > 300 ? "..." : ""),
4233
+ label: `Admin ${handler.method} handler with auth but no role guard`
4234
+ }
4235
+ ];
4236
+ const fingerprint = generateFingerprint({
4237
+ ruleId: RULE_ID20,
3973
4238
  file: relPath,
3974
- startLine: handler.startLine,
3975
- endLine: handler.endLine
4239
+ symbol: handler.method,
4240
+ route: routePath
3976
4241
  });
3977
- }
3978
- }
3979
- return routes;
4242
+ findings.push({
4243
+ id: generateFindingId({
4244
+ ruleId: RULE_ID20,
4245
+ file: relPath,
4246
+ symbol: handler.method
4247
+ }),
4248
+ ruleId: RULE_ID20,
4249
+ title: `Admin route ${routePath} has auth but no role guard`,
4250
+ description: `The ${handler.method} handler at ${routePath} has authentication checks (verifying user identity) but lacks role-based authorization (verifying admin privileges). Any authenticated user could potentially access this admin functionality. Authentication proves WHO you are; authorization proves WHAT you can do.`,
4251
+ severity: "high",
4252
+ confidence: 0.8,
4253
+ category: "authorization",
4254
+ evidence,
4255
+ remediation: {
4256
+ recommendedFix: `Add a role check after authentication. Example:
4257
+
4258
+ const session = await getServerSession();
4259
+ if (!session) {
4260
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
3980
4261
  }
3981
- function buildMiddlewareMap(ctx) {
3982
- const middlewareList = [];
3983
- if (!ctx.fileIndex.middlewareFile) {
3984
- return middlewareList;
3985
- }
3986
- const absolutePath = path3.join(ctx.repoRoot, ctx.fileIndex.middlewareFile);
3987
- const sourceFile = ctx.helpers.parseFile(absolutePath);
3988
- if (!sourceFile) return middlewareList;
3989
- const relPath = ctx.fileIndex.middlewareFile.replace(/\\/g, "/");
3990
- const matchers = extractMiddlewareMatchers(sourceFile);
3991
- const protectsApi = matchers.some(
3992
- (m) => m.includes("/api") || m.includes("/(api)") || m === "/(.*)"
3993
- );
3994
- let startLine = 1;
3995
- sourceFile.forEachDescendant((node) => {
3996
- if (node.getKind() === SyntaxKind2.VariableDeclaration && node.getText().includes("config")) {
3997
- startLine = node.getStartLineNumber();
3998
- }
3999
- });
4000
- middlewareList.push({
4001
- file: relPath,
4002
- matchers,
4003
- protectsApi,
4004
- startLine
4005
- });
4006
- return middlewareList;
4262
+ if (session.user.role !== "admin") {
4263
+ return Response.json({ error: "Forbidden" }, { status: 403 });
4007
4264
  }
4008
- function extractMiddlewareMatchers(sourceFile) {
4009
- const matchers = [];
4010
- sourceFile.forEachDescendant((node) => {
4011
- if (node.getKind() === SyntaxKind2.PropertyAssignment) {
4012
- const text = node.getText();
4013
- if (text.startsWith("matcher")) {
4014
- node.forEachDescendant((child) => {
4015
- if (child.getKind() === SyntaxKind2.StringLiteral) {
4016
- const value = child.getText().replace(/['"]/g, "");
4017
- matchers.push(value);
4018
- }
4019
- });
4020
- }
4265
+
4266
+ Consider using a role-based access control library like CASL or accesscontrol.`
4267
+ },
4268
+ links: {
4269
+ owasp: "https://owasp.org/Top10/A01_2021-Broken_Access_Control/",
4270
+ cwe: "https://cwe.mitre.org/data/definitions/285.html"
4271
+ },
4272
+ fingerprint
4273
+ });
4021
4274
  }
4022
- });
4023
- return matchers;
4275
+ }
4276
+ return findings;
4024
4277
  }
4025
- function isRouteCoveredByMiddleware(routePath, matchers) {
4026
- for (const matcher of matchers) {
4027
- const pattern = matcher.replace(/\*/g, ".*").replace(/\/:path\*/g, "/.*").replace(/\(([^)]+)\)/g, "(?:$1)");
4028
- try {
4029
- const regex = new RegExp(`^${pattern}`);
4030
- if (regex.test(routePath)) {
4031
- return true;
4032
- }
4033
- } catch {
4034
- if (routePath.startsWith(matcher.replace(/\/:path\*$/, ""))) {
4035
- return true;
4036
- }
4037
- }
4278
+
4279
+ // src/scanners/authorization/ownership-check-missing.ts
4280
+ var RULE_ID21 = "VC-AUTHZ-002";
4281
+ var USER_ID_PARAMS = [
4282
+ "userId",
4283
+ "user_id",
4284
+ "userid",
4285
+ "ownerId",
4286
+ "owner_id",
4287
+ "authorId",
4288
+ "author_id",
4289
+ "creatorId",
4290
+ "creator_id",
4291
+ "accountId",
4292
+ "account_id",
4293
+ "memberId",
4294
+ "member_id",
4295
+ "profileId",
4296
+ "profile_id"
4297
+ ];
4298
+ var OWNERSHIP_CHECK_PATTERNS = [
4299
+ // Direct equality: userId === session.user.id
4300
+ /(?:userId|user_id|ownerId|owner_id|authorId)\s*===?\s*session\.user\.id/i,
4301
+ /session\.user\.id\s*===?\s*(?:userId|user_id|ownerId|owner_id|authorId)/i,
4302
+ // Inequality check for ownership: userId !== session.user.id
4303
+ /(?:userId|user_id|ownerId|owner_id|authorId)\s*!==?\s*session\.user\.id/i,
4304
+ /session\.user\.id\s*!==?\s*(?:userId|user_id|ownerId|owner_id|authorId)/i,
4305
+ // Function-based checks
4306
+ /isOwner\s*\(/i,
4307
+ /checkOwnership\s*\(/i,
4308
+ /verifyOwnership\s*\(/i,
4309
+ /canAccess\s*\(/i,
4310
+ /belongsTo\s*\(/i,
4311
+ /ownedBy\s*\(/i,
4312
+ // Prisma where clause with session user
4313
+ /where\s*:\s*\{[^}]*(?:userId|user_id|ownerId|authorId)\s*:\s*session\.user\.id/i,
4314
+ /where\s*:\s*\{[^}]*userId\s*:\s*user\.id/i,
4315
+ // Combined auth + resource check
4316
+ /\.findFirst\s*\([^)]*where\s*:\s*\{[^}]*AND/i,
4317
+ // Finding resource by session user (safe pattern for ownership)
4318
+ /authorId\s*:\s*session\.user\.id/i,
4319
+ /ownerId\s*:\s*session\.user\.id/i,
4320
+ /userId\s*:\s*session\.user\.id/i,
4321
+ /creatorId\s*:\s*session\.user\.id/i,
4322
+ // Role-based authorization (admin/moderator can access any resource)
4323
+ /session\.user\.role\s*[!=]==?\s*["']admin["']/i,
4324
+ /\.role\s*[!=]==?\s*["']admin["']/i,
4325
+ /\.includes\s*\(\s*session\.user\.role\s*\)/i,
4326
+ /\[.*["']admin["'].*\]\.includes/i
4327
+ ];
4328
+ var DANGEROUS_OPS_PATTERNS = [
4329
+ /prisma\.\w+\.update/i,
4330
+ /prisma\.\w+\.delete/i,
4331
+ /prisma\.\w+\.upsert/i,
4332
+ /\.update\s*\(/i,
4333
+ /\.delete\s*\(/i,
4334
+ /\.destroy\s*\(/i,
4335
+ /\.remove\s*\(/i,
4336
+ /db\.\w+\.update/i,
4337
+ /db\.\w+\.delete/i
4338
+ ];
4339
+ function extractRoutePath3(filePath) {
4340
+ const normalized = filePath.replace(/\\/g, "/");
4341
+ const match = normalized.match(/(?:app|src\/app)(\/api\/[^/]+(?:\/[^/]+)*?)\/route\.[tj]sx?$/);
4342
+ if (match) {
4343
+ return match[1];
4038
4344
  }
4039
- return false;
4345
+ return normalized;
4040
4346
  }
4041
- function buildProofTrace(ctx, route) {
4042
- const steps = [];
4043
- let authProven = false;
4044
- let validationProven = false;
4045
- const sourceFile = ctx.helpers.parseFile(
4046
- path3.join(ctx.repoRoot, route.file)
4047
- );
4048
- if (!sourceFile) {
4049
- return {
4050
- routeId: route.routeId,
4051
- authProven: false,
4052
- validationProven: false,
4053
- middlewareCovered: false,
4054
- steps: []
4055
- };
4347
+ function extractsUserId(handlerText) {
4348
+ const hasBodyExtraction = /(?:await\s+)?(?:request\.json|req\.body)\s*\(\s*\)|\.json\s*\(\s*\)/.test(handlerText);
4349
+ if (!hasBodyExtraction) {
4350
+ return { found: false };
4056
4351
  }
4057
- const handlers = ctx.helpers.findRouteHandlers(sourceFile);
4058
- const handler = handlers.find((h) => h.method === route.method);
4059
- if (!handler) {
4060
- return {
4061
- routeId: route.routeId,
4062
- authProven: false,
4063
- validationProven: false,
4064
- middlewareCovered: false,
4065
- steps: []
4066
- };
4352
+ for (const param of USER_ID_PARAMS) {
4353
+ const pattern = new RegExp(`\\{[^}]*\\b${param}\\b[^}]*\\}\\s*=\\s*(?:await\\s+)?`, "i");
4354
+ if (pattern.test(handlerText)) {
4355
+ const match = handlerText.match(pattern);
4356
+ return {
4357
+ found: true,
4358
+ param,
4359
+ snippet: match?.[0] || param
4360
+ };
4361
+ }
4067
4362
  }
4068
- if (ctx.helpers.containsAuthCheck(handler.functionNode)) {
4069
- authProven = true;
4070
- steps.push({
4071
- file: route.file,
4072
- line: handler.startLine,
4073
- snippet: truncateSnippet(handler.functionNode.getText(), 100),
4074
- label: "Auth check found in handler"
4075
- });
4363
+ for (const param of USER_ID_PARAMS) {
4364
+ const directPattern = new RegExp(`(?:body|data|payload)\\.${param}\\b`, "i");
4365
+ if (directPattern.test(handlerText)) {
4366
+ const match = handlerText.match(directPattern);
4367
+ return {
4368
+ found: true,
4369
+ param,
4370
+ snippet: match?.[0] || param
4371
+ };
4372
+ }
4076
4373
  }
4077
- const validationUsage = ctx.helpers.findValidationUsage(handler.functionNode);
4078
- if (validationUsage.length > 0 && validationUsage.some((v) => v.resultUsed)) {
4079
- validationProven = true;
4080
- steps.push({
4081
- file: route.file,
4082
- line: validationUsage[0].line,
4083
- snippet: truncateSnippet(ctx.helpers.getNodeText(validationUsage[0].node), 100),
4084
- label: "Validation found in handler"
4085
- });
4374
+ return { found: false };
4375
+ }
4376
+ function hasOwnershipCheck(handlerText) {
4377
+ return OWNERSHIP_CHECK_PATTERNS.some((pattern) => pattern.test(handlerText));
4378
+ }
4379
+ function hasDangerousOps(handlerText) {
4380
+ return DANGEROUS_OPS_PATTERNS.some((pattern) => pattern.test(handlerText));
4381
+ }
4382
+ function hasSessionAccess(handlerText) {
4383
+ return /session\.user|getServerSession|auth\(\)|currentUser|req\.user/i.test(handlerText);
4384
+ }
4385
+ async function scanOwnershipCheckMissing(context) {
4386
+ const { repoRoot, fileIndex, helpers, repoMeta } = context;
4387
+ const findings = [];
4388
+ if (repoMeta.framework !== "next") {
4389
+ return findings;
4086
4390
  }
4087
- if (!authProven || !validationProven) {
4088
- const importedModules = getLocalImports(sourceFile, ctx.repoRoot, route.file);
4391
+ for (const relPath of fileIndex.apiRouteFiles) {
4392
+ const absPath = resolvePath(repoRoot, relPath);
4393
+ const sourceFile = helpers.parseFile(absPath);
4394
+ if (!sourceFile) continue;
4395
+ const routePath = extractRoutePath3(relPath);
4396
+ const handlers = helpers.findRouteHandlers(sourceFile);
4397
+ for (const handler of handlers) {
4398
+ if (!["POST", "PUT", "PATCH", "DELETE"].includes(handler.method)) {
4399
+ continue;
4400
+ }
4401
+ const handlerText = helpers.getNodeText(handler.functionNode);
4402
+ const userIdExtraction = extractsUserId(handlerText);
4403
+ if (!userIdExtraction.found) {
4404
+ continue;
4405
+ }
4406
+ if (!hasDangerousOps(handlerText)) {
4407
+ continue;
4408
+ }
4409
+ if (!hasSessionAccess(handlerText)) {
4410
+ continue;
4411
+ }
4412
+ if (hasOwnershipCheck(handlerText)) {
4413
+ continue;
4414
+ }
4415
+ const evidence = [
4416
+ {
4417
+ file: relPath,
4418
+ startLine: handler.startLine,
4419
+ endLine: handler.endLine,
4420
+ snippet: handlerText.slice(0, 400) + (handlerText.length > 400 ? "..." : ""),
4421
+ label: `${handler.method} handler extracts ${userIdExtraction.param} without ownership check`
4422
+ }
4423
+ ];
4424
+ const fingerprint = generateFingerprint({
4425
+ ruleId: RULE_ID21,
4426
+ file: relPath,
4427
+ symbol: handler.method,
4428
+ route: routePath
4429
+ });
4430
+ findings.push({
4431
+ id: generateFindingId({
4432
+ ruleId: RULE_ID21,
4433
+ file: relPath,
4434
+ symbol: handler.method
4435
+ }),
4436
+ ruleId: RULE_ID21,
4437
+ title: `Missing ownership check for ${userIdExtraction.param} in ${routePath}`,
4438
+ description: `The ${handler.method} handler at ${routePath} extracts '${userIdExtraction.param}' from the request and performs database operations, but never verifies that this ID belongs to the authenticated user. An attacker could modify or delete another user's resources by supplying a different user ID. This is an Insecure Direct Object Reference (IDOR) vulnerability.`,
4439
+ severity: "critical",
4440
+ confidence: 0.75,
4441
+ category: "authorization",
4442
+ evidence,
4443
+ remediation: {
4444
+ recommendedFix: `Verify that the requested resource belongs to the authenticated user:
4445
+
4446
+ // Option 1: Compare IDs explicitly
4447
+ if (userId !== session.user.id) {
4448
+ return Response.json({ error: "Forbidden" }, { status: 403 });
4449
+ }
4450
+
4451
+ // Option 2: Include ownership in the database query
4452
+ const resource = await prisma.post.findFirst({
4453
+ where: { id: postId, authorId: session.user.id }
4454
+ });
4455
+ if (!resource) {
4456
+ return Response.json({ error: "Not found" }, { status: 404 });
4457
+ }`
4458
+ },
4459
+ links: {
4460
+ owasp: "https://owasp.org/Top10/A01_2021-Broken_Access_Control/",
4461
+ cwe: "https://cwe.mitre.org/data/definitions/639.html"
4462
+ },
4463
+ fingerprint
4464
+ });
4465
+ }
4466
+ }
4467
+ return findings;
4468
+ }
4469
+
4470
+ // src/scanners/authorization/role-declared-not-enforced.ts
4471
+ var RULE_ID22 = "VC-AUTHZ-003";
4472
+ var ROLE_DECLARATION_PATTERNS = [
4473
+ // Type unions: type Role = "admin" | "user"
4474
+ /type\s+Role\s*=\s*["'][^"']+["']\s*\|/i,
4475
+ // Enum: enum Role { Admin, User }
4476
+ /enum\s+Role\s*\{/i,
4477
+ // Const object: const ROLES = { ADMIN: "admin", ... }
4478
+ /const\s+ROLES?\s*=\s*\{/i,
4479
+ // Role array: const roles = ["admin", "user"]
4480
+ /const\s+roles?\s*=\s*\[/i,
4481
+ // Interface with role: interface User { role: "admin" | "user" }
4482
+ /role\s*:\s*["'][^"']+["']\s*\|/i
4483
+ ];
4484
+ var ROLE_VALUES = [
4485
+ "admin",
4486
+ "administrator",
4487
+ "superuser",
4488
+ "moderator",
4489
+ "editor",
4490
+ "manager",
4491
+ "staff",
4492
+ "user",
4493
+ "guest",
4494
+ "viewer",
4495
+ "member"
4496
+ ];
4497
+ var ROLE_CHECK_PATTERNS2 = [
4498
+ // Direct role comparison with session
4499
+ /session\.user\.role\s*[!=]==?\s*["'](admin|moderator|staff|manager|user)["']/i,
4500
+ /\.role\s*[!=]==?\s*["'](admin|moderator|staff|manager|user)["']/i,
4501
+ /["'](admin|moderator|staff|manager)["']\s*[!=]==?\s*\.role/i,
4502
+ // Role existence check: if (!session.user.role) or if (session.user.role)
4503
+ /!\s*session\.user\.role\b/i,
4504
+ /if\s*\(\s*session\.user\.role\b/i,
4505
+ // Role includes/has methods
4506
+ /\.role\s*===?\s*\w+\.ADMIN/i,
4507
+ /hasRole\s*\(/i,
4508
+ /checkRole\s*\(/i,
4509
+ /isRole\s*\(/i,
4510
+ /requireRole\s*\(/i,
4511
+ /roles?\.\s*includes\s*\(/i,
4512
+ /\[.*["']admin["'].*\]\.includes\s*\(.*session\.user\.role/i,
4513
+ /\.includes\s*\(\s*session\.user\.role\s*\)/i,
4514
+ // Admin checks
4515
+ /\.isAdmin/i,
4516
+ /isAdmin\s*\(/i,
4517
+ /requireAdmin/i,
4518
+ /adminOnly/i,
4519
+ // RBAC libraries
4520
+ /can\s*\(\s*["']/i,
4521
+ /abilities\./i,
4522
+ /casl/i,
4523
+ /accesscontrol/i
4524
+ ];
4525
+ var AUTH_PATTERNS = [
4526
+ "getServerSession",
4527
+ "getSession",
4528
+ "auth(",
4529
+ "session.user",
4530
+ "currentUser"
4531
+ ];
4532
+ function findRoleDeclarations(context) {
4533
+ const declarations = [];
4534
+ const { repoRoot, fileIndex, helpers } = context;
4535
+ const typeFiles = fileIndex.allSourceFiles.filter(
4536
+ (f) => f.includes("/types/") || f.includes("/types.") || f.includes("/constants") || f.includes("/config") || f.includes("/auth") || f.includes("/lib/")
4537
+ );
4538
+ for (const relPath of typeFiles) {
4539
+ const absPath = resolvePath(repoRoot, relPath);
4540
+ const sourceFile = helpers.parseFile(absPath);
4541
+ if (!sourceFile) continue;
4542
+ const text = sourceFile.getFullText();
4543
+ for (const pattern of ROLE_DECLARATION_PATTERNS) {
4544
+ const match = text.match(pattern);
4545
+ if (match) {
4546
+ const rolesFound = ROLE_VALUES.filter(
4547
+ (role) => new RegExp(`["']${role}["']`, "i").test(text)
4548
+ );
4549
+ if (rolesFound.length > 1) {
4550
+ const pos = match.index || 0;
4551
+ const line = sourceFile.getLineAndColumnAtPos(pos).line;
4552
+ declarations.push({
4553
+ file: relPath,
4554
+ line,
4555
+ snippet: match[0].slice(0, 100),
4556
+ roles: rolesFound
4557
+ });
4558
+ break;
4559
+ }
4560
+ }
4561
+ }
4562
+ const typeAliases = sourceFile.getTypeAliases();
4563
+ for (const typeAlias of typeAliases) {
4564
+ const name = typeAlias.getName();
4565
+ if (/^role$/i.test(name)) {
4566
+ const typeText = typeAlias.getText();
4567
+ const rolesFound = ROLE_VALUES.filter(
4568
+ (role) => new RegExp(`["']${role}["']`, "i").test(typeText)
4569
+ );
4570
+ if (rolesFound.length > 1) {
4571
+ declarations.push({
4572
+ file: relPath,
4573
+ line: typeAlias.getStartLineNumber(),
4574
+ snippet: typeText.slice(0, 100),
4575
+ roles: rolesFound
4576
+ });
4577
+ }
4578
+ }
4579
+ }
4580
+ const enums = sourceFile.getEnums();
4581
+ for (const enumDecl of enums) {
4582
+ const name = enumDecl.getName();
4583
+ if (/^role$/i.test(name)) {
4584
+ const members = enumDecl.getMembers().map((m) => m.getName().toLowerCase());
4585
+ const matchingRoles = members.filter(
4586
+ (m) => ROLE_VALUES.some((r) => m.includes(r))
4587
+ );
4588
+ if (matchingRoles.length > 1) {
4589
+ declarations.push({
4590
+ file: relPath,
4591
+ line: enumDecl.getStartLineNumber(),
4592
+ snippet: enumDecl.getText().slice(0, 100),
4593
+ roles: matchingRoles
4594
+ });
4595
+ }
4596
+ }
4597
+ }
4598
+ }
4599
+ return declarations;
4600
+ }
4601
+ function hasRoleEnforcement(handlerText) {
4602
+ return ROLE_CHECK_PATTERNS2.some((pattern) => pattern.test(handlerText));
4603
+ }
4604
+ function hasAuthAccess(handlerText) {
4605
+ return AUTH_PATTERNS.some((pattern) => handlerText.includes(pattern));
4606
+ }
4607
+ function extractRoutePath4(filePath) {
4608
+ const normalized = filePath.replace(/\\/g, "/");
4609
+ const match = normalized.match(/(?:app|src\/app)(\/api\/[^/]+(?:\/[^/]+)*?)\/route\.[tj]sx?$/);
4610
+ if (match) {
4611
+ return match[1];
4612
+ }
4613
+ return normalized;
4614
+ }
4615
+ async function scanRoleDeclaredNotEnforced(context) {
4616
+ const { repoRoot, fileIndex, helpers, repoMeta } = context;
4617
+ const findings = [];
4618
+ if (repoMeta.framework !== "next") {
4619
+ return findings;
4620
+ }
4621
+ const roleDeclarations = findRoleDeclarations(context);
4622
+ if (roleDeclarations.length === 0) {
4623
+ return findings;
4624
+ }
4625
+ const routesWithoutEnforcement = [];
4626
+ for (const relPath of fileIndex.apiRouteFiles) {
4627
+ const absPath = resolvePath(repoRoot, relPath);
4628
+ const sourceFile = helpers.parseFile(absPath);
4629
+ if (!sourceFile) continue;
4630
+ const routePath = extractRoutePath4(relPath);
4631
+ const handlers = helpers.findRouteHandlers(sourceFile);
4632
+ for (const handler of handlers) {
4633
+ if (!["POST", "PUT", "PATCH", "DELETE"].includes(handler.method)) {
4634
+ continue;
4635
+ }
4636
+ const handlerText = helpers.getNodeText(handler.functionNode);
4637
+ if (!hasAuthAccess(handlerText)) {
4638
+ continue;
4639
+ }
4640
+ if (hasRoleEnforcement(handlerText)) {
4641
+ continue;
4642
+ }
4643
+ routesWithoutEnforcement.push({
4644
+ file: relPath,
4645
+ routePath,
4646
+ method: handler.method,
4647
+ startLine: handler.startLine,
4648
+ endLine: handler.endLine,
4649
+ snippet: handlerText.slice(0, 300) + (handlerText.length > 300 ? "..." : "")
4650
+ });
4651
+ }
4652
+ }
4653
+ if (routesWithoutEnforcement.length > 0 && roleDeclarations.length > 0) {
4654
+ const routesToFlag = routesWithoutEnforcement.slice(0, 5);
4655
+ for (const route of routesToFlag) {
4656
+ const roleDecl = roleDeclarations[0];
4657
+ const evidence = [
4658
+ {
4659
+ file: roleDecl.file,
4660
+ startLine: roleDecl.line,
4661
+ endLine: roleDecl.line,
4662
+ snippet: roleDecl.snippet,
4663
+ label: `Role types defined: ${roleDecl.roles.join(", ")}`
4664
+ },
4665
+ {
4666
+ file: route.file,
4667
+ startLine: route.startLine,
4668
+ endLine: route.endLine,
4669
+ snippet: route.snippet,
4670
+ label: `${route.method} handler without role check`
4671
+ }
4672
+ ];
4673
+ const fingerprint = generateFingerprint({
4674
+ ruleId: RULE_ID22,
4675
+ file: route.file,
4676
+ symbol: route.method,
4677
+ route: route.routePath
4678
+ });
4679
+ findings.push({
4680
+ id: generateFindingId({
4681
+ ruleId: RULE_ID22,
4682
+ file: route.file,
4683
+ symbol: route.method
4684
+ }),
4685
+ ruleId: RULE_ID22,
4686
+ title: `Roles defined but not enforced in ${route.routePath}`,
4687
+ description: `The codebase defines role types (${roleDecl.roles.join(", ")}) in ${roleDecl.file}, suggesting role-based access control is intended. However, the ${route.method} handler at ${route.routePath} has authentication but doesn't check the user's role. This may indicate incomplete RBAC implementation.`,
4688
+ severity: "medium",
4689
+ confidence: 0.7,
4690
+ category: "authorization",
4691
+ evidence,
4692
+ remediation: {
4693
+ recommendedFix: `Add role checking to the handler. Since roles are already defined in your codebase, implement enforcement:
4694
+
4695
+ const session = await getServerSession();
4696
+ if (!session) {
4697
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
4698
+ }
4699
+ if (!["admin", "moderator"].includes(session.user.role)) {
4700
+ return Response.json({ error: "Forbidden" }, { status: 403 });
4701
+ }
4702
+
4703
+ Consider creating a reusable middleware or higher-order function for role checks.`
4704
+ },
4705
+ links: {
4706
+ owasp: "https://owasp.org/Top10/A01_2021-Broken_Access_Control/",
4707
+ cwe: "https://cwe.mitre.org/data/definitions/862.html"
4708
+ },
4709
+ fingerprint
4710
+ });
4711
+ }
4712
+ }
4713
+ return findings;
4714
+ }
4715
+
4716
+ // src/scanners/authorization/trusted-client-id.ts
4717
+ var RULE_ID23 = "VC-AUTHZ-004";
4718
+ var UNTRUSTED_ID_PARAMS = [
4719
+ "userId",
4720
+ "user_id",
4721
+ "userid",
4722
+ "ownerId",
4723
+ "owner_id",
4724
+ "authorId",
4725
+ "author_id",
4726
+ "creatorId",
4727
+ "creator_id",
4728
+ "tenantId",
4729
+ "tenant_id",
4730
+ "organizationId",
4731
+ "organization_id",
4732
+ "orgId",
4733
+ "org_id",
4734
+ "teamId",
4735
+ "team_id",
4736
+ "companyId",
4737
+ "company_id",
4738
+ "accountId",
4739
+ "account_id"
4740
+ ];
4741
+ function usesSafeSessionIdInWrite(handlerText) {
4742
+ const codeOnly = handlerText.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
4743
+ const safePatterns = [
4744
+ // authorId: session.user.id or userId: session.user.id
4745
+ /(?:authorId|ownerId|creatorId|userId|tenantId|organizationId)\s*:\s*session\.user\.id/i,
4746
+ // Using currentUser.id
4747
+ /(?:authorId|ownerId|creatorId|userId)\s*:\s*currentUser\.id/i,
4748
+ // Using user.id from validated context
4749
+ /(?:authorId|ownerId|creatorId|userId)\s*:\s*user\.id/i
4750
+ ];
4751
+ return safePatterns.some((p) => p.test(codeOnly));
4752
+ }
4753
+ var WRITE_OP_PATTERNS = [
4754
+ /\.create\s*\(/i,
4755
+ /\.insert\s*\(/i,
4756
+ /\.upsert\s*\(/i,
4757
+ /\.createMany\s*\(/i
4758
+ ];
4759
+ function extractRoutePath5(filePath) {
4760
+ const normalized = filePath.replace(/\\/g, "/");
4761
+ const match = normalized.match(/(?:app|src\/app)(\/api\/[^/]+(?:\/[^/]+)*?)\/route\.[tj]sx?$/);
4762
+ if (match) {
4763
+ return match[1];
4764
+ }
4765
+ return normalized;
4766
+ }
4767
+ function extractsUntrustedId(handlerText) {
4768
+ const hasBodyExtraction = /(?:await\s+)?(?:request\.json|req\.body)\s*\(\s*\)|\.json\s*\(\s*\)/.test(handlerText);
4769
+ if (!hasBodyExtraction) {
4770
+ return { found: false };
4771
+ }
4772
+ for (const param of UNTRUSTED_ID_PARAMS) {
4773
+ const destructurePattern = new RegExp(`\\{[^}]*\\b${param}\\b[^}]*\\}\\s*=`, "i");
4774
+ const directPattern = new RegExp(`(?:body|data|payload)\\.${param}\\b`, "i");
4775
+ if (destructurePattern.test(handlerText) || directPattern.test(handlerText)) {
4776
+ return { found: true, param };
4777
+ }
4778
+ }
4779
+ return { found: false };
4780
+ }
4781
+ function usesClientIdInWrite(handlerText, param) {
4782
+ const hasWriteOp = WRITE_OP_PATTERNS.some((p) => p.test(handlerText));
4783
+ if (!hasWriteOp) {
4784
+ return { found: false };
4785
+ }
4786
+ const directUsePattern = new RegExp(
4787
+ `(?:authorId|ownerId|creatorId|userId|tenantId|organizationId)\\s*:\\s*${param}(?:\\s*,|\\s*})`,
4788
+ "i"
4789
+ );
4790
+ if (directUsePattern.test(handlerText)) {
4791
+ const match = handlerText.match(directUsePattern);
4792
+ return { found: true, snippet: match?.[0]?.replace(/[,}]$/, "").trim() };
4793
+ }
4794
+ const multilinePattern = new RegExp(
4795
+ `(?:authorId|ownerId|creatorId|userId|tenantId|organizationId)\\s*:\\s*${param}\\b`,
4796
+ "i"
4797
+ );
4798
+ if (multilinePattern.test(handlerText)) {
4799
+ const contextPattern = new RegExp(
4800
+ `(?:authorId|ownerId|creatorId|userId)\\s*:\\s*${param}\\s*(?:,|//|$)`,
4801
+ "im"
4802
+ );
4803
+ if (contextPattern.test(handlerText)) {
4804
+ const match = handlerText.match(multilinePattern);
4805
+ return { found: true, snippet: match?.[0]?.trim() };
4806
+ }
4807
+ }
4808
+ const bodyUsePattern = new RegExp(
4809
+ `(?:authorId|ownerId|creatorId|userId|tenantId|organizationId)\\s*:\\s*(?:body|data|payload)\\.${param}`,
4810
+ "i"
4811
+ );
4812
+ if (bodyUsePattern.test(handlerText)) {
4813
+ const match = handlerText.match(bodyUsePattern);
4814
+ return { found: true, snippet: match?.[0] };
4815
+ }
4816
+ const spreadPattern = /\{\s*\.\.\.(?:body|data|payload|json)/i;
4817
+ if (spreadPattern.test(handlerText)) {
4818
+ if (!SAFE_ID_PATTERNS.some((p) => p.test(handlerText))) {
4819
+ return { found: true, snippet: "spreading request data without overriding user ID" };
4820
+ }
4821
+ }
4822
+ return { found: false };
4823
+ }
4824
+ function usesSafeSessionId(handlerText) {
4825
+ return usesSafeSessionIdInWrite(handlerText);
4826
+ }
4827
+ async function scanTrustedClientId(context) {
4828
+ const { repoRoot, fileIndex, helpers, repoMeta } = context;
4829
+ const findings = [];
4830
+ if (repoMeta.framework !== "next") {
4831
+ return findings;
4832
+ }
4833
+ for (const relPath of fileIndex.apiRouteFiles) {
4834
+ const absPath = resolvePath(repoRoot, relPath);
4835
+ const sourceFile = helpers.parseFile(absPath);
4836
+ if (!sourceFile) continue;
4837
+ const routePath = extractRoutePath5(relPath);
4838
+ const handlers = helpers.findRouteHandlers(sourceFile);
4839
+ for (const handler of handlers) {
4840
+ if (handler.method !== "POST") {
4841
+ continue;
4842
+ }
4843
+ const handlerText = helpers.getNodeText(handler.functionNode);
4844
+ const hasWriteOp = WRITE_OP_PATTERNS.some((p) => p.test(handlerText));
4845
+ if (!hasWriteOp) {
4846
+ continue;
4847
+ }
4848
+ const extraction = extractsUntrustedId(handlerText);
4849
+ if (!extraction.found) {
4850
+ continue;
4851
+ }
4852
+ const writeUsage = usesClientIdInWrite(handlerText, extraction.param);
4853
+ if (!writeUsage.found) {
4854
+ continue;
4855
+ }
4856
+ if (usesSafeSessionId(handlerText)) {
4857
+ continue;
4858
+ }
4859
+ const evidence = [
4860
+ {
4861
+ file: relPath,
4862
+ startLine: handler.startLine,
4863
+ endLine: handler.endLine,
4864
+ snippet: handlerText.slice(0, 400) + (handlerText.length > 400 ? "..." : ""),
4865
+ label: `POST handler uses client-provided ${extraction.param} for write: ${writeUsage.snippet}`
4866
+ }
4867
+ ];
4868
+ const fingerprint = generateFingerprint({
4869
+ ruleId: RULE_ID23,
4870
+ file: relPath,
4871
+ symbol: handler.method,
4872
+ route: routePath
4873
+ });
4874
+ findings.push({
4875
+ id: generateFindingId({
4876
+ ruleId: RULE_ID23,
4877
+ file: relPath,
4878
+ symbol: handler.method
4879
+ }),
4880
+ ruleId: RULE_ID23,
4881
+ title: `Server trusts client-provided ${extraction.param} in ${routePath}`,
4882
+ description: `The POST handler at ${routePath} extracts '${extraction.param}' from the request body and uses it directly in a write operation (${writeUsage.snippet}). An attacker can impersonate any user by supplying a different ID in the request body. User and tenant IDs for write operations must always come from the authenticated session, never from client input.`,
4883
+ severity: "critical",
4884
+ confidence: 0.85,
4885
+ category: "authorization",
4886
+ evidence,
4887
+ remediation: {
4888
+ recommendedFix: `Always derive user/tenant IDs from the authenticated session:
4889
+
4890
+ // UNSAFE - trusting client input
4891
+ const { userId, content } = await request.json();
4892
+ await prisma.post.create({ data: { content, authorId: userId } });
4893
+
4894
+ // SAFE - using session
4895
+ const session = await getServerSession();
4896
+ const { content } = await request.json();
4897
+ await prisma.post.create({ data: { content, authorId: session.user.id } });
4898
+
4899
+ Never accept user/tenant identifiers from client-controlled input.`
4900
+ },
4901
+ links: {
4902
+ owasp: "https://owasp.org/Top10/A01_2021-Broken_Access_Control/",
4903
+ cwe: "https://cwe.mitre.org/data/definitions/639.html"
4904
+ },
4905
+ fingerprint
4906
+ });
4907
+ }
4908
+ }
4909
+ return findings;
4910
+ }
4911
+
4912
+ // src/scanners/authorization/index.ts
4913
+ var authorizationPack = {
4914
+ id: "authorization",
4915
+ name: "Authorization Semantics",
4916
+ scanners: [
4917
+ scanAdminRouteNoRoleGuard,
4918
+ scanOwnershipCheckMissing,
4919
+ scanRoleDeclaredNotEnforced,
4920
+ scanTrustedClientId
4921
+ ]
4922
+ };
4923
+
4924
+ // src/scanners/lifecycle/create-update-asymmetry.ts
4925
+ var RULE_ID24 = "VC-LIFE-001";
4926
+ function extractEntityName(filePath) {
4927
+ const normalized = filePath.replace(/\\/g, "/");
4928
+ const match = normalized.match(/\/api\/([a-z]+(?:-[a-z]+)*)/i);
4929
+ if (match) {
4930
+ return match[1].toLowerCase();
4931
+ }
4932
+ return null;
4933
+ }
4934
+ function getBasePath(filePath) {
4935
+ const normalized = filePath.replace(/\\/g, "/");
4936
+ return normalized.replace(/\/\[[^\]]+\]/g, "").replace(/\/route\.[tj]sx?$/, "");
4937
+ }
4938
+ function hasAuthCheck2(handlerText) {
4939
+ const authPatterns = [
4940
+ /getServerSession\s*\(/,
4941
+ /auth\s*\(\)/,
4942
+ /requireAuth\s*\(/,
4943
+ /checkAuth\s*\(/,
4944
+ /isAuthenticated/,
4945
+ /session\s*\?\./,
4946
+ /!session\b/,
4947
+ /session\s*===?\s*null/,
4948
+ /currentUser/,
4949
+ /req\.user/,
4950
+ /middleware.*auth/i,
4951
+ /withAuth\s*\(/,
4952
+ /protectedRoute/i,
4953
+ /AuthGuard/
4954
+ ];
4955
+ return authPatterns.some((pattern) => pattern.test(handlerText));
4956
+ }
4957
+ async function scanCreateUpdateAsymmetry(context) {
4958
+ const { repoRoot, fileIndex, helpers, repoMeta } = context;
4959
+ const findings = [];
4960
+ if (repoMeta.framework !== "next") {
4961
+ return findings;
4962
+ }
4963
+ const entityGroups = /* @__PURE__ */ new Map();
4964
+ for (const relPath of fileIndex.apiRouteFiles) {
4965
+ const absPath = resolvePath(repoRoot, relPath);
4966
+ const sourceFile = helpers.parseFile(absPath);
4967
+ if (!sourceFile) continue;
4968
+ const entityName = extractEntityName(relPath);
4969
+ if (!entityName) continue;
4970
+ const basePath = getBasePath(relPath);
4971
+ const handlers = helpers.findRouteHandlers(sourceFile);
4972
+ for (const handler of handlers) {
4973
+ const handlerText = helpers.getNodeText(handler.functionNode);
4974
+ const hasAuth = hasAuthCheck2(handlerText);
4975
+ const groupKey = entityName;
4976
+ if (!entityGroups.has(groupKey)) {
4977
+ entityGroups.set(groupKey, {
4978
+ entityName,
4979
+ basePath,
4980
+ handlers: []
4981
+ });
4982
+ }
4983
+ entityGroups.get(groupKey).handlers.push({
4984
+ method: handler.method,
4985
+ file: relPath,
4986
+ handler,
4987
+ hasAuth,
4988
+ handlerText
4989
+ });
4990
+ }
4991
+ }
4992
+ for (const [entityKey, group] of entityGroups) {
4993
+ const protectedCreates = group.handlers.filter(
4994
+ (h) => h.method === "POST" && h.hasAuth
4995
+ );
4996
+ if (protectedCreates.length === 0) continue;
4997
+ const unprotectedUpdates = group.handlers.filter(
4998
+ (h) => (h.method === "PUT" || h.method === "PATCH") && !h.hasAuth
4999
+ );
5000
+ for (const unprotected of unprotectedUpdates) {
5001
+ const protectedExample = protectedCreates[0];
5002
+ const evidence = [
5003
+ {
5004
+ file: protectedExample.file,
5005
+ startLine: protectedExample.handler.startLine,
5006
+ endLine: Math.min(protectedExample.handler.startLine + 10, protectedExample.handler.endLine),
5007
+ snippet: protectedExample.handlerText.slice(0, 300) + "...",
5008
+ label: `Protected POST handler (creates ${group.entityName})`
5009
+ },
5010
+ {
5011
+ file: unprotected.file,
5012
+ startLine: unprotected.handler.startLine,
5013
+ endLine: Math.min(unprotected.handler.startLine + 10, unprotected.handler.endLine),
5014
+ snippet: unprotected.handlerText.slice(0, 300) + "...",
5015
+ label: `Unprotected ${unprotected.method} handler (updates ${group.entityName})`
5016
+ }
5017
+ ];
5018
+ const fingerprint = generateFingerprint({
5019
+ ruleId: RULE_ID24,
5020
+ file: unprotected.file,
5021
+ symbol: unprotected.method,
5022
+ route: group.basePath
5023
+ });
5024
+ findings.push({
5025
+ id: generateFindingId({
5026
+ ruleId: RULE_ID24,
5027
+ file: unprotected.file,
5028
+ symbol: unprotected.method
5029
+ }),
5030
+ ruleId: RULE_ID24,
5031
+ title: `Create-update asymmetry for ${group.entityName}: POST protected but ${unprotected.method} is not`,
5032
+ description: `The POST handler for '${group.entityName}' requires authentication, but the ${unprotected.method} handler that modifies the same entity type does not have equivalent protection. This creates a lifecycle vulnerability where authenticated creation can be bypassed by unauthenticated modification. An attacker could modify ${group.entityName} records without proper authorization.`,
5033
+ severity: "high",
5034
+ confidence: 0.85,
5035
+ category: "lifecycle",
5036
+ evidence,
5037
+ remediation: {
5038
+ recommendedFix: `Add authentication checks to the ${unprotected.method} handler:
5039
+
5040
+ export async function ${unprotected.method}(request: Request) {
5041
+ const session = await getServerSession();
5042
+ if (!session) {
5043
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
5044
+ }
5045
+ // ... rest of handler
5046
+ }
5047
+
5048
+ Ensure all state-changing operations on '${group.entityName}' have consistent auth requirements.`
5049
+ },
5050
+ links: {
5051
+ owasp: "https://owasp.org/Top10/A01_2021-Broken_Access_Control/",
5052
+ cwe: "https://cwe.mitre.org/data/definitions/862.html"
5053
+ },
5054
+ fingerprint
5055
+ });
5056
+ }
5057
+ }
5058
+ return findings;
5059
+ }
5060
+
5061
+ // src/scanners/lifecycle/validation-schema-drift.ts
5062
+ var RULE_ID25 = "VC-LIFE-002";
5063
+ var VALIDATION_PATTERNS = [
5064
+ { pattern: /\.parse\s*\(/, library: "zod" },
5065
+ { pattern: /\.safeParse\s*\(/, library: "zod" },
5066
+ { pattern: /\.parseAsync\s*\(/, library: "zod" },
5067
+ { pattern: /z\.\w+\(\)/, library: "zod" },
5068
+ { pattern: /yup\.object\s*\(/, library: "yup" },
5069
+ { pattern: /\.validate\s*\(/, library: "yup/joi" },
5070
+ { pattern: /\.validateSync\s*\(/, library: "yup" },
5071
+ { pattern: /Joi\.object\s*\(/, library: "joi" },
5072
+ { pattern: /schema\.validate\s*\(/, library: "joi" },
5073
+ { pattern: /valibot\./i, library: "valibot" },
5074
+ { pattern: /v\.\w+\(\)/, library: "valibot" },
5075
+ { pattern: /ajv\.compile\s*\(/, library: "ajv" },
5076
+ { pattern: /validate\s*\(\s*body/, library: "custom" },
5077
+ { pattern: /validateBody\s*\(/, library: "custom" },
5078
+ { pattern: /validateRequest\s*\(/, library: "custom" }
5079
+ ];
5080
+ function extractEntityName2(filePath) {
5081
+ const normalized = filePath.replace(/\\/g, "/");
5082
+ const match = normalized.match(/\/api\/([a-z]+(?:-[a-z]+)*)/i);
5083
+ if (match) {
5084
+ return match[1].toLowerCase();
5085
+ }
5086
+ return null;
5087
+ }
5088
+ function getBasePath2(filePath) {
5089
+ const normalized = filePath.replace(/\\/g, "/");
5090
+ return normalized.replace(/\/\[[^\]]+\]/g, "").replace(/\/route\.[tj]sx?$/, "");
5091
+ }
5092
+ function detectValidation(handlerText) {
5093
+ for (const { pattern, library } of VALIDATION_PATTERNS) {
5094
+ if (pattern.test(handlerText)) {
5095
+ return { hasValidation: true, library };
5096
+ }
5097
+ }
5098
+ return { hasValidation: false };
5099
+ }
5100
+ function readsRequestBody(handlerText) {
5101
+ return /request\.json\s*\(\)|req\.body|formData\s*\(\)|\.text\s*\(\)/.test(handlerText);
5102
+ }
5103
+ async function scanValidationSchemaDrift(context) {
5104
+ const { repoRoot, fileIndex, helpers, repoMeta } = context;
5105
+ const findings = [];
5106
+ if (repoMeta.framework !== "next") {
5107
+ return findings;
5108
+ }
5109
+ const entityGroups = /* @__PURE__ */ new Map();
5110
+ for (const relPath of fileIndex.apiRouteFiles) {
5111
+ const absPath = resolvePath(repoRoot, relPath);
5112
+ const sourceFile = helpers.parseFile(absPath);
5113
+ if (!sourceFile) continue;
5114
+ const entityName = extractEntityName2(relPath);
5115
+ if (!entityName) continue;
5116
+ const basePath = getBasePath2(relPath);
5117
+ const handlers = helpers.findRouteHandlers(sourceFile);
5118
+ for (const handler of handlers) {
5119
+ if (!["POST", "PUT", "PATCH"].includes(handler.method)) continue;
5120
+ const handlerText = helpers.getNodeText(handler.functionNode);
5121
+ if (!readsRequestBody(handlerText)) continue;
5122
+ const { hasValidation, library } = detectValidation(handlerText);
5123
+ const groupKey = entityName;
5124
+ if (!entityGroups.has(groupKey)) {
5125
+ entityGroups.set(groupKey, {
5126
+ entityName,
5127
+ basePath,
5128
+ handlers: []
5129
+ });
5130
+ }
5131
+ entityGroups.get(groupKey).handlers.push({
5132
+ method: handler.method,
5133
+ file: relPath,
5134
+ handler,
5135
+ hasValidation,
5136
+ validationLibrary: library,
5137
+ handlerText
5138
+ });
5139
+ }
5140
+ }
5141
+ for (const [entityKey, group] of entityGroups) {
5142
+ const validatedCreates = group.handlers.filter(
5143
+ (h) => h.method === "POST" && h.hasValidation
5144
+ );
5145
+ if (validatedCreates.length === 0) continue;
5146
+ const unvalidatedUpdates = group.handlers.filter(
5147
+ (h) => (h.method === "PUT" || h.method === "PATCH") && !h.hasValidation
5148
+ );
5149
+ for (const unvalidated of unvalidatedUpdates) {
5150
+ const validatedExample = validatedCreates[0];
5151
+ const evidence = [
5152
+ {
5153
+ file: validatedExample.file,
5154
+ startLine: validatedExample.handler.startLine,
5155
+ endLine: Math.min(validatedExample.handler.startLine + 15, validatedExample.handler.endLine),
5156
+ snippet: validatedExample.handlerText.slice(0, 350) + "...",
5157
+ label: `Validated POST handler using ${validatedExample.validationLibrary || "validation"}`
5158
+ },
5159
+ {
5160
+ file: unvalidated.file,
5161
+ startLine: unvalidated.handler.startLine,
5162
+ endLine: Math.min(unvalidated.handler.startLine + 15, unvalidated.handler.endLine),
5163
+ snippet: unvalidated.handlerText.slice(0, 350) + "...",
5164
+ label: `Unvalidated ${unvalidated.method} handler - reads body without schema validation`
5165
+ }
5166
+ ];
5167
+ const fingerprint = generateFingerprint({
5168
+ ruleId: RULE_ID25,
5169
+ file: unvalidated.file,
5170
+ symbol: unvalidated.method,
5171
+ route: group.basePath
5172
+ });
5173
+ findings.push({
5174
+ id: generateFindingId({
5175
+ ruleId: RULE_ID25,
5176
+ file: unvalidated.file,
5177
+ symbol: unvalidated.method
5178
+ }),
5179
+ ruleId: RULE_ID25,
5180
+ title: `Validation schema drift for ${group.entityName}: POST validated but ${unvalidated.method} is not`,
5181
+ description: `The POST handler for '${group.entityName}' validates input using ${validatedExample.validationLibrary || "schema validation"}, but the ${unvalidated.method} handler accepts unvalidated request body. This "schema drift" means validation rules applied during creation can be bypassed during updates. Attackers could inject malformed data, exceed field limits, or introduce invalid state through the update endpoint.`,
5182
+ severity: "medium",
5183
+ confidence: 0.8,
5184
+ category: "lifecycle",
5185
+ evidence,
5186
+ remediation: {
5187
+ recommendedFix: `Apply consistent validation to the ${unvalidated.method} handler:
5188
+
5189
+ // If using Zod:
5190
+ const updateSchema = ${group.entityName}Schema.partial(); // Allow partial updates
5191
+
5192
+ export async function ${unvalidated.method}(request: Request) {
5193
+ const body = await request.json();
5194
+ const validated = updateSchema.parse(body);
5195
+ // Use validated data
5196
+ }
5197
+
5198
+ Consider creating a shared schema or using .partial() for update operations.`
5199
+ },
5200
+ links: {
5201
+ owasp: "https://owasp.org/Top10/A03_2021-Injection/",
5202
+ cwe: "https://cwe.mitre.org/data/definitions/20.html"
5203
+ },
5204
+ fingerprint
5205
+ });
5206
+ }
5207
+ }
5208
+ return findings;
5209
+ }
5210
+
5211
+ // src/scanners/lifecycle/delete-rate-limit-gap.ts
5212
+ var RULE_ID26 = "VC-LIFE-003";
5213
+ var RATE_LIMIT_PATTERNS = [
5214
+ // Wrapper functions
5215
+ { pattern: /withRateLimit\s*\(/, type: "wrapper" },
5216
+ { pattern: /rateLimit\s*\(/, type: "wrapper" },
5217
+ { pattern: /rateLimiter\s*\(/, type: "wrapper" },
5218
+ { pattern: /throttle\s*\(/, type: "wrapper" },
5219
+ // Import-based detection
5220
+ { pattern: /import.*(?:rateLimit|RateLimiter).*from/, type: "import" },
5221
+ { pattern: /import.*upstash.*ratelimit/i, type: "upstash" },
5222
+ { pattern: /import.*@upstash\/ratelimit/i, type: "upstash" },
5223
+ // Direct library usage
5224
+ { pattern: /Ratelimit\s*\(/, type: "upstash" },
5225
+ { pattern: /new\s+RateLimiter\s*\(/, type: "custom" },
5226
+ { pattern: /limiter\.limit\s*\(/, type: "custom" },
5227
+ { pattern: /rateLimiter\.check\s*\(/, type: "custom" },
5228
+ // Redis-based rate limiting
5229
+ { pattern: /redis\.incr\s*\(.*rate/i, type: "redis" },
5230
+ { pattern: /INCR.*:ratelimit/i, type: "redis" },
5231
+ // Comment/identifier hints
5232
+ { pattern: /@rateLimit/i, type: "decorator" },
5233
+ { pattern: /RateLimited/i, type: "decorator" },
5234
+ // Middleware patterns
5235
+ { pattern: /app\.use\s*\(\s*rateLimit/i, type: "middleware" },
5236
+ { pattern: /limiter\s*=.*sliding.*window/i, type: "custom" }
5237
+ ];
5238
+ function extractEntityName3(filePath) {
5239
+ const normalized = filePath.replace(/\\/g, "/");
5240
+ const match = normalized.match(/\/api\/([a-z]+(?:-[a-z]+)*)/i);
5241
+ if (match) {
5242
+ return match[1].toLowerCase();
5243
+ }
5244
+ return null;
5245
+ }
5246
+ function getBasePath3(filePath) {
5247
+ const normalized = filePath.replace(/\\/g, "/");
5248
+ return normalized.replace(/\/\[[^\]]+\]/g, "").replace(/\/route\.[tj]sx?$/, "");
5249
+ }
5250
+ function detectRateLimit(handlerText, fullFileText) {
5251
+ for (const { pattern, type } of RATE_LIMIT_PATTERNS) {
5252
+ if (pattern.test(handlerText)) {
5253
+ return { hasRateLimit: true, type };
5254
+ }
5255
+ }
5256
+ const filePatterns = [
5257
+ /import.*(?:rateLimit|RateLimiter).*from/i,
5258
+ /const\s+(?:rateLimit|limiter)\s*=/i,
5259
+ /withRateLimit/
5260
+ ];
5261
+ for (const pattern of filePatterns) {
5262
+ if (pattern.test(fullFileText)) {
5263
+ if (/withRateLimit\s*\(/.test(handlerText) || /limiter\./.test(handlerText)) {
5264
+ return { hasRateLimit: true, type: "file-level" };
5265
+ }
5266
+ }
5267
+ }
5268
+ return { hasRateLimit: false };
5269
+ }
5270
+ function hasDestructiveOps(handlerText) {
5271
+ return /\.delete\s*\(|\.destroy\s*\(|\.remove\s*\(|prisma\.\w+\.delete|deleteMany\s*\(/i.test(handlerText);
5272
+ }
5273
+ async function scanDeleteRateLimitGap(context) {
5274
+ const { repoRoot, fileIndex, helpers, repoMeta } = context;
5275
+ const findings = [];
5276
+ if (repoMeta.framework !== "next") {
5277
+ return findings;
5278
+ }
5279
+ const entityGroups = /* @__PURE__ */ new Map();
5280
+ for (const relPath of fileIndex.apiRouteFiles) {
5281
+ const absPath = resolvePath(repoRoot, relPath);
5282
+ const sourceFile = helpers.parseFile(absPath);
5283
+ if (!sourceFile) continue;
5284
+ const entityName = extractEntityName3(relPath);
5285
+ if (!entityName) continue;
5286
+ const basePath = getBasePath3(relPath);
5287
+ const handlers = helpers.findRouteHandlers(sourceFile);
5288
+ const fullFileText = sourceFile.getFullText();
5289
+ for (const handler of handlers) {
5290
+ if (!["POST", "PUT", "PATCH", "DELETE"].includes(handler.method)) continue;
5291
+ const handlerText = helpers.getNodeText(handler.functionNode);
5292
+ const { hasRateLimit, type } = detectRateLimit(handlerText, fullFileText);
5293
+ const groupKey = entityName;
5294
+ if (!entityGroups.has(groupKey)) {
5295
+ entityGroups.set(groupKey, {
5296
+ entityName,
5297
+ basePath,
5298
+ handlers: []
5299
+ });
5300
+ }
5301
+ entityGroups.get(groupKey).handlers.push({
5302
+ method: handler.method,
5303
+ file: relPath,
5304
+ handler,
5305
+ hasRateLimit,
5306
+ rateLimitType: type,
5307
+ handlerText,
5308
+ fullFileText
5309
+ });
5310
+ }
5311
+ }
5312
+ for (const [entityKey, group] of entityGroups) {
5313
+ const rateLimitedHandlers = group.handlers.filter(
5314
+ (h) => h.method !== "DELETE" && h.hasRateLimit
5315
+ );
5316
+ if (rateLimitedHandlers.length === 0) continue;
5317
+ const unprotectedDeletes = group.handlers.filter(
5318
+ (h) => h.method === "DELETE" && !h.hasRateLimit && hasDestructiveOps(h.handlerText)
5319
+ );
5320
+ for (const unprotected of unprotectedDeletes) {
5321
+ const rateLimitedExample = rateLimitedHandlers[0];
5322
+ const evidence = [
5323
+ {
5324
+ file: rateLimitedExample.file,
5325
+ startLine: rateLimitedExample.handler.startLine,
5326
+ endLine: Math.min(rateLimitedExample.handler.startLine + 10, rateLimitedExample.handler.endLine),
5327
+ snippet: rateLimitedExample.handlerText.slice(0, 250) + "...",
5328
+ label: `Rate-limited ${rateLimitedExample.method} handler (${rateLimitedExample.rateLimitType || "rate limit"})`
5329
+ },
5330
+ {
5331
+ file: unprotected.file,
5332
+ startLine: unprotected.handler.startLine,
5333
+ endLine: Math.min(unprotected.handler.startLine + 10, unprotected.handler.endLine),
5334
+ snippet: unprotected.handlerText.slice(0, 250) + "...",
5335
+ label: `DELETE handler without rate limiting - enables mass deletion`
5336
+ }
5337
+ ];
5338
+ const fingerprint = generateFingerprint({
5339
+ ruleId: RULE_ID26,
5340
+ file: unprotected.file,
5341
+ symbol: "DELETE",
5342
+ route: group.basePath
5343
+ });
5344
+ findings.push({
5345
+ id: generateFindingId({
5346
+ ruleId: RULE_ID26,
5347
+ file: unprotected.file,
5348
+ symbol: "DELETE"
5349
+ }),
5350
+ ruleId: RULE_ID26,
5351
+ title: `Delete rate limit gap for ${group.entityName}: other methods rate-limited but DELETE is not`,
5352
+ description: `The ${rateLimitedExample.method} handler for '${group.entityName}' has rate limiting, but the DELETE handler does not. This asymmetry allows attackers to rapidly delete resources without throttling. A malicious actor could enumerate IDs and mass-delete ${group.entityName} records, causing data loss that would be prevented on other operations.`,
5353
+ severity: "medium",
5354
+ confidence: 0.75,
5355
+ category: "lifecycle",
5356
+ evidence,
5357
+ remediation: {
5358
+ recommendedFix: `Apply rate limiting to the DELETE handler:
5359
+
5360
+ // Using the same rate limiter as other endpoints:
5361
+ export const DELETE = withRateLimit(async (request: Request) => {
5362
+ // ... delete logic
5363
+ });
5364
+
5365
+ // Or with Upstash:
5366
+ import { Ratelimit } from "@upstash/ratelimit";
5367
+ const ratelimit = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, "1 m") });
5368
+
5369
+ export async function DELETE(request: Request) {
5370
+ const { success } = await ratelimit.limit(userId);
5371
+ if (!success) return Response.json({ error: "Too many requests" }, { status: 429 });
5372
+ // ... delete logic
5373
+ }`
5374
+ },
5375
+ links: {
5376
+ owasp: "https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/",
5377
+ cwe: "https://cwe.mitre.org/data/definitions/770.html"
5378
+ },
5379
+ fingerprint
5380
+ });
5381
+ }
5382
+ }
5383
+ return findings;
5384
+ }
5385
+
5386
+ // src/scanners/lifecycle/index.ts
5387
+ var lifecyclePack = {
5388
+ id: "lifecycle",
5389
+ name: "Lifecycle Security Invariants",
5390
+ scanners: [
5391
+ scanCreateUpdateAsymmetry,
5392
+ scanValidationSchemaDrift,
5393
+ scanDeleteRateLimitGap
5394
+ ]
5395
+ };
5396
+
5397
+ // src/scanners/supply-chain/lockfile-parser.ts
5398
+ import * as YAML from "yaml";
5399
+ function parsePackageJson(repoRoot) {
5400
+ const pkgPath = resolvePath(repoRoot, "package.json");
5401
+ const content = readFileSync(pkgPath);
5402
+ if (!content) return null;
5403
+ try {
5404
+ return JSON.parse(content);
5405
+ } catch {
5406
+ return null;
5407
+ }
5408
+ }
5409
+ function parsePnpmLock(content) {
5410
+ const packages = /* @__PURE__ */ new Map();
5411
+ try {
5412
+ const parsed = YAML.parse(content);
5413
+ const pkgs = parsed.packages || {};
5414
+ for (const [key, value] of Object.entries(pkgs)) {
5415
+ if (!value || typeof value !== "object") continue;
5416
+ const pkgData = value;
5417
+ const match = key.match(/^\/?(@?[^@]+)@(.+)$/);
5418
+ if (!match) continue;
5419
+ const [, name, version] = match;
5420
+ packages.set(name, {
5421
+ name,
5422
+ version,
5423
+ hasInstallScripts: Boolean(pkgData.hasInstallScript || pkgData.requiresBuild),
5424
+ dependencies: pkgData.dependencies
5425
+ });
5426
+ }
5427
+ } catch {
5428
+ }
5429
+ return packages;
5430
+ }
5431
+ function parseYarnLock(content) {
5432
+ const packages = /* @__PURE__ */ new Map();
5433
+ try {
5434
+ const entries = content.split(/\n(?=[@\w"])/);
5435
+ for (const entry of entries) {
5436
+ if (!entry.trim()) continue;
5437
+ const headerMatch = entry.match(/^"?(@?[^@\s"]+)@[^":\n]+/);
5438
+ if (!headerMatch) continue;
5439
+ const name = headerMatch[1];
5440
+ const versionMatch = entry.match(/\n\s+version\s+"([^"]+)"/);
5441
+ const version = versionMatch ? versionMatch[1] : "unknown";
5442
+ packages.set(name, {
5443
+ name,
5444
+ version,
5445
+ hasInstallScripts: false
5446
+ // yarn.lock v1 doesn't include this
5447
+ });
5448
+ }
5449
+ } catch {
5450
+ }
5451
+ return packages;
5452
+ }
5453
+ function parseNpmLock(content) {
5454
+ const packages = /* @__PURE__ */ new Map();
5455
+ try {
5456
+ const parsed = JSON.parse(content);
5457
+ const pkgs = parsed.packages || {};
5458
+ const deps = parsed.dependencies || {};
5459
+ for (const [key, value] of Object.entries(pkgs)) {
5460
+ if (!key || key === "") continue;
5461
+ if (!value || typeof value !== "object") continue;
5462
+ const pkgData = value;
5463
+ const name = key.replace(/^node_modules\//, "").replace(/^.*node_modules\//, "");
5464
+ if (!name) continue;
5465
+ packages.set(name, {
5466
+ name,
5467
+ version: pkgData.version || "unknown",
5468
+ hasInstallScripts: Boolean(pkgData.hasInstallScript),
5469
+ scripts: pkgData.scripts
5470
+ });
5471
+ }
5472
+ for (const [name, value] of Object.entries(deps)) {
5473
+ if (!value || typeof value !== "object") continue;
5474
+ if (packages.has(name)) continue;
5475
+ const pkgData = value;
5476
+ packages.set(name, {
5477
+ name,
5478
+ version: pkgData.version || "unknown",
5479
+ hasInstallScripts: Boolean(pkgData.hasInstallScript)
5480
+ });
5481
+ }
5482
+ } catch {
5483
+ }
5484
+ return packages;
5485
+ }
5486
+ function parseLockfile(repoRoot) {
5487
+ const pkg = parsePackageJson(repoRoot);
5488
+ const directDependencies = /* @__PURE__ */ new Set();
5489
+ if (pkg) {
5490
+ for (const name of Object.keys(pkg.dependencies || {})) {
5491
+ directDependencies.add(name);
5492
+ }
5493
+ for (const name of Object.keys(pkg.devDependencies || {})) {
5494
+ directDependencies.add(name);
5495
+ }
5496
+ }
5497
+ const pnpmPath = resolvePath(repoRoot, "pnpm-lock.yaml");
5498
+ if (fileExists(pnpmPath)) {
5499
+ const content = readFileSync(pnpmPath);
5500
+ if (content) {
5501
+ return {
5502
+ type: "pnpm",
5503
+ path: pnpmPath,
5504
+ packages: parsePnpmLock(content),
5505
+ directDependencies
5506
+ };
5507
+ }
5508
+ }
5509
+ const yarnPath = resolvePath(repoRoot, "yarn.lock");
5510
+ if (fileExists(yarnPath)) {
5511
+ const content = readFileSync(yarnPath);
5512
+ if (content) {
5513
+ return {
5514
+ type: "yarn",
5515
+ path: yarnPath,
5516
+ packages: parseYarnLock(content),
5517
+ directDependencies
5518
+ };
5519
+ }
5520
+ }
5521
+ const npmPath = resolvePath(repoRoot, "package-lock.json");
5522
+ if (fileExists(npmPath)) {
5523
+ const content = readFileSync(npmPath);
5524
+ if (content) {
5525
+ return {
5526
+ type: "npm",
5527
+ path: npmPath,
5528
+ packages: parseNpmLock(content),
5529
+ directDependencies
5530
+ };
5531
+ }
5532
+ }
5533
+ return {
5534
+ type: "none",
5535
+ path: null,
5536
+ packages: /* @__PURE__ */ new Map(),
5537
+ directDependencies
5538
+ };
5539
+ }
5540
+ function isVersionRange(version) {
5541
+ if (version.startsWith("workspace:")) return false;
5542
+ if (version.startsWith("file:") || version.startsWith("git") || version.includes("://")) {
5543
+ return false;
5544
+ }
5545
+ const rangeIndicators = /[\^~><*x|]|\s-\s/;
5546
+ return rangeIndicators.test(version);
5547
+ }
5548
+ function getVersionRangeType(version) {
5549
+ if (version.startsWith("^")) return "caret (^) - allows minor updates";
5550
+ if (version.startsWith("~")) return "tilde (~) - allows patch updates";
5551
+ if (version.includes("||")) return "OR range - multiple versions";
5552
+ if (version === "*" || version === "x") return "wildcard - any version";
5553
+ if (version.includes(">") || version.includes("<")) return "comparison range";
5554
+ return "range";
5555
+ }
5556
+
5557
+ // src/scanners/supply-chain/security-critical-packages.ts
5558
+ var SECURITY_CRITICAL_PACKAGES = [
5559
+ // Authentication
5560
+ { name: "next-auth", category: "auth", reason: "Authentication framework" },
5561
+ { name: "@auth/core", category: "auth", reason: "Auth.js core" },
5562
+ { name: "@auth/prisma-adapter", category: "auth", reason: "Auth.js Prisma adapter" },
5563
+ { name: "passport", category: "auth", reason: "Authentication middleware" },
5564
+ { name: "passport-local", category: "auth", reason: "Local authentication strategy" },
5565
+ { name: "passport-jwt", category: "auth", reason: "JWT authentication strategy" },
5566
+ { name: "passport-oauth2", category: "auth", reason: "OAuth2 authentication" },
5567
+ { name: "@clerk/nextjs", category: "auth", reason: "Clerk authentication" },
5568
+ { name: "@supabase/auth-helpers-nextjs", category: "auth", reason: "Supabase auth" },
5569
+ { name: "firebase-admin", category: "auth", reason: "Firebase Admin SDK with auth" },
5570
+ { name: "@okta/okta-auth-js", category: "auth", reason: "Okta authentication" },
5571
+ { name: "auth0", category: "auth", reason: "Auth0 SDK" },
5572
+ { name: "@auth0/nextjs-auth0", category: "auth", reason: "Auth0 Next.js SDK" },
5573
+ { name: "lucia", category: "auth", reason: "Modern authentication library" },
5574
+ { name: "better-auth", category: "auth", reason: "Authentication library" },
5575
+ // Cryptography
5576
+ { name: "bcrypt", category: "crypto", reason: "Password hashing" },
5577
+ { name: "bcryptjs", category: "crypto", reason: "Password hashing (JS)" },
5578
+ { name: "argon2", category: "crypto", reason: "Password hashing (Argon2)" },
5579
+ { name: "scrypt", category: "crypto", reason: "Password hashing (scrypt)" },
5580
+ { name: "jsonwebtoken", category: "crypto", reason: "JWT signing/verification" },
5581
+ { name: "jose", category: "crypto", reason: "JWT/JWS/JWE implementation" },
5582
+ { name: "crypto-js", category: "crypto", reason: "Cryptographic operations" },
5583
+ { name: "node-forge", category: "crypto", reason: "Cryptographic toolkit" },
5584
+ { name: "tweetnacl", category: "crypto", reason: "Cryptographic library" },
5585
+ { name: "sodium-native", category: "crypto", reason: "libsodium bindings" },
5586
+ { name: "libsodium-wrappers", category: "crypto", reason: "libsodium for JS" },
5587
+ // Session management
5588
+ { name: "express-session", category: "session", reason: "Session middleware" },
5589
+ { name: "cookie-session", category: "session", reason: "Cookie-based sessions" },
5590
+ { name: "iron-session", category: "session", reason: "Encrypted sessions" },
5591
+ { name: "connect-redis", category: "session", reason: "Redis session store" },
5592
+ { name: "connect-mongo", category: "session", reason: "MongoDB session store" },
5593
+ // Validation
5594
+ { name: "zod", category: "validation", reason: "Schema validation" },
5595
+ { name: "yup", category: "validation", reason: "Schema validation" },
5596
+ { name: "joi", category: "validation", reason: "Schema validation" },
5597
+ { name: "ajv", category: "validation", reason: "JSON Schema validation" },
5598
+ { name: "validator", category: "validation", reason: "String validation" },
5599
+ { name: "express-validator", category: "validation", reason: "Express validation" },
5600
+ { name: "class-validator", category: "validation", reason: "Class-based validation" },
5601
+ // Security-sensitive network
5602
+ { name: "helmet", category: "network", reason: "Security headers" },
5603
+ { name: "cors", category: "network", reason: "CORS configuration" },
5604
+ { name: "csurf", category: "network", reason: "CSRF protection" },
5605
+ { name: "express-rate-limit", category: "network", reason: "Rate limiting" },
5606
+ { name: "@upstash/ratelimit", category: "network", reason: "Rate limiting" },
5607
+ { name: "rate-limiter-flexible", category: "network", reason: "Rate limiting" },
5608
+ { name: "hpp", category: "network", reason: "HTTP Parameter Pollution protection" }
5609
+ ];
5610
+ var SECURITY_CRITICAL_MAP = new Map(
5611
+ SECURITY_CRITICAL_PACKAGES.map((pkg) => [pkg.name, pkg])
5612
+ );
5613
+ function isSecurityCritical(name) {
5614
+ return SECURITY_CRITICAL_MAP.get(name);
5615
+ }
5616
+ var AUTH_LIBRARY_GROUPS = {
5617
+ nextAuth: ["next-auth", "@auth/core"],
5618
+ clerk: ["@clerk/nextjs", "@clerk/clerk-js"],
5619
+ supabase: ["@supabase/auth-helpers-nextjs", "@supabase/supabase-js"],
5620
+ firebase: ["firebase-admin", "firebase"],
5621
+ auth0: ["auth0", "@auth0/nextjs-auth0", "@auth0/auth0-spa-js"],
5622
+ okta: ["@okta/okta-auth-js", "@okta/okta-react"],
5623
+ passport: ["passport"],
5624
+ lucia: ["lucia"],
5625
+ jwt: ["jsonwebtoken", "jose"],
5626
+ betterAuth: ["better-auth"]
5627
+ };
5628
+ function detectAuthLibraries(dependencies) {
5629
+ const detected = [];
5630
+ for (const [group, packages] of Object.entries(AUTH_LIBRARY_GROUPS)) {
5631
+ if (packages.some((pkg) => pkg in dependencies)) {
5632
+ detected.push(group);
5633
+ }
5634
+ }
5635
+ return detected;
5636
+ }
5637
+ var SUSPICIOUS_SCRIPT_PATTERNS = [
5638
+ { pattern: /curl\s+.*\s*\|\s*(bash|sh)/i, reason: "Remote script execution" },
5639
+ { pattern: /wget\s+.*\s*\|\s*(bash|sh)/i, reason: "Remote script execution" },
5640
+ { pattern: /\beval\s*\(/i, reason: "Dynamic code execution" },
5641
+ { pattern: /base64\s+(-d|--decode)/i, reason: "Base64 decoding (possible obfuscation)" },
5642
+ { pattern: /\$\(.*\)/i, reason: "Command substitution" },
5643
+ { pattern: /`.*`/i, reason: "Command substitution (backticks)" },
5644
+ { pattern: /powershell/i, reason: "PowerShell execution" },
5645
+ { pattern: /\.exe\b/i, reason: "Windows executable reference" },
5646
+ { pattern: /\/etc\/passwd/i, reason: "System file access" },
5647
+ { pattern: /\/etc\/shadow/i, reason: "System file access" },
5648
+ { pattern: /ssh.*@/i, reason: "SSH connection attempt" },
5649
+ { pattern: /rm\s+-rf\s+\//i, reason: "Destructive command" },
5650
+ { pattern: /nc\s+-.*\d+/i, reason: "Netcat usage (potential backdoor)" },
5651
+ { pattern: /ncat/i, reason: "Ncat usage (potential backdoor)" },
5652
+ { pattern: /reverse.*shell/i, reason: "Reverse shell reference" },
5653
+ { pattern: /crypto.*wallet/i, reason: "Cryptocurrency reference" },
5654
+ { pattern: /bitcoin|ethereum|monero/i, reason: "Cryptocurrency reference" },
5655
+ { pattern: /keylog/i, reason: "Keylogger reference" },
5656
+ { pattern: /exfiltrat/i, reason: "Data exfiltration reference" },
5657
+ { pattern: /hidden|stealth/i, reason: "Stealth behavior reference" },
5658
+ { pattern: /process\.env\.[A-Z_]+.*https?:\/\//i, reason: "Env var with URL (potential C2)" }
5659
+ ];
5660
+ function findSuspiciousPatterns(script) {
5661
+ const found = [];
5662
+ for (const { pattern, reason } of SUSPICIOUS_SCRIPT_PATTERNS) {
5663
+ if (pattern.test(script)) {
5664
+ found.push({ pattern: pattern.source, reason });
5665
+ }
5666
+ }
5667
+ return found;
5668
+ }
5669
+
5670
+ // src/scanners/supply-chain/postinstall-scripts.ts
5671
+ var RULE_ID27 = "VC-SUP-001";
5672
+ var INSTALL_SCRIPT_KEYS = [
5673
+ "preinstall",
5674
+ "install",
5675
+ "postinstall",
5676
+ "prepare"
5677
+ ];
5678
+ async function scanPostinstallScripts(context) {
5679
+ const { repoRoot } = context;
5680
+ const findings = [];
5681
+ const pkg = parsePackageJson(repoRoot);
5682
+ if (!pkg || !pkg.scripts) {
5683
+ return findings;
5684
+ }
5685
+ for (const scriptKey of INSTALL_SCRIPT_KEYS) {
5686
+ const script = pkg.scripts[scriptKey];
5687
+ if (!script) continue;
5688
+ const suspicious = findSuspiciousPatterns(script);
5689
+ let severity = "low";
5690
+ let description = `The package.json contains a \`${scriptKey}\` script that runs during \`npm install\`. `;
5691
+ if (suspicious.length > 0) {
5692
+ severity = "high";
5693
+ description += `The script contains suspicious patterns:
5694
+ `;
5695
+ for (const { reason } of suspicious) {
5696
+ description += `- ${reason}
5697
+ `;
5698
+ }
5699
+ } else if (script.includes("node") || script.includes("npx")) {
5700
+ severity = "medium";
5701
+ description += `The script executes Node.js code which could perform arbitrary operations.`;
5702
+ } else {
5703
+ description += `While often legitimate, install scripts can be exploited for supply chain attacks. Review the script content.`;
5704
+ }
5705
+ const fingerprint = generateFingerprint({
5706
+ ruleId: RULE_ID27,
5707
+ file: "package.json",
5708
+ symbol: scriptKey
5709
+ });
5710
+ findings.push({
5711
+ id: generateFindingId({
5712
+ ruleId: RULE_ID27,
5713
+ file: "package.json",
5714
+ symbol: scriptKey
5715
+ }),
5716
+ severity,
5717
+ confidence: suspicious.length > 0 ? 0.9 : 0.7,
5718
+ category: "supply-chain",
5719
+ ruleId: RULE_ID27,
5720
+ title: `Install script detected: ${scriptKey}`,
5721
+ description,
5722
+ evidence: [
5723
+ {
5724
+ file: "package.json",
5725
+ startLine: 1,
5726
+ endLine: 1,
5727
+ snippet: `"${scriptKey}": "${script.length > 100 ? script.slice(0, 100) + "..." : script}"`,
5728
+ context: "Install script definition"
5729
+ }
5730
+ ],
5731
+ remediation: {
5732
+ recommendedFix: suspicious.length > 0 ? `Review and remove suspicious patterns from the ${scriptKey} script. Consider whether this script is necessary.` : `Review the ${scriptKey} script to ensure it performs only necessary build operations. Consider using --ignore-scripts flag in CI environments.`
5733
+ },
5734
+ links: {
5735
+ cwe: "https://cwe.mitre.org/data/definitions/829.html"
5736
+ },
5737
+ fingerprint
5738
+ });
5739
+ }
5740
+ return findings;
5741
+ }
5742
+
5743
+ // src/scanners/supply-chain/version-ranges.ts
5744
+ var RULE_ID28 = "VC-SUP-002";
5745
+ async function scanVersionRanges(context) {
5746
+ const { repoRoot } = context;
5747
+ const findings = [];
5748
+ const pkg = parsePackageJson(repoRoot);
5749
+ if (!pkg) {
5750
+ return findings;
5751
+ }
5752
+ const allDeps = {
5753
+ ...pkg.dependencies,
5754
+ ...pkg.devDependencies
5755
+ };
5756
+ for (const [name, version] of Object.entries(allDeps)) {
5757
+ const criticalInfo = isSecurityCritical(name);
5758
+ if (!criticalInfo) continue;
5759
+ if (!isVersionRange(version)) continue;
5760
+ const rangeType = getVersionRangeType(version);
5761
+ const isDevDep = name in (pkg.devDependencies || {});
5762
+ let severity = "medium";
5763
+ if (criticalInfo.category === "auth" || criticalInfo.category === "crypto") {
5764
+ severity = "high";
5765
+ }
5766
+ if (isDevDep) {
5767
+ severity = severity === "high" ? "medium" : "low";
5768
+ }
5769
+ const fingerprint = generateFingerprint({
5770
+ ruleId: RULE_ID28,
5771
+ file: "package.json",
5772
+ symbol: name
5773
+ });
5774
+ findings.push({
5775
+ id: generateFindingId({
5776
+ ruleId: RULE_ID28,
5777
+ file: "package.json",
5778
+ symbol: name
5779
+ }),
5780
+ severity,
5781
+ confidence: 0.85,
5782
+ category: "supply-chain",
5783
+ ruleId: RULE_ID28,
5784
+ title: `Unpinned security-critical dependency: ${name}`,
5785
+ description: `The ${criticalInfo.category} package \`${name}\` uses a version range (\`${version}\`) instead of a pinned version. This is a ${rangeType}. Version ranges allow automatic updates that could introduce breaking changes or supply chain attacks. Security-critical packages should use exact versions with lockfile commitment.`,
5786
+ evidence: [
5787
+ {
5788
+ file: "package.json",
5789
+ startLine: 1,
5790
+ endLine: 1,
5791
+ snippet: `"${name}": "${version}"`,
5792
+ context: `${isDevDep ? "devDependencies" : "dependencies"} - ${criticalInfo.reason}`
5793
+ }
5794
+ ],
5795
+ remediation: {
5796
+ recommendedFix: `Pin ${name} to an exact version (e.g., "1.2.3" instead of "${version}"). Ensure your lockfile (package-lock.json, yarn.lock, or pnpm-lock.yaml) is committed to version control. Use automated dependency update tools (Dependabot, Renovate) to receive controlled updates.`
5797
+ },
5798
+ links: {
5799
+ cwe: "https://cwe.mitre.org/data/definitions/829.html"
5800
+ },
5801
+ fingerprint
5802
+ });
5803
+ }
5804
+ return findings;
5805
+ }
5806
+
5807
+ // src/scanners/supply-chain/deprecated-packages.ts
5808
+ var DEPRECATED_PACKAGES = [
5809
+ // Security-critical deprecations
5810
+ {
5811
+ name: "request",
5812
+ reason: "Deprecated since 2020, no security patches",
5813
+ replacement: "node-fetch, axios, or got",
5814
+ severity: "high"
5815
+ },
5816
+ {
5817
+ name: "request-promise",
5818
+ reason: "Deprecated, depends on deprecated request",
5819
+ replacement: "node-fetch, axios, or got",
5820
+ severity: "high"
5821
+ },
5822
+ {
5823
+ name: "request-promise-native",
5824
+ reason: "Deprecated, depends on deprecated request",
5825
+ replacement: "node-fetch, axios, or got",
5826
+ severity: "high"
5827
+ },
5828
+ {
5829
+ name: "node-uuid",
5830
+ reason: "Deprecated, renamed to uuid",
5831
+ replacement: "uuid",
5832
+ severity: "low"
5833
+ },
5834
+ {
5835
+ name: "bcrypt-nodejs",
5836
+ reason: "Unmaintained, security concerns",
5837
+ replacement: "bcrypt or bcryptjs",
5838
+ severity: "high",
5839
+ advisory: "Multiple known vulnerabilities"
5840
+ },
5841
+ {
5842
+ name: "cryptiles",
5843
+ reason: "Deprecated due to timing attack vulnerabilities",
5844
+ replacement: "@hapi/cryptiles",
5845
+ severity: "critical",
5846
+ advisory: "CVE-2018-1000620"
5847
+ },
5848
+ {
5849
+ name: "hoek",
5850
+ reason: "Prototype pollution vulnerability",
5851
+ replacement: "@hapi/hoek >= 6.0.0",
5852
+ severity: "critical",
5853
+ advisory: "CVE-2018-3728"
5854
+ },
5855
+ {
5856
+ name: "lodash",
5857
+ reason: "Versions < 4.17.21 have prototype pollution vulnerabilities",
5858
+ severity: "medium",
5859
+ advisory: "CVE-2021-23337, CVE-2020-8203"
5860
+ },
5861
+ {
5862
+ name: "minimist",
5863
+ reason: "Versions < 1.2.6 have prototype pollution",
5864
+ severity: "medium",
5865
+ advisory: "CVE-2021-44906"
5866
+ },
5867
+ {
5868
+ name: "qs",
5869
+ reason: "Versions < 6.10.3 have prototype pollution",
5870
+ severity: "medium",
5871
+ advisory: "CVE-2022-24999"
5872
+ },
5873
+ {
5874
+ name: "ua-parser-js",
5875
+ reason: "Versions < 0.7.33 or 0.8.x/1.0.x compromised",
5876
+ severity: "critical",
5877
+ advisory: "Supply chain attack (malware)"
5878
+ },
5879
+ {
5880
+ name: "event-stream",
5881
+ reason: "Versions 3.3.6 contained malware targeting bitcoin wallets",
5882
+ severity: "critical",
5883
+ advisory: "Supply chain attack (bitcoin theft)"
5884
+ },
5885
+ {
5886
+ name: "flatmap-stream",
5887
+ reason: "Contained malicious code, part of event-stream attack",
5888
+ severity: "critical",
5889
+ advisory: "Supply chain attack"
5890
+ },
5891
+ {
5892
+ name: "coa",
5893
+ reason: "Versions 2.0.3+ briefly compromised",
5894
+ severity: "high",
5895
+ advisory: "Supply chain attack"
5896
+ },
5897
+ {
5898
+ name: "rc",
5899
+ reason: "Versions 1.2.9, 1.3.0, 2.3.9 briefly compromised",
5900
+ severity: "high",
5901
+ advisory: "Supply chain attack"
5902
+ },
5903
+ {
5904
+ name: "colors",
5905
+ reason: "Versions 1.4.1+ contain intentional sabotage code",
5906
+ replacement: "chalk or ansi-colors",
5907
+ severity: "high",
5908
+ advisory: "Protestware"
5909
+ },
5910
+ {
5911
+ name: "faker",
5912
+ reason: "Versions 6.6.6+ contain intentional sabotage code",
5913
+ replacement: "@faker-js/faker",
5914
+ severity: "high",
5915
+ advisory: "Protestware"
5916
+ },
5917
+ {
5918
+ name: "node-ipc",
5919
+ reason: "Versions 10.1.1-10.1.2 contained destructive code",
5920
+ severity: "critical",
5921
+ advisory: "Protestware/malware targeting Russia/Belarus"
5922
+ },
5923
+ {
5924
+ name: "merge",
5925
+ reason: "Prototype pollution in all versions",
5926
+ replacement: "lodash.merge or Object spread",
5927
+ severity: "medium",
5928
+ advisory: "CVE-2018-16469"
5929
+ },
5930
+ {
5931
+ name: "deep-extend",
5932
+ reason: "Versions < 0.6.0 have prototype pollution",
5933
+ severity: "high",
5934
+ advisory: "CVE-2018-3750"
5935
+ },
5936
+ {
5937
+ name: "mixin-deep",
5938
+ reason: "Versions < 1.3.2 have prototype pollution",
5939
+ severity: "high",
5940
+ advisory: "CVE-2019-10746"
5941
+ },
5942
+ {
5943
+ name: "set-value",
5944
+ reason: "Versions < 4.0.0 have prototype pollution",
5945
+ severity: "high",
5946
+ advisory: "CVE-2021-23440"
5947
+ },
5948
+ // Authentication/crypto deprecations
5949
+ {
5950
+ name: "passport-local-mongoose",
5951
+ reason: "Outdated, use @passport-js/passport-local-mongoose",
5952
+ severity: "low"
5953
+ },
5954
+ {
5955
+ name: "jwt-simple",
5956
+ reason: "Algorithm confusion vulnerability possible",
5957
+ replacement: "jsonwebtoken with explicit algorithms",
5958
+ severity: "high"
5959
+ },
5960
+ {
5961
+ name: "cookie-session",
5962
+ reason: "Consider express-session for sensitive data",
5963
+ severity: "low"
5964
+ },
5965
+ // Other security-relevant deprecations
5966
+ {
5967
+ name: "marked",
5968
+ reason: "Versions < 4.0.10 have ReDoS and XSS vulnerabilities",
5969
+ severity: "medium",
5970
+ advisory: "Multiple CVEs"
5971
+ },
5972
+ {
5973
+ name: "serialize-javascript",
5974
+ reason: "Versions < 3.1.0 have XSS vulnerability",
5975
+ severity: "high",
5976
+ advisory: "CVE-2020-7660"
5977
+ },
5978
+ {
5979
+ name: "sanitize-html",
5980
+ reason: "Versions < 2.7.1 have XSS bypass vulnerabilities",
5981
+ severity: "high",
5982
+ advisory: "Multiple CVEs"
5983
+ },
5984
+ {
5985
+ name: "tar",
5986
+ reason: "Versions < 6.1.11 have path traversal vulnerabilities",
5987
+ severity: "high",
5988
+ advisory: "CVE-2021-37701, CVE-2021-37712"
5989
+ },
5990
+ {
5991
+ name: "decompress",
5992
+ reason: "Arbitrary file write via path traversal",
5993
+ severity: "critical",
5994
+ advisory: "CVE-2020-12265"
5995
+ },
5996
+ {
5997
+ name: "unzip",
5998
+ reason: "Arbitrary file write via path traversal",
5999
+ replacement: "unzipper or yauzl",
6000
+ severity: "critical"
6001
+ },
6002
+ {
6003
+ name: "adm-zip",
6004
+ reason: "Versions < 0.5.2 have path traversal",
6005
+ severity: "high",
6006
+ advisory: "CVE-2018-1002204"
6007
+ },
6008
+ {
6009
+ name: "xmldom",
6010
+ reason: "Versions < 0.7.5 have XXE and other vulnerabilities",
6011
+ replacement: "@xmldom/xmldom",
6012
+ severity: "high"
6013
+ },
6014
+ {
6015
+ name: "xml2js",
6016
+ reason: "Prototype pollution in some versions",
6017
+ severity: "medium"
6018
+ }
6019
+ ];
6020
+ var DEPRECATED_PACKAGES_MAP = new Map(
6021
+ DEPRECATED_PACKAGES.map((pkg) => [pkg.name, pkg])
6022
+ );
6023
+ function isDeprecated(name) {
6024
+ return DEPRECATED_PACKAGES_MAP.get(name);
6025
+ }
6026
+
6027
+ // src/scanners/supply-chain/deprecated-packages-scanner.ts
6028
+ var RULE_ID29 = "VC-SUP-003";
6029
+ async function scanDeprecatedPackages(context) {
6030
+ const { repoRoot } = context;
6031
+ const findings = [];
6032
+ const pkg = parsePackageJson(repoRoot);
6033
+ if (!pkg) {
6034
+ return findings;
6035
+ }
6036
+ const allDeps = {
6037
+ ...pkg.dependencies,
6038
+ ...pkg.devDependencies
6039
+ };
6040
+ for (const [name, version] of Object.entries(allDeps)) {
6041
+ const deprecation = isDeprecated(name);
6042
+ if (!deprecation) continue;
6043
+ const isDevDep = name in (pkg.devDependencies || {});
6044
+ let severity = deprecation.severity;
6045
+ if (isDevDep && severity !== "critical") {
6046
+ severity = severity === "high" ? "medium" : "low";
6047
+ }
6048
+ let description = `The package \`${name}\` (${version}) is deprecated or has known vulnerabilities. `;
6049
+ description += `Reason: ${deprecation.reason}. `;
6050
+ if (deprecation.advisory) {
6051
+ description += `Advisory: ${deprecation.advisory}. `;
6052
+ }
6053
+ if (deprecation.replacement) {
6054
+ description += `Consider replacing with: ${deprecation.replacement}.`;
6055
+ }
6056
+ const fingerprint = generateFingerprint({
6057
+ ruleId: RULE_ID29,
6058
+ file: "package.json",
6059
+ symbol: name
6060
+ });
6061
+ findings.push({
6062
+ id: generateFindingId({
6063
+ ruleId: RULE_ID29,
6064
+ file: "package.json",
6065
+ symbol: name
6066
+ }),
6067
+ severity,
6068
+ confidence: 0.95,
6069
+ category: "supply-chain",
6070
+ ruleId: RULE_ID29,
6071
+ title: `Deprecated/vulnerable package: ${name}`,
6072
+ description,
6073
+ evidence: [
6074
+ {
6075
+ file: "package.json",
6076
+ startLine: 1,
6077
+ endLine: 1,
6078
+ snippet: `"${name}": "${version}"`,
6079
+ context: isDevDep ? "devDependencies" : "dependencies"
6080
+ }
6081
+ ],
6082
+ remediation: {
6083
+ recommendedFix: deprecation.replacement ? `Replace ${name} with ${deprecation.replacement}. Remove ${name} from your dependencies and install the replacement.` : `Remove or update ${name} to a secure version. Check npm for the latest secure version or alternative packages.`
6084
+ },
6085
+ links: {
6086
+ cwe: "https://cwe.mitre.org/data/definitions/1104.html"
6087
+ },
6088
+ fingerprint
6089
+ });
6090
+ }
6091
+ return findings;
6092
+ }
6093
+
6094
+ // src/scanners/supply-chain/multiple-auth-systems.ts
6095
+ var RULE_ID30 = "VC-SUP-004";
6096
+ function getGroupName(group) {
6097
+ const names = {
6098
+ nextAuth: "NextAuth.js / Auth.js",
6099
+ clerk: "Clerk",
6100
+ supabase: "Supabase Auth",
6101
+ firebase: "Firebase Auth",
6102
+ auth0: "Auth0",
6103
+ okta: "Okta",
6104
+ passport: "Passport.js",
6105
+ lucia: "Lucia",
6106
+ jwt: "Custom JWT (jsonwebtoken/jose)",
6107
+ betterAuth: "Better Auth"
6108
+ };
6109
+ return names[group] || group;
6110
+ }
6111
+ function areCompatible(groups) {
6112
+ const nonJwt = groups.filter((g) => g !== "jwt");
6113
+ if (nonJwt.length <= 1) return true;
6114
+ return false;
6115
+ }
6116
+ async function scanMultipleAuthSystems(context) {
6117
+ const { repoRoot } = context;
6118
+ const findings = [];
6119
+ const pkg = parsePackageJson(repoRoot);
6120
+ if (!pkg) {
6121
+ return findings;
6122
+ }
6123
+ const allDeps = {
6124
+ ...pkg.dependencies,
6125
+ ...pkg.devDependencies
6126
+ };
6127
+ const detectedGroups = detectAuthLibraries(allDeps);
6128
+ if (detectedGroups.length < 2) {
6129
+ return findings;
6130
+ }
6131
+ if (areCompatible(detectedGroups)) {
6132
+ return findings;
6133
+ }
6134
+ const groupPackages = {};
6135
+ for (const group of detectedGroups) {
6136
+ const packages = AUTH_LIBRARY_GROUPS[group].filter((pkg2) => pkg2 in allDeps);
6137
+ if (packages.length > 0) {
6138
+ groupPackages[group] = packages;
6139
+ }
6140
+ }
6141
+ const groupNames = detectedGroups.map(getGroupName);
6142
+ let description = `Multiple authentication systems detected: ${groupNames.join(", ")}. Having multiple auth libraries can lead to:
6143
+ - Confusion about which system is enforcing authentication
6144
+ - Incomplete migration leaving some routes unprotected
6145
+ - Conflicting session/token handling
6146
+
6147
+ Detected packages:
6148
+ `;
6149
+ for (const [group, packages] of Object.entries(groupPackages)) {
6150
+ description += `- ${getGroupName(group)}: ${packages.join(", ")}
6151
+ `;
6152
+ }
6153
+ const evidenceItems = [];
6154
+ for (const [group, packages] of Object.entries(groupPackages)) {
6155
+ for (const pkgName of packages) {
6156
+ evidenceItems.push({
6157
+ file: "package.json",
6158
+ startLine: 1,
6159
+ endLine: 1,
6160
+ snippet: `"${pkgName}": "${allDeps[pkgName]}"`,
6161
+ context: `${getGroupName(group)} authentication library`
6162
+ });
6163
+ }
6164
+ }
6165
+ const fingerprint = generateFingerprint({
6166
+ ruleId: RULE_ID30,
6167
+ file: "package.json",
6168
+ symbol: detectedGroups.sort().join(",")
6169
+ });
6170
+ findings.push({
6171
+ id: generateFindingId({
6172
+ ruleId: RULE_ID30,
6173
+ file: "package.json",
6174
+ symbol: "multiple-auth"
6175
+ }),
6176
+ severity: "medium",
6177
+ confidence: 0.75,
6178
+ category: "supply-chain",
6179
+ ruleId: RULE_ID30,
6180
+ title: `Multiple authentication systems detected`,
6181
+ description,
6182
+ evidence: evidenceItems.slice(0, 5),
6183
+ // Limit evidence items
6184
+ remediation: {
6185
+ recommendedFix: `Consolidate to a single authentication system. If migrating, ensure the old system is fully removed after migration. If both are intentionally used (e.g., different auth for different parts of the app), document this clearly and ensure no authentication gaps exist.`
6186
+ },
6187
+ links: {
6188
+ cwe: "https://cwe.mitre.org/data/definitions/287.html"
6189
+ },
6190
+ fingerprint
6191
+ });
6192
+ return findings;
6193
+ }
6194
+
6195
+ // src/scanners/supply-chain/suspicious-scripts.ts
6196
+ var RULE_ID31 = "VC-SUP-005";
6197
+ var KNOWN_SAFE_INSTALL_SCRIPTS = /* @__PURE__ */ new Set([
6198
+ // Native addons that need compilation
6199
+ "bcrypt",
6200
+ "argon2",
6201
+ "sharp",
6202
+ "canvas",
6203
+ "node-sass",
6204
+ "sqlite3",
6205
+ "better-sqlite3",
6206
+ "node-gyp",
6207
+ "fsevents",
6208
+ "esbuild",
6209
+ "@swc/core",
6210
+ "turbo",
6211
+ // Prisma
6212
+ "prisma",
6213
+ "@prisma/client",
6214
+ "@prisma/engines",
6215
+ // Playwright/Puppeteer (browser downloads)
6216
+ "playwright",
6217
+ "playwright-core",
6218
+ "puppeteer",
6219
+ "puppeteer-core",
6220
+ // Electron
6221
+ "electron",
6222
+ "electron-builder",
6223
+ // Husky/lint-staged (git hooks)
6224
+ "husky",
6225
+ "lefthook",
6226
+ // Other build tools
6227
+ "node-pre-gyp",
6228
+ "@mapbox/node-pre-gyp",
6229
+ "prebuild-install"
6230
+ ]);
6231
+ async function scanSuspiciousScripts(context) {
6232
+ const { repoRoot } = context;
6233
+ const findings = [];
6234
+ const lockfile = parseLockfile(repoRoot);
6235
+ if (lockfile.type === "none") {
6236
+ return findings;
6237
+ }
6238
+ for (const [name, pkg] of lockfile.packages) {
6239
+ if (!pkg.hasInstallScripts) continue;
6240
+ if (KNOWN_SAFE_INSTALL_SCRIPTS.has(name)) continue;
6241
+ const isDirect = lockfile.directDependencies.has(name);
6242
+ let suspicious = [];
6243
+ let scriptContent = "";
6244
+ if (pkg.scripts) {
6245
+ const scripts = [
6246
+ pkg.scripts.preinstall,
6247
+ pkg.scripts.install,
6248
+ pkg.scripts.postinstall,
6249
+ pkg.scripts.prepare
6250
+ ].filter(Boolean);
6251
+ scriptContent = scripts.join("\n");
6252
+ suspicious = findSuspiciousPatterns(scriptContent);
6253
+ }
6254
+ let severity = "low";
6255
+ let confidence = 0.6;
6256
+ if (suspicious.length > 0) {
6257
+ severity = "high";
6258
+ confidence = 0.9;
6259
+ } else if (isDirect) {
6260
+ severity = "medium";
6261
+ confidence = 0.7;
6262
+ }
6263
+ let description = `The dependency \`${name}@${pkg.version}\` has install scripts that run during \`npm install\`. `;
6264
+ if (suspicious.length > 0) {
6265
+ description += `Suspicious patterns detected:
6266
+ `;
6267
+ for (const { reason } of suspicious) {
6268
+ description += `- ${reason}
6269
+ `;
6270
+ }
6271
+ } else {
6272
+ description += `While install scripts are sometimes legitimate, they are also a common supply chain attack vector. Review the package to ensure the install script is necessary and safe.`;
6273
+ }
6274
+ const fingerprint = generateFingerprint({
6275
+ ruleId: RULE_ID31,
6276
+ file: lockfile.path || "lockfile",
6277
+ symbol: name
6278
+ });
6279
+ findings.push({
6280
+ id: generateFindingId({
6281
+ ruleId: RULE_ID31,
6282
+ file: lockfile.path || "lockfile",
6283
+ symbol: name
6284
+ }),
6285
+ severity,
6286
+ confidence,
6287
+ category: "supply-chain",
6288
+ ruleId: RULE_ID31,
6289
+ title: `Dependency with install scripts: ${name}`,
6290
+ description,
6291
+ evidence: [
6292
+ {
6293
+ file: lockfile.path || "lockfile",
6294
+ startLine: 1,
6295
+ endLine: 1,
6296
+ snippet: `${name}@${pkg.version} (hasInstallScripts: true)`,
6297
+ context: isDirect ? "Direct dependency" : "Transitive dependency"
6298
+ },
6299
+ ...scriptContent ? [
6300
+ {
6301
+ file: `node_modules/${name}/package.json`,
6302
+ startLine: 1,
6303
+ endLine: 1,
6304
+ snippet: scriptContent.length > 200 ? scriptContent.slice(0, 200) + "..." : scriptContent,
6305
+ context: "Install script content"
6306
+ }
6307
+ ] : []
6308
+ ],
6309
+ remediation: {
6310
+ recommendedFix: suspicious.length > 0 ? `Immediately review ${name} for malicious behavior. Consider removing the package or using --ignore-scripts during install.` : `Review ${name}'s install scripts to ensure they are safe. Consider using \`npm install --ignore-scripts\` in CI and running scripts explicitly after audit.`
6311
+ },
6312
+ links: {
6313
+ cwe: "https://cwe.mitre.org/data/definitions/829.html"
6314
+ },
6315
+ fingerprint
6316
+ });
6317
+ }
6318
+ return findings;
6319
+ }
6320
+
6321
+ // src/scanners/supply-chain/index.ts
6322
+ var supplyChainPack = {
6323
+ id: "supply-chain",
6324
+ name: "Supply Chain Security",
6325
+ scanners: [
6326
+ scanPostinstallScripts,
6327
+ scanVersionRanges,
6328
+ scanDeprecatedPackages,
6329
+ scanMultipleAuthSystems,
6330
+ scanSuspiciousScripts
6331
+ ]
6332
+ };
6333
+
6334
+ // src/phase3/proof-trace-builder.ts
6335
+ import crypto3 from "crypto";
6336
+ import path3 from "path";
6337
+ import { SyntaxKind as SyntaxKind2 } from "ts-morph";
6338
+ var MAX_TRACE_DEPTH = 2;
6339
+ function generateRouteId(routePath, method, file) {
6340
+ const normalized = `${method}:${routePath}:${file}`.toLowerCase();
6341
+ return crypto3.createHash("sha256").update(normalized).digest("hex").slice(0, 12);
6342
+ }
6343
+ function filePathToRoutePath(filePath) {
6344
+ const normalized = filePath.replace(/\\/g, "/");
6345
+ const appIndex = normalized.indexOf("/app/");
6346
+ if (appIndex === -1) {
6347
+ const appIndexAlt = normalized.indexOf("app/");
6348
+ if (appIndexAlt === 0) {
6349
+ return extractRoutePath6(normalized.slice(4));
6350
+ }
6351
+ return "/";
6352
+ }
6353
+ return extractRoutePath6(normalized.slice(appIndex + 5));
6354
+ }
6355
+ function extractRoutePath6(routePart) {
6356
+ const withoutRoute = routePart.replace(/\/route\.(ts|tsx|js|jsx)$/, "");
6357
+ if (withoutRoute === "" || withoutRoute === "api") {
6358
+ return withoutRoute === "" ? "/" : "/api";
6359
+ }
6360
+ return "/" + withoutRoute;
6361
+ }
6362
+ function buildRouteMap(ctx) {
6363
+ const routes = [];
6364
+ for (const routeFile of ctx.fileIndex.routeFiles) {
6365
+ const absolutePath = path3.join(ctx.repoRoot, routeFile);
6366
+ const sourceFile = ctx.helpers.parseFile(absolutePath);
6367
+ if (!sourceFile) continue;
6368
+ const handlers = ctx.helpers.findRouteHandlers(sourceFile);
6369
+ const relPath = routeFile.replace(/\\/g, "/");
6370
+ const routePath = filePathToRoutePath(relPath);
6371
+ for (const handler of handlers) {
6372
+ const routeId = generateRouteId(routePath, handler.method, relPath);
6373
+ routes.push({
6374
+ routeId,
6375
+ method: handler.method,
6376
+ path: routePath,
6377
+ file: relPath,
6378
+ startLine: handler.startLine,
6379
+ endLine: handler.endLine
6380
+ });
6381
+ }
6382
+ }
6383
+ return routes;
6384
+ }
6385
+ function buildMiddlewareMap(ctx) {
6386
+ const middlewareList = [];
6387
+ if (!ctx.fileIndex.middlewareFile) {
6388
+ return middlewareList;
6389
+ }
6390
+ const absolutePath = path3.join(ctx.repoRoot, ctx.fileIndex.middlewareFile);
6391
+ const sourceFile = ctx.helpers.parseFile(absolutePath);
6392
+ if (!sourceFile) return middlewareList;
6393
+ const relPath = ctx.fileIndex.middlewareFile.replace(/\\/g, "/");
6394
+ const matchers = extractMiddlewareMatchers(sourceFile);
6395
+ const protectsApi = matchers.some(
6396
+ (m) => m.includes("/api") || m.includes("/(api)") || m === "/(.*)"
6397
+ );
6398
+ let startLine = 1;
6399
+ sourceFile.forEachDescendant((node) => {
6400
+ if (node.getKind() === SyntaxKind2.VariableDeclaration && node.getText().includes("config")) {
6401
+ startLine = node.getStartLineNumber();
6402
+ }
6403
+ });
6404
+ middlewareList.push({
6405
+ file: relPath,
6406
+ matchers,
6407
+ protectsApi,
6408
+ startLine
6409
+ });
6410
+ return middlewareList;
6411
+ }
6412
+ function extractMiddlewareMatchers(sourceFile) {
6413
+ const matchers = [];
6414
+ sourceFile.forEachDescendant((node) => {
6415
+ if (node.getKind() === SyntaxKind2.PropertyAssignment) {
6416
+ const text = node.getText();
6417
+ if (text.startsWith("matcher")) {
6418
+ node.forEachDescendant((child) => {
6419
+ if (child.getKind() === SyntaxKind2.StringLiteral) {
6420
+ const value = child.getText().replace(/['"]/g, "");
6421
+ matchers.push(value);
6422
+ }
6423
+ });
6424
+ }
6425
+ }
6426
+ });
6427
+ return matchers;
6428
+ }
6429
+ function isRouteCoveredByMiddleware(routePath, matchers) {
6430
+ for (const matcher of matchers) {
6431
+ const pattern = matcher.replace(/\*/g, ".*").replace(/\/:path\*/g, "/.*").replace(/\(([^)]+)\)/g, "(?:$1)");
6432
+ try {
6433
+ const regex = new RegExp(`^${pattern}`);
6434
+ if (regex.test(routePath)) {
6435
+ return true;
6436
+ }
6437
+ } catch {
6438
+ if (routePath.startsWith(matcher.replace(/\/:path\*$/, ""))) {
6439
+ return true;
6440
+ }
6441
+ }
6442
+ }
6443
+ return false;
6444
+ }
6445
+ function buildProofTrace(ctx, route) {
6446
+ const steps = [];
6447
+ let authProven = false;
6448
+ let validationProven = false;
6449
+ const sourceFile = ctx.helpers.parseFile(
6450
+ path3.join(ctx.repoRoot, route.file)
6451
+ );
6452
+ if (!sourceFile) {
6453
+ return {
6454
+ routeId: route.routeId,
6455
+ authProven: false,
6456
+ validationProven: false,
6457
+ middlewareCovered: false,
6458
+ steps: []
6459
+ };
6460
+ }
6461
+ const handlers = ctx.helpers.findRouteHandlers(sourceFile);
6462
+ const handler = handlers.find((h) => h.method === route.method);
6463
+ if (!handler) {
6464
+ return {
6465
+ routeId: route.routeId,
6466
+ authProven: false,
6467
+ validationProven: false,
6468
+ middlewareCovered: false,
6469
+ steps: []
6470
+ };
6471
+ }
6472
+ if (ctx.helpers.containsAuthCheck(handler.functionNode)) {
6473
+ authProven = true;
6474
+ steps.push({
6475
+ file: route.file,
6476
+ line: handler.startLine,
6477
+ snippet: truncateSnippet(handler.functionNode.getText(), 100),
6478
+ label: "Auth check found in handler"
6479
+ });
6480
+ }
6481
+ const validationUsage = ctx.helpers.findValidationUsage(handler.functionNode);
6482
+ if (validationUsage.length > 0 && validationUsage.some((v) => v.resultUsed)) {
6483
+ validationProven = true;
6484
+ steps.push({
6485
+ file: route.file,
6486
+ line: validationUsage[0].line,
6487
+ snippet: truncateSnippet(ctx.helpers.getNodeText(validationUsage[0].node), 100),
6488
+ label: "Validation found in handler"
6489
+ });
6490
+ }
6491
+ if (!authProven || !validationProven) {
6492
+ const importedModules = getLocalImports(sourceFile, ctx.repoRoot, route.file);
4089
6493
  for (const importInfo of importedModules) {
4090
6494
  if (authProven && validationProven) break;
4091
6495
  const traceResult = traceImportedModule(
@@ -4526,7 +6930,7 @@ function mineAllIntentClaims(ctx, routes) {
4526
6930
 
4527
6931
  // src/phase3/scanners/comment-claim-unproven.ts
4528
6932
  import crypto5 from "crypto";
4529
- var RULE_ID20 = "VC-HALL-010";
6933
+ var RULE_ID32 = "VC-HALL-010";
4530
6934
  async function scanCommentClaimUnproven(ctx) {
4531
6935
  const findings = [];
4532
6936
  const routes = buildRouteMap(ctx);
@@ -4542,7 +6946,7 @@ async function scanCommentClaimUnproven(ctx) {
4542
6946
  severity: "medium",
4543
6947
  confidence: 0.75,
4544
6948
  category: "hallucinations",
4545
- ruleId: RULE_ID20,
6949
+ ruleId: RULE_ID32,
4546
6950
  title: `Comment claims ${formatClaimType(claim.type)} but implementation doesn't prove it`,
4547
6951
  description: generateDescription2(claim, route, trace),
4548
6952
  evidence: [
@@ -4658,13 +7062,13 @@ function generateFindingId2(claim) {
4658
7062
  return `f-${crypto5.randomUUID().slice(0, 8)}`;
4659
7063
  }
4660
7064
  function generateFingerprint2(claim) {
4661
- const data = `${RULE_ID20}:${claim.location.file}:${claim.location.startLine}:${claim.type}`;
7065
+ const data = `${RULE_ID32}:${claim.location.file}:${claim.location.startLine}:${claim.type}`;
4662
7066
  return `sha256:${crypto5.createHash("sha256").update(data).digest("hex")}`;
4663
7067
  }
4664
7068
 
4665
7069
  // src/phase3/scanners/middleware-assumed-not-matching.ts
4666
7070
  import crypto6 from "crypto";
4667
- var RULE_ID21 = "VC-HALL-011";
7071
+ var RULE_ID33 = "VC-HALL-011";
4668
7072
  var MIDDLEWARE_EXPECTATION_SIGNALS = [
4669
7073
  // Comments
4670
7074
  /middleware.*protect/i,
@@ -4695,7 +7099,7 @@ async function scanMiddlewareAssumedNotMatching(ctx) {
4695
7099
  severity: "high",
4696
7100
  confidence: 0.7,
4697
7101
  category: "hallucinations",
4698
- ruleId: RULE_ID21,
7102
+ ruleId: RULE_ID33,
4699
7103
  title: `Route ${route.method} ${route.path} expects middleware but is not covered`,
4700
7104
  description: generateDescription3(route, middlewareMap[0], expectsMiddleware.reason),
4701
7105
  evidence: [
@@ -4802,14 +7206,14 @@ function generateFindingId3(route) {
4802
7206
  return `f-${crypto6.randomUUID().slice(0, 8)}`;
4803
7207
  }
4804
7208
  function generateFingerprint3(route) {
4805
- const data = `${RULE_ID21}:${route.file}:${route.method}:${route.path}`;
7209
+ const data = `${RULE_ID33}:${route.file}:${route.method}:${route.path}`;
4806
7210
  return `sha256:${crypto6.createHash("sha256").update(data).digest("hex")}`;
4807
7211
  }
4808
7212
 
4809
7213
  // src/phase3/scanners/validation-claimed-missing.ts
4810
7214
  import crypto7 from "crypto";
4811
7215
  import path5 from "path";
4812
- var RULE_ID22 = "VC-HALL-012";
7216
+ var RULE_ID34 = "VC-HALL-012";
4813
7217
  async function scanValidationClaimedMissing(ctx) {
4814
7218
  const findings = [];
4815
7219
  const routes = buildRouteMap(ctx);
@@ -4912,7 +7316,7 @@ function createFinding(route, claim, issueType, description, additionalEvidence)
4912
7316
  severity: "medium",
4913
7317
  confidence: 0.8,
4914
7318
  category: "hallucinations",
4915
- ruleId: RULE_ID22,
7319
+ ruleId: RULE_ID34,
4916
7320
  title: generateTitle(route, issueType),
4917
7321
  description,
4918
7322
  evidence,
@@ -4965,7 +7369,7 @@ function checkUnusedValidationImports(ctx, routes, claims) {
4965
7369
  severity: "medium",
4966
7370
  confidence: 0.7,
4967
7371
  category: "hallucinations",
4968
- ruleId: RULE_ID22,
7372
+ ruleId: RULE_ID34,
4969
7373
  title: `Validation library imported but no schemas defined in ${claim.location.file}`,
4970
7374
  description: "A validation library is imported suggesting validation intent, but no validation schemas are defined in the file. This could be dead code or incomplete implementation.",
4971
7375
  evidence: [
@@ -4980,7 +7384,7 @@ function checkUnusedValidationImports(ctx, routes, claims) {
4980
7384
  remediation: {
4981
7385
  recommendedFix: "Define validation schemas using the imported library, or remove the unused import."
4982
7386
  },
4983
- fingerprint: `sha256:${crypto7.createHash("sha256").update(`${RULE_ID22}:${claim.location.file}:import_unused`).digest("hex")}`
7387
+ fingerprint: `sha256:${crypto7.createHash("sha256").update(`${RULE_ID34}:${claim.location.file}:import_unused`).digest("hex")}`
4984
7388
  });
4985
7389
  }
4986
7390
  }
@@ -4988,14 +7392,14 @@ function checkUnusedValidationImports(ctx, routes, claims) {
4988
7392
  return findings;
4989
7393
  }
4990
7394
  function generateFingerprint4(route, issueType) {
4991
- const data = `${RULE_ID22}:${route.file}:${route.method}:${issueType}`;
7395
+ const data = `${RULE_ID34}:${route.file}:${route.method}:${issueType}`;
4992
7396
  return `sha256:${crypto7.createHash("sha256").update(data).digest("hex")}`;
4993
7397
  }
4994
7398
 
4995
7399
  // src/phase3/scanners/auth-by-ui-server-gap.ts
4996
7400
  import crypto8 from "crypto";
4997
7401
  import path6 from "path";
4998
- var RULE_ID23 = "VC-AUTH-010";
7402
+ var RULE_ID35 = "VC-AUTH-010";
4999
7403
  var CLIENT_AUTH_PATTERNS = [
5000
7404
  // React/Next.js session hooks
5001
7405
  /\buseSession\s*\(/,
@@ -5053,7 +7457,7 @@ async function scanAuthByUiServerGap(ctx) {
5053
7457
  severity: "critical",
5054
7458
  confidence: 0.85,
5055
7459
  category: "auth",
5056
- ruleId: RULE_ID23,
7460
+ ruleId: RULE_ID35,
5057
7461
  title: `Client-side auth with unprotected server endpoint ${matchingUnprotected.method} ${matchingUnprotected.path}`,
5058
7462
  description: generateDescription4(
5059
7463
  relPath,
@@ -5184,7 +7588,7 @@ if (!session) {
5184
7588
  Client-side auth checks are for UX only and must never be the sole protection mechanism.`;
5185
7589
  }
5186
7590
  function generateFingerprint5(componentFile, route) {
5187
- const data = `${RULE_ID23}:${componentFile}:${route.file}:${route.method}`;
7591
+ const data = `${RULE_ID35}:${componentFile}:${route.file}:${route.method}`;
5188
7592
  return `sha256:${crypto8.createHash("sha256").update(data).digest("hex")}`;
5189
7593
  }
5190
7594
 
@@ -5216,12 +7620,452 @@ var ALL_SCANNER_PACKS = [
5216
7620
  cryptoPack,
5217
7621
  uploadsPack,
5218
7622
  abusePack,
7623
+ authorizationPack,
7624
+ lifecyclePack,
7625
+ supplyChainPack,
5219
7626
  // Phase 3 packs
5220
7627
  hallucinationsPack2,
5221
7628
  authPackPhase3
5222
7629
  ];
5223
7630
  var ALL_SCANNERS = ALL_SCANNER_PACKS.flatMap((pack) => pack.scanners);
5224
7631
 
7632
+ // src/phase4/correlator.ts
7633
+ function detectAuthWithoutValidation(ctx) {
7634
+ const correlatedFindings = [];
7635
+ const findingsByFile = /* @__PURE__ */ new Map();
7636
+ for (const finding of ctx.findings) {
7637
+ const file = finding.evidence[0]?.file;
7638
+ if (file) {
7639
+ const existing = findingsByFile.get(file) || [];
7640
+ existing.push(finding);
7641
+ findingsByFile.set(file, existing);
7642
+ }
7643
+ }
7644
+ const routes = ctx.routeMap?.routes || [];
7645
+ const stateChangingMethods = ["POST", "PUT", "PATCH", "DELETE"];
7646
+ for (const route of routes) {
7647
+ if (!stateChangingMethods.includes(route.method)) continue;
7648
+ const fileFindings = findingsByFile.get(route.file) || [];
7649
+ const authFindings = fileFindings.filter((f) => f.category === "auth");
7650
+ const validationFindings = fileFindings.filter((f) => f.category === "validation");
7651
+ if (authFindings.length > 0 && validationFindings.length === 0) {
7652
+ const relatedIds = authFindings.map((f) => f.fingerprint);
7653
+ const evidence = [
7654
+ {
7655
+ file: route.file,
7656
+ startLine: route.startLine || 1,
7657
+ endLine: route.endLine || 1,
7658
+ label: `${route.method} ${route.path} - auth present, validation missing`
7659
+ },
7660
+ ...authFindings[0].evidence
7661
+ ];
7662
+ const fingerprint = generateFingerprint({
7663
+ ruleId: "VC-CORR-001",
7664
+ file: route.file,
7665
+ route: route.routeId,
7666
+ symbol: "auth_validation_gap"
7667
+ });
7668
+ correlatedFindings.push({
7669
+ id: generateFindingId({
7670
+ ruleId: "VC-CORR-001",
7671
+ file: route.file,
7672
+ symbol: route.routeId
7673
+ }),
7674
+ severity: "medium",
7675
+ confidence: 0.75,
7676
+ category: "correlation",
7677
+ ruleId: "VC-CORR-001",
7678
+ title: "Auth Check Without Input Validation",
7679
+ description: `The ${route.method} ${route.path} endpoint has authentication checks but appears to be missing server-side input validation. Authenticated users can still submit malicious input. Related findings: ${relatedIds.join(", ")}`,
7680
+ evidence,
7681
+ remediation: {
7682
+ recommendedFix: `Add input validation using Zod, Yup, or Joi to validate request body/params before processing. Authentication prevents unauthorized access, but validation prevents malformed data.`
7683
+ },
7684
+ fingerprint,
7685
+ correlationData: {
7686
+ relatedFindingIds: relatedIds,
7687
+ pattern: "auth_without_validation",
7688
+ explanation: `Route ${route.routeId} has auth checks (findings: ${relatedIds.length}) but no validation evidence.`
7689
+ },
7690
+ relatedFindings: relatedIds
7691
+ });
7692
+ }
7693
+ }
7694
+ return correlatedFindings;
7695
+ }
7696
+ function detectMiddlewareUploadGap(ctx) {
7697
+ const correlatedFindings = [];
7698
+ if (!ctx.middlewareMap) return correlatedFindings;
7699
+ const middlewareMap = ctx.middlewareMap;
7700
+ const uncoveredRoutes = middlewareMap.coverage?.filter((c) => !c.covered) || [];
7701
+ const uploadFindings = ctx.findings.filter(
7702
+ (f) => f.ruleId.startsWith("VC-UPL") || f.category === "uploads"
7703
+ );
7704
+ for (const uncovered of uncoveredRoutes) {
7705
+ const relatedUploads = uploadFindings.filter(
7706
+ (f) => f.evidence.some((e) => e.file.includes(uncovered.routeId) || uncovered.routeId.includes(e.file.replace(/\\/g, "/")))
7707
+ );
7708
+ if (relatedUploads.length > 0) {
7709
+ const relatedIds = relatedUploads.map((f) => f.fingerprint);
7710
+ const evidence = [
7711
+ {
7712
+ file: relatedUploads[0].evidence[0]?.file || uncovered.routeId,
7713
+ startLine: relatedUploads[0].evidence[0]?.startLine || 1,
7714
+ endLine: relatedUploads[0].evidence[0]?.endLine || 1,
7715
+ label: `Upload endpoint not protected by middleware`
7716
+ }
7717
+ ];
7718
+ const fingerprint = generateFingerprint({
7719
+ ruleId: "VC-CORR-002",
7720
+ file: uncovered.routeId,
7721
+ symbol: "middleware_upload_gap"
7722
+ });
7723
+ correlatedFindings.push({
7724
+ id: generateFindingId({
7725
+ ruleId: "VC-CORR-002",
7726
+ file: uncovered.routeId,
7727
+ symbol: "upload"
7728
+ }),
7729
+ severity: "high",
7730
+ confidence: 0.8,
7731
+ category: "correlation",
7732
+ ruleId: "VC-CORR-002",
7733
+ title: "Upload Endpoint Not Protected by Middleware",
7734
+ description: `File upload endpoint at ${uncovered.routeId} is not covered by the global middleware. Upload endpoints without rate limiting or auth middleware are vulnerable to abuse. Related upload findings: ${relatedIds.join(", ")}`,
7735
+ evidence,
7736
+ remediation: {
7737
+ recommendedFix: `Extend middleware matcher to cover this upload endpoint, or add explicit rate limiting and auth checks in the handler. Consider: matcher: ['/((?!api/upload).*)'] to ensure coverage.`
7738
+ },
7739
+ fingerprint,
7740
+ correlationData: {
7741
+ relatedFindingIds: relatedIds,
7742
+ pattern: "middleware_upload_gap",
7743
+ explanation: `Upload route ${uncovered.routeId} bypasses middleware (${uncovered.reason || "not in matcher"}).`
7744
+ },
7745
+ relatedFindings: relatedIds
7746
+ });
7747
+ }
7748
+ }
7749
+ return correlatedFindings;
7750
+ }
7751
+ function detectNetworkAuthLeak(ctx) {
7752
+ const correlatedFindings = [];
7753
+ const networkFindings = ctx.findings.filter(
7754
+ (f) => f.ruleId.startsWith("VC-NET") || f.category === "network"
7755
+ );
7756
+ const authFindings = ctx.findings.filter(
7757
+ (f) => f.category === "auth" || f.category === "secrets"
7758
+ );
7759
+ const networkByFile = /* @__PURE__ */ new Map();
7760
+ for (const f of networkFindings) {
7761
+ const file = f.evidence[0]?.file;
7762
+ if (file) {
7763
+ const existing = networkByFile.get(file) || [];
7764
+ existing.push(f);
7765
+ networkByFile.set(file, existing);
7766
+ }
7767
+ }
7768
+ const authByFile = /* @__PURE__ */ new Map();
7769
+ for (const f of authFindings) {
7770
+ const file = f.evidence[0]?.file;
7771
+ if (file) {
7772
+ const existing = authByFile.get(file) || [];
7773
+ existing.push(f);
7774
+ authByFile.set(file, existing);
7775
+ }
7776
+ }
7777
+ for (const [file, netFindings] of networkByFile) {
7778
+ const fileAuthFindings = authByFile.get(file) || [];
7779
+ const ssrfFindings = netFindings.filter(
7780
+ (f) => f.ruleId === "VC-NET-001" || f.title.toLowerCase().includes("ssrf")
7781
+ );
7782
+ if (ssrfFindings.length > 0 && fileAuthFindings.length > 0) {
7783
+ const relatedIds = [...ssrfFindings, ...fileAuthFindings].map((f) => f.fingerprint);
7784
+ const evidence = [
7785
+ ...ssrfFindings[0].evidence,
7786
+ ...fileAuthFindings[0]?.evidence || []
7787
+ ];
7788
+ const fingerprint = generateFingerprint({
7789
+ ruleId: "VC-CORR-003",
7790
+ file,
7791
+ symbol: "network_auth_leak"
7792
+ });
7793
+ correlatedFindings.push({
7794
+ id: generateFindingId({
7795
+ ruleId: "VC-CORR-003",
7796
+ file,
7797
+ symbol: "ssrf_token"
7798
+ }),
7799
+ severity: "critical",
7800
+ confidence: 0.7,
7801
+ category: "correlation",
7802
+ ruleId: "VC-CORR-003",
7803
+ title: "Token May Be Forwarded to User-Controlled URL",
7804
+ description: `File ${file} has both SSRF-prone fetch calls and authentication/token handling. Tokens or session data may be inadvertently forwarded to attacker-controlled servers. Related findings: ${relatedIds.join(", ")}`,
7805
+ evidence,
7806
+ remediation: {
7807
+ recommendedFix: `Never forward auth tokens to user-controlled URLs. Use an allowlist for external requests. Strip sensitive headers before forwarding requests. Consider using a separate HTTP client without default auth headers for user-provided URLs.`
7808
+ },
7809
+ fingerprint,
7810
+ correlationData: {
7811
+ relatedFindingIds: relatedIds,
7812
+ pattern: "network_auth_leak",
7813
+ explanation: `File has SSRF vulnerability and handles auth tokens - risk of token exfiltration.`
7814
+ },
7815
+ relatedFindings: relatedIds
7816
+ });
7817
+ }
7818
+ }
7819
+ return correlatedFindings;
7820
+ }
7821
+ function detectPrivacyLoggingInAuth(ctx) {
7822
+ const correlatedFindings = [];
7823
+ const privacyFindings = ctx.findings.filter(
7824
+ (f) => f.ruleId.startsWith("VC-PRIV") || f.category === "privacy"
7825
+ );
7826
+ const apiRouteFiles = new Set(
7827
+ (ctx.routeMap?.routes || []).map((r) => r.file)
7828
+ );
7829
+ for (const privacyFinding of privacyFindings) {
7830
+ const file = privacyFinding.evidence[0]?.file;
7831
+ if (!file) continue;
7832
+ const isApiRoute = apiRouteFiles.has(file) || file.includes("/api/") || file.includes("route.ts") || file.includes("route.js");
7833
+ if (isApiRoute) {
7834
+ const relatedIds = [privacyFinding.fingerprint];
7835
+ const evidence = [...privacyFinding.evidence];
7836
+ const fingerprint = generateFingerprint({
7837
+ ruleId: "VC-CORR-004",
7838
+ file,
7839
+ symbol: "privacy_auth_context",
7840
+ startLine: privacyFinding.evidence[0]?.startLine
7841
+ });
7842
+ correlatedFindings.push({
7843
+ id: generateFindingId({
7844
+ ruleId: "VC-CORR-004",
7845
+ file,
7846
+ symbol: "sensitive_log_api",
7847
+ startLine: privacyFinding.evidence[0]?.startLine
7848
+ }),
7849
+ severity: "high",
7850
+ confidence: 0.8,
7851
+ category: "correlation",
7852
+ ruleId: "VC-CORR-004",
7853
+ title: "Sensitive Logging in Authenticated API Context",
7854
+ description: `Sensitive data logging in ${file} occurs in an API route context. This increases impact as authenticated user data (tokens, sessions, PII) may be exposed in logs. Related finding: ${privacyFinding.fingerprint}`,
7855
+ evidence,
7856
+ remediation: {
7857
+ recommendedFix: `Remove sensitive data from logs in API routes. Log only non-sensitive identifiers (user ID, request ID, timestamps). Use structured logging with explicit field allowlists.`
7858
+ },
7859
+ fingerprint,
7860
+ correlationData: {
7861
+ relatedFindingIds: relatedIds,
7862
+ pattern: "privacy_auth_context",
7863
+ explanation: `Privacy finding in API route context increases data exposure risk.`
7864
+ },
7865
+ relatedFindings: relatedIds
7866
+ });
7867
+ }
7868
+ }
7869
+ return correlatedFindings;
7870
+ }
7871
+ function detectCryptoAuthGate(ctx) {
7872
+ const correlatedFindings = [];
7873
+ const jwtFindings = ctx.findings.filter(
7874
+ (f) => f.ruleId === "VC-CRYPTO-002" || f.title.toLowerCase().includes("jwt") && f.title.toLowerCase().includes("decode")
7875
+ );
7876
+ const apiRouteFiles = new Set(
7877
+ (ctx.routeMap?.routes || []).map((r) => r.file)
7878
+ );
7879
+ for (const jwtFinding of jwtFindings) {
7880
+ const file = jwtFinding.evidence[0]?.file;
7881
+ if (!file) continue;
7882
+ const isAuthContext = apiRouteFiles.has(file) || file.includes("/api/") || file.includes("auth") || file.includes("middleware") || file.includes("session");
7883
+ if (isAuthContext) {
7884
+ const relatedIds = [jwtFinding.fingerprint];
7885
+ const evidence = [...jwtFinding.evidence];
7886
+ const fingerprint = generateFingerprint({
7887
+ ruleId: "VC-CORR-005",
7888
+ file,
7889
+ symbol: "jwt_auth_gate",
7890
+ startLine: jwtFinding.evidence[0]?.startLine
7891
+ });
7892
+ correlatedFindings.push({
7893
+ id: generateFindingId({
7894
+ ruleId: "VC-CORR-005",
7895
+ file,
7896
+ symbol: "jwt_gate",
7897
+ startLine: jwtFinding.evidence[0]?.startLine
7898
+ }),
7899
+ severity: "critical",
7900
+ confidence: 0.85,
7901
+ category: "correlation",
7902
+ ruleId: "VC-CORR-005",
7903
+ title: "JWT Decode Without Verify on Auth Gate Path",
7904
+ description: `jwt.decode() without verification is used in ${file}, which appears to be an authentication/authorization gate. This allows attackers to forge JWT tokens and bypass auth entirely. Related finding: ${jwtFinding.fingerprint}`,
7905
+ evidence,
7906
+ remediation: {
7907
+ recommendedFix: `Replace jwt.decode() with jwt.verify() in auth gate paths. Never trust JWT claims without signature verification. If you need to read claims before verification (e.g., to get 'kid'), verify immediately after.`
7908
+ },
7909
+ fingerprint,
7910
+ correlationData: {
7911
+ relatedFindingIds: relatedIds,
7912
+ pattern: "crypto_auth_gate",
7913
+ explanation: `JWT decode without verify in auth gate path allows token forgery.`
7914
+ },
7915
+ relatedFindings: relatedIds
7916
+ });
7917
+ }
7918
+ }
7919
+ return correlatedFindings;
7920
+ }
7921
+ function detectHallucinationCoverageGap(ctx) {
7922
+ const correlatedFindings = [];
7923
+ const hallFindings = ctx.findings.filter(
7924
+ (f) => f.ruleId.startsWith("VC-HALL") || f.category === "hallucinations"
7925
+ );
7926
+ const proofTraces = ctx.proofTraces || {};
7927
+ for (const hallFinding of hallFindings) {
7928
+ const file = hallFinding.evidence[0]?.file;
7929
+ if (!file) continue;
7930
+ const fileRoutes = (ctx.routeMap?.routes || []).filter((r) => r.file === file);
7931
+ for (const route of fileRoutes) {
7932
+ const trace = proofTraces[route.routeId];
7933
+ if (trace && trace.summary.includes("No protection proven")) {
7934
+ const relatedIds = [hallFinding.fingerprint];
7935
+ const evidence = [
7936
+ ...hallFinding.evidence,
7937
+ {
7938
+ file: route.file,
7939
+ startLine: route.startLine || 1,
7940
+ endLine: route.endLine || 1,
7941
+ label: `Route ${route.method} ${route.path} - proof trace shows no protection`
7942
+ }
7943
+ ];
7944
+ const fingerprint = generateFingerprint({
7945
+ ruleId: "VC-CORR-006",
7946
+ file,
7947
+ route: route.routeId,
7948
+ symbol: "hallucination_coverage"
7949
+ });
7950
+ correlatedFindings.push({
7951
+ id: generateFindingId({
7952
+ ruleId: "VC-CORR-006",
7953
+ file,
7954
+ symbol: route.routeId
7955
+ }),
7956
+ severity: "high",
7957
+ confidence: 0.8,
7958
+ category: "correlation",
7959
+ ruleId: "VC-CORR-006",
7960
+ title: "Security Claim Contradicts Proof Trace",
7961
+ description: `Comments or imports in ${file} claim security protection, but the proof trace for ${route.method} ${route.path} shows no actual protection. This is a dangerous hallucination that creates false confidence. Related finding: ${hallFinding.fingerprint}`,
7962
+ evidence,
7963
+ remediation: {
7964
+ recommendedFix: `Either implement the claimed security control (auth check, validation, etc.) or remove misleading comments/imports. Proof traces require actual runtime checks to prove protection.`
7965
+ },
7966
+ fingerprint,
7967
+ correlationData: {
7968
+ relatedFindingIds: relatedIds,
7969
+ pattern: "hallucination_coverage_gap",
7970
+ explanation: `Hallucination finding + proof trace gap = false security confidence.`
7971
+ },
7972
+ relatedFindings: relatedIds
7973
+ });
7974
+ }
7975
+ }
7976
+ }
7977
+ return correlatedFindings;
7978
+ }
7979
+ function buildGraph(ctx, correlatedFindings) {
7980
+ const nodes = [];
7981
+ const edges = [];
7982
+ const nodeIds = /* @__PURE__ */ new Set();
7983
+ if (ctx.routeMap?.routes) {
7984
+ for (const route of ctx.routeMap.routes) {
7985
+ if (!nodeIds.has(route.routeId)) {
7986
+ nodes.push({
7987
+ id: route.routeId,
7988
+ type: "route",
7989
+ label: `${route.method} ${route.path}`,
7990
+ file: route.file,
7991
+ line: route.startLine
7992
+ });
7993
+ nodeIds.add(route.routeId);
7994
+ }
7995
+ }
7996
+ }
7997
+ for (const finding of [...ctx.findings, ...correlatedFindings]) {
7998
+ const nodeId = `finding-${finding.fingerprint.slice(0, 16)}`;
7999
+ if (!nodeIds.has(nodeId)) {
8000
+ nodes.push({
8001
+ id: nodeId,
8002
+ type: "finding",
8003
+ label: finding.title,
8004
+ file: finding.evidence[0]?.file,
8005
+ line: finding.evidence[0]?.startLine,
8006
+ metadata: { severity: finding.severity, category: finding.category }
8007
+ });
8008
+ nodeIds.add(nodeId);
8009
+ }
8010
+ if (finding.relatedFindings) {
8011
+ for (const relatedId of finding.relatedFindings) {
8012
+ const relatedNodeId = `finding-${relatedId.slice(0, 16)}`;
8013
+ edges.push({
8014
+ source: nodeId,
8015
+ target: relatedNodeId,
8016
+ type: "correlates",
8017
+ label: finding.correlationData?.pattern
8018
+ });
8019
+ }
8020
+ }
8021
+ }
8022
+ return { nodes, edges };
8023
+ }
8024
+ function runCorrelationPass(ctx) {
8025
+ const startTime = Date.now();
8026
+ const correlatedFindings = [
8027
+ ...detectAuthWithoutValidation(ctx),
8028
+ ...detectMiddlewareUploadGap(ctx),
8029
+ ...detectNetworkAuthLeak(ctx),
8030
+ ...detectPrivacyLoggingInAuth(ctx),
8031
+ ...detectCryptoAuthGate(ctx),
8032
+ ...detectHallucinationCoverageGap(ctx)
8033
+ ];
8034
+ correlatedFindings.sort((a, b) => {
8035
+ const ruleCompare = a.ruleId.localeCompare(b.ruleId);
8036
+ if (ruleCompare !== 0) return ruleCompare;
8037
+ const fileA = a.evidence[0]?.file || "";
8038
+ const fileB = b.evidence[0]?.file || "";
8039
+ const fileCompare = fileA.localeCompare(fileB);
8040
+ if (fileCompare !== 0) return fileCompare;
8041
+ const lineA = a.evidence[0]?.startLine || 0;
8042
+ const lineB = b.evidence[0]?.startLine || 0;
8043
+ return lineA - lineB;
8044
+ });
8045
+ const byPattern = {};
8046
+ for (const finding of correlatedFindings) {
8047
+ const pattern = finding.correlationData?.pattern;
8048
+ if (pattern) {
8049
+ byPattern[pattern] = (byPattern[pattern] || 0) + 1;
8050
+ }
8051
+ }
8052
+ const allFindings = [...ctx.findings, ...correlatedFindings];
8053
+ const graph = buildGraph(ctx, correlatedFindings);
8054
+ const correlationSummary = {
8055
+ totalCorrelations: correlatedFindings.length,
8056
+ byPattern,
8057
+ correlationDurationMs: Date.now() - startTime
8058
+ };
8059
+ return {
8060
+ findings: allFindings,
8061
+ correlationSummary,
8062
+ graph
8063
+ };
8064
+ }
8065
+ function shouldRunCorrelation(findings) {
8066
+ return findings.length > 0;
8067
+ }
8068
+
5225
8069
  // src/utils/sarif-formatter.ts
5226
8070
  function severityToSarifLevel(severity) {
5227
8071
  switch (severity) {
@@ -5587,8 +8431,9 @@ async function runScannersWithProgress(baseContext, totalFiles) {
5587
8431
  progress.stop();
5588
8432
  return allFindings;
5589
8433
  }
5590
- function createArtifact(findings, targetDir, fileCount, repoName, metrics, phase3Data) {
5591
- const summary = computeSummary(findings);
8434
+ function createArtifact(findings, targetDir, fileCount, repoName, metrics, phase3Data, correlationResult) {
8435
+ const finalFindings = correlationResult?.findings ?? findings;
8436
+ const summary = computeSummary(finalFindings);
5592
8437
  const gitInfo = getGitInfo(targetDir);
5593
8438
  const name = repoName ?? getRepoName(targetDir);
5594
8439
  const artifact = {
@@ -5596,7 +8441,7 @@ function createArtifact(findings, targetDir, fileCount, repoName, metrics, phase
5596
8441
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5597
8442
  tool: {
5598
8443
  name: "vibecheck",
5599
- version: "0.0.1"
8444
+ version: CLI_VERSION
5600
8445
  },
5601
8446
  repo: {
5602
8447
  name,
@@ -5604,7 +8449,7 @@ function createArtifact(findings, targetDir, fileCount, repoName, metrics, phase
5604
8449
  git: gitInfo
5605
8450
  },
5606
8451
  summary,
5607
- findings
8452
+ findings: finalFindings
5608
8453
  };
5609
8454
  if (metrics) {
5610
8455
  artifact.metrics = {
@@ -5627,6 +8472,10 @@ function createArtifact(findings, targetDir, fileCount, repoName, metrics, phase
5627
8472
  };
5628
8473
  }
5629
8474
  }
8475
+ if (correlationResult) {
8476
+ artifact.correlationSummary = correlationResult.correlationSummary;
8477
+ artifact.graph = correlationResult.graph;
8478
+ }
5630
8479
  return artifact;
5631
8480
  }
5632
8481
  function formatSeverity(severity) {
@@ -5823,6 +8672,24 @@ async function executeScan(targetDir, options) {
5823
8672
  intentSpinner.succeed(`Built Phase 3 data (${parts.join(", ")})`);
5824
8673
  console.log(`\x1B[90m \u2514\u2500 Auth coverage: ${Math.round(coverageRaw.authCoverage * 100)}%\x1B[0m`);
5825
8674
  }
8675
+ let correlationResult;
8676
+ if (shouldRunCorrelation(findings)) {
8677
+ const correlationSpinner = new Spinner("Running Phase 4 correlation pass");
8678
+ correlationSpinner.start();
8679
+ correlationResult = runCorrelationPass({
8680
+ findings,
8681
+ routeMap: phase3Data?.routeMap,
8682
+ middlewareMap: phase3Data?.middlewareMap,
8683
+ proofTraces: phase3Data?.proofTraces,
8684
+ intentMap: phase3Data?.intentMap
8685
+ });
8686
+ const correlationCount = correlationResult.correlationSummary.totalCorrelations;
8687
+ if (correlationCount > 0) {
8688
+ correlationSpinner.succeed(`Phase 4 correlation: ${correlationCount} pattern(s) detected`);
8689
+ } else {
8690
+ correlationSpinner.succeed("Phase 4 correlation: no patterns detected");
8691
+ }
8692
+ }
5826
8693
  const artifact = createArtifact(
5827
8694
  findings,
5828
8695
  absoluteTarget,
@@ -5832,7 +8699,8 @@ async function executeScan(targetDir, options) {
5832
8699
  filesScanned: initialContext.fileIndex.allSourceFiles.length,
5833
8700
  scanDurationMs
5834
8701
  },
5835
- phase3Data
8702
+ phase3Data,
8703
+ correlationResult
5836
8704
  );
5837
8705
  try {
5838
8706
  validateArtifact(artifact);
@@ -6647,60 +9515,60 @@ function registerIntentCommand(program2) {
6647
9515
  import path10 from "path";
6648
9516
 
6649
9517
  // ../policy/dist/schemas/waiver.js
6650
- import { z as z6 } from "zod";
6651
- var WaiverMatchSchema = z6.object({
9518
+ import { z as z7 } from "zod";
9519
+ var WaiverMatchSchema = z7.object({
6652
9520
  /** Exact fingerprint match */
6653
- fingerprint: z6.string().optional(),
9521
+ fingerprint: z7.string().optional(),
6654
9522
  /** Rule ID (exact or prefix like "VC-AUTH-*") */
6655
- ruleId: z6.string().optional(),
9523
+ ruleId: z7.string().optional(),
6656
9524
  /** Path glob pattern for evidence file matching */
6657
- pathPattern: z6.string().optional()
9525
+ pathPattern: z7.string().optional()
6658
9526
  });
6659
- var WaiverSchema = z6.object({
9527
+ var WaiverSchema = z7.object({
6660
9528
  /** Unique waiver ID */
6661
- id: z6.string(),
9529
+ id: z7.string(),
6662
9530
  /** Match criteria */
6663
9531
  match: WaiverMatchSchema.refine((m) => m.fingerprint || m.ruleId, { message: "Waiver must specify fingerprint or ruleId" }),
6664
9532
  /** Justification for the waiver */
6665
- reason: z6.string().min(1),
9533
+ reason: z7.string().min(1),
6666
9534
  /** Who created this waiver */
6667
- createdBy: z6.string().min(1),
9535
+ createdBy: z7.string().min(1),
6668
9536
  /** When the waiver was created */
6669
- createdAt: z6.string().datetime(),
9537
+ createdAt: z7.string().datetime(),
6670
9538
  /** Optional expiration date */
6671
- expiresAt: z6.string().datetime().optional(),
9539
+ expiresAt: z7.string().datetime().optional(),
6672
9540
  /** Optional ticket/issue reference */
6673
- ticketRef: z6.string().optional()
9541
+ ticketRef: z7.string().optional()
6674
9542
  });
6675
- var WaiversFileSchema = z6.object({
9543
+ var WaiversFileSchema = z7.object({
6676
9544
  /** Schema version */
6677
- version: z6.literal("0.1"),
9545
+ version: z7.literal("0.1"),
6678
9546
  /** List of waivers */
6679
- waivers: z6.array(WaiverSchema)
9547
+ waivers: z7.array(WaiverSchema)
6680
9548
  });
6681
9549
 
6682
9550
  // ../policy/dist/schemas/policy-config.js
6683
- import { z as z7 } from "zod";
6684
- var ProfileNameSchema = z7.enum(["startup", "strict", "compliance-lite"]);
6685
- var ThresholdsSchema = z7.object({
9551
+ import { z as z8 } from "zod";
9552
+ var ProfileNameSchema = z8.enum(["startup", "strict", "compliance-lite"]);
9553
+ var ThresholdsSchema = z8.object({
6686
9554
  /** Minimum severity to trigger FAIL status */
6687
9555
  failOnSeverity: SeveritySchema.default("high"),
6688
9556
  /** Minimum severity to trigger WARN status */
6689
9557
  warnOnSeverity: SeveritySchema.default("medium"),
6690
9558
  /** Minimum confidence (0-1) for a finding to trigger FAIL */
6691
- minConfidenceForFail: z7.number().min(0).max(1).default(0.7),
9559
+ minConfidenceForFail: z8.number().min(0).max(1).default(0.7),
6692
9560
  /** Minimum confidence (0-1) for a finding to trigger WARN */
6693
- minConfidenceForWarn: z7.number().min(0).max(1).default(0.5),
9561
+ minConfidenceForWarn: z8.number().min(0).max(1).default(0.5),
6694
9562
  /** Special lower confidence threshold for critical findings */
6695
- minConfidenceCritical: z7.number().min(0).max(1).default(0.5),
9563
+ minConfidenceCritical: z8.number().min(0).max(1).default(0.5),
6696
9564
  /** Maximum number of findings before auto-fail (0 = unlimited) */
6697
- maxFindings: z7.number().int().min(0).default(0),
9565
+ maxFindings: z8.number().int().min(0).default(0),
6698
9566
  /** Maximum number of critical findings before auto-fail (0 = unlimited) */
6699
- maxCritical: z7.number().int().min(0).default(0),
9567
+ maxCritical: z8.number().int().min(0).default(0),
6700
9568
  /** Maximum number of high findings before auto-fail (0 = unlimited) */
6701
- maxHigh: z7.number().int().min(0).default(0)
9569
+ maxHigh: z8.number().int().min(0).default(0)
6702
9570
  });
6703
- var OverrideActionSchema = z7.enum([
9571
+ var OverrideActionSchema = z8.enum([
6704
9572
  "ignore",
6705
9573
  // Skip this finding entirely
6706
9574
  "downgrade",
@@ -6712,56 +9580,62 @@ var OverrideActionSchema = z7.enum([
6712
9580
  "fail"
6713
9581
  // Always fail on this
6714
9582
  ]);
6715
- var OverrideSchema = z7.object({
9583
+ var OverrideSchema = z8.object({
6716
9584
  /** Rule ID pattern (exact or prefix like "VC-AUTH-*") */
6717
- ruleId: z7.string().optional(),
9585
+ ruleId: z8.string().optional(),
6718
9586
  /** Category to match */
6719
9587
  category: CategorySchema.optional(),
6720
9588
  /** Path pattern for evidence file matching (glob-like) */
6721
- pathPattern: z7.string().optional(),
9589
+ pathPattern: z8.string().optional(),
6722
9590
  /** Action to take when matched */
6723
9591
  action: OverrideActionSchema,
6724
9592
  /** Override severity when action is downgrade/upgrade */
6725
9593
  severity: SeveritySchema.optional(),
6726
9594
  /** Comment explaining the override */
6727
- comment: z7.string().optional()
9595
+ comment: z8.string().optional()
6728
9596
  });
6729
- var RegressionPolicySchema = z7.object({
9597
+ var RegressionPolicySchema = z8.object({
6730
9598
  /** Fail on any new high/critical findings */
6731
- failOnNewHighCritical: z7.boolean().default(true),
9599
+ failOnNewHighCritical: z8.boolean().default(true),
6732
9600
  /** Fail on any severity regression (e.g., medium became high) */
6733
- failOnSeverityRegression: z7.boolean().default(false),
9601
+ failOnSeverityRegression: z8.boolean().default(false),
6734
9602
  /** Fail on net increase in findings */
6735
- failOnNetIncrease: z7.boolean().default(false),
9603
+ failOnNetIncrease: z8.boolean().default(false),
6736
9604
  /** Warn on any new findings */
6737
- warnOnNewFindings: z7.boolean().default(true)
9605
+ warnOnNewFindings: z8.boolean().default(true),
9606
+ /** Fail on protection removed from routes (auth/validation coverage decreased) */
9607
+ failOnProtectionRemoved: z8.boolean().default(false),
9608
+ /** Warn on protection removed from routes */
9609
+ warnOnProtectionRemoved: z8.boolean().default(true),
9610
+ /** Fail on any semantic regression (coverage decrease, severity group increase) */
9611
+ failOnSemanticRegression: z8.boolean().default(false)
6738
9612
  });
6739
- var PolicyConfigSchema = z7.object({
9613
+ var PolicyConfigSchema = z8.object({
6740
9614
  /** Profile name for presets */
6741
9615
  profile: ProfileNameSchema.optional(),
6742
9616
  /** Threshold configuration */
6743
9617
  thresholds: ThresholdsSchema.default({}),
6744
9618
  /** Override rules */
6745
- overrides: z7.array(OverrideSchema).default([]),
9619
+ overrides: z8.array(OverrideSchema).default([]),
6746
9620
  /** Regression policy */
6747
9621
  regression: RegressionPolicySchema.default({})
6748
9622
  });
6749
- var ConfigFileSchema = z7.object({
9623
+ var ConfigFileSchema = z8.object({
6750
9624
  /** Policy configuration */
6751
9625
  policy: PolicyConfigSchema.optional(),
6752
9626
  /** Path to waivers file */
6753
- waiversPath: z7.string().optional()
9627
+ waiversPath: z8.string().optional()
6754
9628
  });
6755
9629
 
6756
9630
  // ../policy/dist/schemas/policy-report.js
6757
- import { z as z8 } from "zod";
9631
+ import { z as z9 } from "zod";
6758
9632
  var POLICY_REPORT_VERSION = "0.1";
6759
- var PolicyStatusSchema = z8.enum(["pass", "warn", "fail"]);
6760
- var PolicyReasonSchema = z8.object({
9633
+ var PolicyStatusSchema = z9.enum(["pass", "warn", "fail"]);
9634
+ var PolicyReasonSchema = z9.object({
6761
9635
  /** The status this reason contributes to */
6762
9636
  status: PolicyStatusSchema,
6763
9637
  /** Machine-readable reason code */
6764
- code: z8.enum([
9638
+ code: z9.enum([
6765
9639
  "severity_threshold",
6766
9640
  "confidence_threshold",
6767
9641
  "count_threshold",
@@ -6769,85 +9643,127 @@ var PolicyReasonSchema = z8.object({
6769
9643
  "severity_regression",
6770
9644
  "net_increase",
6771
9645
  "override_fail",
6772
- "no_issues"
9646
+ "no_issues",
9647
+ // Semantic regression codes
9648
+ "protection_removed",
9649
+ "coverage_decreased",
9650
+ "severity_group_increase",
9651
+ "semantic_regression"
6773
9652
  ]),
6774
9653
  /** Human-readable description */
6775
- message: z8.string(),
9654
+ message: z9.string(),
6776
9655
  /** Optional finding IDs that triggered this */
6777
- findingIds: z8.array(z8.string()).optional(),
9656
+ findingIds: z9.array(z9.string()).optional(),
6778
9657
  /** Optional details */
6779
- details: z8.record(z8.unknown()).optional()
9658
+ details: z9.record(z9.unknown()).optional()
6780
9659
  });
6781
- var PolicySummaryCountsSchema = z8.object({
9660
+ var PolicySummaryCountsSchema = z9.object({
6782
9661
  /** Total findings after filtering */
6783
- total: z8.number().int(),
9662
+ total: z9.number().int(),
6784
9663
  /** By severity */
6785
- bySeverity: z8.object({
6786
- critical: z8.number().int(),
6787
- high: z8.number().int(),
6788
- medium: z8.number().int(),
6789
- low: z8.number().int(),
6790
- info: z8.number().int()
9664
+ bySeverity: z9.object({
9665
+ critical: z9.number().int(),
9666
+ high: z9.number().int(),
9667
+ medium: z9.number().int(),
9668
+ low: z9.number().int(),
9669
+ info: z9.number().int()
6791
9670
  }),
6792
9671
  /** By category */
6793
- byCategory: z8.record(CategorySchema, z8.number().int()),
9672
+ byCategory: z9.record(CategorySchema, z9.number().int()),
6794
9673
  /** Count of waived findings */
6795
- waived: z8.number().int(),
9674
+ waived: z9.number().int(),
6796
9675
  /** Count of ignored by override */
6797
- ignored: z8.number().int()
9676
+ ignored: z9.number().int()
9677
+ });
9678
+ var ProtectionRegressionSchema = z9.object({
9679
+ /** Route identifier (e.g., "/api/users:POST") */
9680
+ routeId: z9.string(),
9681
+ /** File containing the route */
9682
+ file: z9.string(),
9683
+ /** HTTP method */
9684
+ method: z9.string(),
9685
+ /** Type of protection that was removed */
9686
+ protectionType: z9.enum(["auth", "validation", "rate-limit", "middleware"]),
9687
+ /** Description of what changed */
9688
+ description: z9.string(),
9689
+ /** Related finding fingerprints */
9690
+ relatedFingerprints: z9.array(z9.string()).optional()
9691
+ });
9692
+ var SemanticRegressionSchema = z9.object({
9693
+ /** Type of semantic regression */
9694
+ type: z9.enum([
9695
+ "protection_removed",
9696
+ // Auth/validation removed from route
9697
+ "coverage_decreased",
9698
+ // Fewer routes protected
9699
+ "severity_group_increase"
9700
+ // Multiple findings in same group got worse
9701
+ ]),
9702
+ /** Severity of this regression */
9703
+ severity: SeveritySchema,
9704
+ /** Human-readable description */
9705
+ description: z9.string(),
9706
+ /** Route or fingerprint group affected */
9707
+ affectedId: z9.string(),
9708
+ /** Detailed evidence */
9709
+ details: z9.record(z9.unknown()).optional()
6798
9710
  });
6799
- var RegressionSummarySchema = z8.object({
9711
+ var RegressionSummarySchema = z9.object({
6800
9712
  /** Baseline artifact path or identifier */
6801
- baselineId: z8.string(),
9713
+ baselineId: z9.string(),
6802
9714
  /** When baseline was generated */
6803
- baselineGeneratedAt: z8.string(),
9715
+ baselineGeneratedAt: z9.string(),
6804
9716
  /** New findings (not in baseline) */
6805
- newFindings: z8.array(z8.object({
6806
- findingId: z8.string(),
6807
- fingerprint: z8.string(),
9717
+ newFindings: z9.array(z9.object({
9718
+ findingId: z9.string(),
9719
+ fingerprint: z9.string(),
6808
9720
  severity: SeveritySchema,
6809
- ruleId: z8.string(),
6810
- title: z8.string()
9721
+ ruleId: z9.string(),
9722
+ title: z9.string()
6811
9723
  })),
6812
9724
  /** Resolved findings (in baseline but not current) */
6813
- resolvedFindings: z8.array(z8.object({
6814
- fingerprint: z8.string(),
9725
+ resolvedFindings: z9.array(z9.object({
9726
+ fingerprint: z9.string(),
6815
9727
  severity: SeveritySchema,
6816
- ruleId: z8.string(),
6817
- title: z8.string()
9728
+ ruleId: z9.string(),
9729
+ title: z9.string()
6818
9730
  })),
6819
9731
  /** Persisting findings (in both) */
6820
- persistingCount: z8.number().int(),
9732
+ persistingCount: z9.number().int(),
6821
9733
  /** Severity regressions (same fingerprint but higher severity) */
6822
- severityRegressions: z8.array(z8.object({
6823
- fingerprint: z8.string(),
6824
- ruleId: z8.string(),
9734
+ severityRegressions: z9.array(z9.object({
9735
+ fingerprint: z9.string(),
9736
+ ruleId: z9.string(),
6825
9737
  previousSeverity: SeveritySchema,
6826
9738
  currentSeverity: SeveritySchema,
6827
- title: z8.string()
9739
+ title: z9.string()
6828
9740
  })),
6829
9741
  /** Net change in finding count */
6830
- netChange: z8.number().int()
9742
+ netChange: z9.number().int(),
9743
+ /** Protection regressions (auth/validation removed from routes) */
9744
+ protectionRegressions: z9.array(ProtectionRegressionSchema).optional(),
9745
+ /** Semantic regressions (abstract security property degradations) */
9746
+ semanticRegressions: z9.array(SemanticRegressionSchema).optional()
6831
9747
  });
6832
- var WaivedFindingSchema = z8.object({
9748
+ var WaivedFindingSchema = z9.object({
6833
9749
  /** The finding that was waived */
6834
- finding: z8.object({
6835
- id: z8.string(),
6836
- fingerprint: z8.string(),
6837
- ruleId: z8.string(),
9750
+ finding: z9.object({
9751
+ id: z9.string(),
9752
+ fingerprint: z9.string(),
9753
+ ruleId: z9.string(),
6838
9754
  severity: SeveritySchema,
6839
- title: z8.string()
9755
+ title: z9.string()
6840
9756
  }),
6841
9757
  /** The waiver that matched */
6842
9758
  waiver: WaiverSchema,
6843
9759
  /** Whether waiver is expired */
6844
- expired: z8.boolean()
9760
+ expired: z9.boolean()
6845
9761
  });
6846
- var PolicyReportSchema = z8.object({
9762
+ var PolicyReportSchema = z9.object({
6847
9763
  /** Report schema version */
6848
- policyVersion: z8.literal(POLICY_REPORT_VERSION),
9764
+ policyVersion: z9.literal(POLICY_REPORT_VERSION),
6849
9765
  /** When evaluation was performed */
6850
- evaluatedAt: z8.string().datetime(),
9766
+ evaluatedAt: z9.string().datetime(),
6851
9767
  /** Profile name used */
6852
9768
  profileName: ProfileNameSchema.nullable(),
6853
9769
  /** Final status */
@@ -6855,36 +9771,36 @@ var PolicyReportSchema = z8.object({
6855
9771
  /** Thresholds applied */
6856
9772
  thresholds: ThresholdsSchema,
6857
9773
  /** Overrides applied */
6858
- overrides: z8.array(OverrideSchema),
9774
+ overrides: z9.array(OverrideSchema),
6859
9775
  /** Regression policy applied */
6860
9776
  regressionPolicy: RegressionPolicySchema,
6861
9777
  /** Summary counts */
6862
9778
  summary: PolicySummaryCountsSchema,
6863
9779
  /** Reasons for the status */
6864
- reasons: z8.array(PolicyReasonSchema),
9780
+ reasons: z9.array(PolicyReasonSchema),
6865
9781
  /** Regression summary (if baseline provided) */
6866
9782
  regression: RegressionSummarySchema.optional(),
6867
9783
  /** Waived findings */
6868
- waivedFindings: z8.array(WaivedFindingSchema),
9784
+ waivedFindings: z9.array(WaivedFindingSchema),
6869
9785
  /** Active (non-waived, non-ignored) findings included in evaluation */
6870
- activeFindings: z8.array(z8.object({
6871
- id: z8.string(),
6872
- fingerprint: z8.string(),
6873
- ruleId: z8.string(),
9786
+ activeFindings: z9.array(z9.object({
9787
+ id: z9.string(),
9788
+ fingerprint: z9.string(),
9789
+ ruleId: z9.string(),
6874
9790
  severity: SeveritySchema,
6875
9791
  originalSeverity: SeveritySchema.optional(),
6876
- confidence: z8.number(),
6877
- title: z8.string(),
9792
+ confidence: z9.number(),
9793
+ title: z9.string(),
6878
9794
  category: CategorySchema,
6879
- evidencePaths: z8.array(z8.string())
9795
+ evidencePaths: z9.array(z9.string())
6880
9796
  })),
6881
9797
  /** Recommended exit code (0 = pass/warn, 1 = fail) */
6882
- exitCode: z8.union([z8.literal(0), z8.literal(1)]),
9798
+ exitCode: z9.union([z9.literal(0), z9.literal(1)]),
6883
9799
  /** Source artifact info */
6884
- artifact: z8.object({
6885
- path: z8.string().optional(),
6886
- generatedAt: z8.string(),
6887
- repoName: z8.string().optional()
9800
+ artifact: z9.object({
9801
+ path: z9.string().optional(),
9802
+ generatedAt: z9.string(),
9803
+ repoName: z9.string().optional()
6888
9804
  })
6889
9805
  });
6890
9806
 
@@ -6930,7 +9846,10 @@ var STARTUP_REGRESSION = {
6930
9846
  failOnNewHighCritical: true,
6931
9847
  failOnSeverityRegression: false,
6932
9848
  failOnNetIncrease: false,
6933
- warnOnNewFindings: true
9849
+ warnOnNewFindings: true,
9850
+ failOnProtectionRemoved: false,
9851
+ warnOnProtectionRemoved: true,
9852
+ failOnSemanticRegression: false
6934
9853
  };
6935
9854
  var STARTUP_PROFILE = {
6936
9855
  profile: "startup",
@@ -6952,7 +9871,10 @@ var STRICT_REGRESSION = {
6952
9871
  failOnNewHighCritical: true,
6953
9872
  failOnSeverityRegression: true,
6954
9873
  failOnNetIncrease: false,
6955
- warnOnNewFindings: true
9874
+ warnOnNewFindings: true,
9875
+ failOnProtectionRemoved: true,
9876
+ warnOnProtectionRemoved: true,
9877
+ failOnSemanticRegression: false
6956
9878
  };
6957
9879
  var STRICT_PROFILE = {
6958
9880
  profile: "strict",
@@ -6974,7 +9896,10 @@ var COMPLIANCE_LITE_REGRESSION = {
6974
9896
  failOnNewHighCritical: true,
6975
9897
  failOnSeverityRegression: true,
6976
9898
  failOnNetIncrease: true,
6977
- warnOnNewFindings: true
9899
+ warnOnNewFindings: true,
9900
+ failOnProtectionRemoved: true,
9901
+ warnOnProtectionRemoved: true,
9902
+ failOnSemanticRegression: true
6978
9903
  };
6979
9904
  var COMPLIANCE_LITE_PROFILE = {
6980
9905
  profile: "compliance-lite",
@@ -7012,7 +9937,7 @@ function matchRuleId(ruleId, pattern) {
7012
9937
  return ruleId === pattern;
7013
9938
  }
7014
9939
  function matchPathPattern(evidencePaths, pattern) {
7015
- return evidencePaths.some((path12) => micromatch.isMatch(path12, pattern));
9940
+ return evidencePaths.some((path14) => micromatch.isMatch(path14, pattern));
7016
9941
  }
7017
9942
  function isWaiverExpired(waiver, now = /* @__PURE__ */ new Date()) {
7018
9943
  if (!waiver.expiresAt) {
@@ -7113,6 +10038,44 @@ function removeWaiver(file, waiverId) {
7113
10038
  }
7114
10039
 
7115
10040
  // ../policy/dist/regression.js
10041
+ var PROTECTION_RULE_MAPPING = {
10042
+ "VC-AUTH": "auth",
10043
+ "VC-VAL": "validation",
10044
+ "VC-RATE": "rate-limit",
10045
+ "VC-MW": "middleware",
10046
+ "VC-LIFE-001": "auth",
10047
+ "VC-LIFE-002": "validation",
10048
+ "VC-LIFE-003": "rate-limit"
10049
+ };
10050
+ function extractRouteId(finding) {
10051
+ if (finding.evidence.length === 0)
10052
+ return null;
10053
+ const file = finding.evidence[0].file;
10054
+ const methodMatch = finding.title.match(/\b(GET|POST|PUT|PATCH|DELETE)\b/);
10055
+ const method = methodMatch ? methodMatch[1] : "UNKNOWN";
10056
+ return `${file}:${method}`;
10057
+ }
10058
+ function getProtectionType(ruleId) {
10059
+ for (const [prefix, type] of Object.entries(PROTECTION_RULE_MAPPING)) {
10060
+ if (ruleId.startsWith(prefix)) {
10061
+ return type;
10062
+ }
10063
+ }
10064
+ return null;
10065
+ }
10066
+ function groupByRoute(findings) {
10067
+ const groups = /* @__PURE__ */ new Map();
10068
+ for (const finding of findings) {
10069
+ const routeId = extractRouteId(finding);
10070
+ if (!routeId)
10071
+ continue;
10072
+ if (!groups.has(routeId)) {
10073
+ groups.set(routeId, []);
10074
+ }
10075
+ groups.get(routeId).push(finding);
10076
+ }
10077
+ return groups;
10078
+ }
7116
10079
  function buildFingerprintIndex(findings) {
7117
10080
  const index = /* @__PURE__ */ new Map();
7118
10081
  for (const finding of findings) {
@@ -7167,6 +10130,8 @@ function computeRegression(current, baseline) {
7167
10130
  }
7168
10131
  }
7169
10132
  const netChange = current.findings.length - baseline.findings.length;
10133
+ const protectionRegressions = detectProtectionRegressions(current, baseline);
10134
+ const semanticRegressions = detectSemanticRegressions(current, baseline);
7170
10135
  return {
7171
10136
  baselineId: baseline.repo?.name ?? "unknown",
7172
10137
  baselineGeneratedAt: baseline.generatedAt,
@@ -7174,7 +10139,9 @@ function computeRegression(current, baseline) {
7174
10139
  resolvedFindings,
7175
10140
  persistingCount,
7176
10141
  severityRegressions,
7177
- netChange
10142
+ netChange,
10143
+ protectionRegressions: protectionRegressions.length > 0 ? protectionRegressions : void 0,
10144
+ semanticRegressions: semanticRegressions.length > 0 ? semanticRegressions : void 0
7178
10145
  };
7179
10146
  }
7180
10147
  function hasNewHighCritical(regression) {
@@ -7189,6 +10156,107 @@ function hasSeverityRegressions(regression) {
7189
10156
  function hasNetIncrease(regression) {
7190
10157
  return regression.netChange > 0;
7191
10158
  }
10159
+ function detectProtectionRegressions(current, baseline) {
10160
+ const regressions = [];
10161
+ const baselineByRoute = groupByRoute(baseline.findings);
10162
+ const currentByRoute = groupByRoute(current.findings);
10163
+ for (const [routeId, currentFindings] of currentByRoute) {
10164
+ const baselineFindings = baselineByRoute.get(routeId) || [];
10165
+ for (const finding of currentFindings) {
10166
+ const protectionType = getProtectionType(finding.ruleId);
10167
+ if (!protectionType)
10168
+ continue;
10169
+ const hadSimilarFinding = baselineFindings.some((bf) => bf.ruleId === finding.ruleId || bf.fingerprint === finding.fingerprint);
10170
+ if (!hadSimilarFinding) {
10171
+ const [file, method] = routeId.split(":");
10172
+ regressions.push({
10173
+ routeId,
10174
+ file,
10175
+ method,
10176
+ protectionType,
10177
+ description: `New ${protectionType} issue detected: ${finding.title}`,
10178
+ relatedFingerprints: [finding.fingerprint]
10179
+ });
10180
+ }
10181
+ }
10182
+ }
10183
+ return regressions;
10184
+ }
10185
+ function detectSemanticRegressions(current, baseline) {
10186
+ const regressions = [];
10187
+ const protectionRegressions = detectProtectionRegressions(current, baseline);
10188
+ for (const pr of protectionRegressions) {
10189
+ regressions.push({
10190
+ type: "protection_removed",
10191
+ severity: "high",
10192
+ description: pr.description,
10193
+ affectedId: pr.routeId,
10194
+ details: { protectionType: pr.protectionType, file: pr.file, method: pr.method }
10195
+ });
10196
+ }
10197
+ const baselineRoutes = new Set(groupByRoute(baseline.findings).keys());
10198
+ const currentRoutes = new Set(groupByRoute(current.findings).keys());
10199
+ const newlyUnprotectedRoutes = [];
10200
+ for (const route of currentRoutes) {
10201
+ if (!baselineRoutes.has(route)) {
10202
+ newlyUnprotectedRoutes.push(route);
10203
+ }
10204
+ }
10205
+ if (newlyUnprotectedRoutes.length > 0 && baseline.findings.length > 0) {
10206
+ const coverageIncrease = newlyUnprotectedRoutes.length / Math.max(baselineRoutes.size, 1);
10207
+ if (coverageIncrease > 0.1) {
10208
+ regressions.push({
10209
+ type: "coverage_decreased",
10210
+ severity: "medium",
10211
+ description: `${newlyUnprotectedRoutes.length} new routes have security findings (coverage decreased)`,
10212
+ affectedId: "coverage",
10213
+ details: {
10214
+ previousRouteCount: baselineRoutes.size,
10215
+ currentRouteCount: currentRoutes.size,
10216
+ newlyAffectedRoutes: newlyUnprotectedRoutes.slice(0, 5)
10217
+ // Limit to 5 for readability
10218
+ }
10219
+ });
10220
+ }
10221
+ }
10222
+ const currentIndex = buildFingerprintIndex(current.findings);
10223
+ const baselineIndex = buildFingerprintIndex(baseline.findings);
10224
+ const baselineBySeverity = /* @__PURE__ */ new Map();
10225
+ const currentBySeverity = /* @__PURE__ */ new Map();
10226
+ for (const finding of baseline.findings) {
10227
+ const key = finding.ruleId.replace(/-\d+$/, "");
10228
+ const current2 = baselineBySeverity.get(key) || 0;
10229
+ baselineBySeverity.set(key, Math.max(current2, SEVERITY_ORDER2[finding.severity]));
10230
+ }
10231
+ for (const finding of current.findings) {
10232
+ const key = finding.ruleId.replace(/-\d+$/, "");
10233
+ const curr = currentBySeverity.get(key) || 0;
10234
+ currentBySeverity.set(key, Math.max(curr, SEVERITY_ORDER2[finding.severity]));
10235
+ }
10236
+ for (const [rulePrefix, currentMax] of currentBySeverity) {
10237
+ const baselineMax = baselineBySeverity.get(rulePrefix) || 0;
10238
+ if (currentMax > baselineMax && currentMax >= SEVERITY_ORDER2.high) {
10239
+ const severityName = Object.entries(SEVERITY_ORDER2).find(([, v]) => v === currentMax)?.[0];
10240
+ regressions.push({
10241
+ type: "severity_group_increase",
10242
+ severity: severityName,
10243
+ description: `${rulePrefix} findings have increased to ${severityName} severity`,
10244
+ affectedId: rulePrefix,
10245
+ details: {
10246
+ previousMaxSeverity: baselineMax,
10247
+ currentMaxSeverity: currentMax
10248
+ }
10249
+ });
10250
+ }
10251
+ }
10252
+ return regressions;
10253
+ }
10254
+ function hasProtectionRegressions(regression) {
10255
+ return (regression.protectionRegressions?.length ?? 0) > 0;
10256
+ }
10257
+ function hasSemanticRegressions(regression) {
10258
+ return (regression.semanticRegressions?.length ?? 0) > 0;
10259
+ }
7192
10260
 
7193
10261
  // ../policy/dist/evaluator.js
7194
10262
  function applyOverrides(finding, overrides) {
@@ -7257,6 +10325,11 @@ function computeSummaryCounts(processed, waivedFindings) {
7257
10325
  uploads: 0,
7258
10326
  hallucinations: 0,
7259
10327
  abuse: 0,
10328
+ // Phase 4 categories
10329
+ correlation: 0,
10330
+ authorization: 0,
10331
+ lifecycle: 0,
10332
+ "supply-chain": 0,
7260
10333
  other: 0
7261
10334
  };
7262
10335
  for (const p of active) {
@@ -7400,6 +10473,52 @@ function evaluateRegression(regression, policy) {
7400
10473
  findingIds: regression.newFindings.map((f) => f.findingId)
7401
10474
  });
7402
10475
  }
10476
+ if (hasProtectionRegressions(regression)) {
10477
+ const protectionRegs = regression.protectionRegressions || [];
10478
+ if (policy.failOnProtectionRemoved) {
10479
+ status = "fail";
10480
+ reasons.push({
10481
+ status: "fail",
10482
+ code: "protection_removed",
10483
+ message: `${protectionRegs.length} route(s) lost protection coverage`,
10484
+ details: {
10485
+ regressions: protectionRegs.map((r) => ({
10486
+ route: r.routeId,
10487
+ protectionType: r.protectionType
10488
+ }))
10489
+ }
10490
+ });
10491
+ } else if (policy.warnOnProtectionRemoved && status === "pass") {
10492
+ status = "warn";
10493
+ reasons.push({
10494
+ status: "warn",
10495
+ code: "protection_removed",
10496
+ message: `${protectionRegs.length} route(s) may have lost protection`,
10497
+ details: {
10498
+ regressions: protectionRegs.map((r) => ({
10499
+ route: r.routeId,
10500
+ protectionType: r.protectionType
10501
+ }))
10502
+ }
10503
+ });
10504
+ }
10505
+ }
10506
+ if (policy.failOnSemanticRegression && hasSemanticRegressions(regression)) {
10507
+ const semanticRegs = regression.semanticRegressions || [];
10508
+ status = "fail";
10509
+ reasons.push({
10510
+ status: "fail",
10511
+ code: "semantic_regression",
10512
+ message: `${semanticRegs.length} semantic regression(s) detected`,
10513
+ details: {
10514
+ regressions: semanticRegs.map((r) => ({
10515
+ type: r.type,
10516
+ severity: r.severity,
10517
+ description: r.description
10518
+ }))
10519
+ }
10520
+ });
10521
+ }
7403
10522
  return { status, reasons };
7404
10523
  }
7405
10524
  function mergeStatus(a, b) {
@@ -8411,22 +11530,11 @@ var PLAN_FEATURES = {
8411
11530
  "abuse_classification",
8412
11531
  "architecture_maps",
8413
11532
  "signed_export"
8414
- ],
8415
- enterprise: [
8416
- "baseline",
8417
- "policy_customization",
8418
- "abuse_classification",
8419
- "architecture_maps",
8420
- "signed_export",
8421
- "sso",
8422
- "audit_logs",
8423
- "custom_rules"
8424
11533
  ]
8425
11534
  };
8426
11535
  var PLAN_NAMES = {
8427
11536
  free: "Free",
8428
- pro: "Pro",
8429
- enterprise: "Enterprise"
11537
+ pro: "Pro"
8430
11538
  };
8431
11539
  function isDemoLicense(licenseId) {
8432
11540
  return licenseId.startsWith("demo-") || licenseId.startsWith("trial-");
@@ -8466,7 +11574,7 @@ function parseLicenseKey(licenseKey) {
8466
11574
  const [payloadB64, signature] = parts;
8467
11575
  const payloadJson = atob(payloadB64);
8468
11576
  const payload = JSON.parse(payloadJson);
8469
- if (!payload.id || !payload.plan || !payload.email || !payload.issuedAt) {
11577
+ if (!payload.id || !payload.plan || !payload.issuedAt) {
8470
11578
  return null;
8471
11579
  }
8472
11580
  return { payload, signature };
@@ -8572,10 +11680,10 @@ function createLicense(options, privateKeyB64) {
8572
11680
  plan: options.plan,
8573
11681
  name: options.name,
8574
11682
  email: options.email,
11683
+ customerId: options.customerId,
8575
11684
  issuedAt: now.toISOString(),
8576
11685
  expiresAt: options.expiresAt?.toISOString() ?? null,
8577
- features: allFeatures,
8578
- ...options.seats && { seats: options.seats }
11686
+ features: allFeatures
8579
11687
  };
8580
11688
  const payloadJson = JSON.stringify(payload);
8581
11689
  const payloadB64 = Buffer.from(payloadJson).toString("base64");
@@ -9002,9 +12110,597 @@ async function keygenAction() {
9002
12110
  console.log();
9003
12111
  }
9004
12112
 
12113
+ // src/commands/verify-determinism.ts
12114
+ import path12 from "path";
12115
+ import crypto9 from "crypto";
12116
+ function normalizeArtifact(artifact) {
12117
+ const normalized = JSON.parse(JSON.stringify(artifact));
12118
+ normalized.generatedAt = "NORMALIZED";
12119
+ if (normalized.metrics) {
12120
+ normalized.metrics.scanDurationMs = 0;
12121
+ }
12122
+ if (normalized.findings) {
12123
+ normalized.findings.sort((a, b) => a.fingerprint.localeCompare(b.fingerprint));
12124
+ }
12125
+ if (normalized.routeMap?.routes) {
12126
+ normalized.routeMap.routes.sort((a, b) => a.routeId.localeCompare(b.routeId));
12127
+ }
12128
+ if (normalized.intentMap?.intents) {
12129
+ normalized.intentMap.intents.sort((a, b) => a.intentId.localeCompare(b.intentId));
12130
+ }
12131
+ if (normalized.middlewareMap?.coverage) {
12132
+ normalized.middlewareMap.coverage.sort((a, b) => a.routeId.localeCompare(b.routeId));
12133
+ }
12134
+ if (normalized.proofTraces) {
12135
+ const sortedTraces = {};
12136
+ for (const key of Object.keys(normalized.proofTraces).sort()) {
12137
+ sortedTraces[key] = normalized.proofTraces[key];
12138
+ }
12139
+ normalized.proofTraces = sortedTraces;
12140
+ }
12141
+ return normalized;
12142
+ }
12143
+ function hashContent(content) {
12144
+ return crypto9.createHash("sha256").update(content).digest("hex");
12145
+ }
12146
+ async function runScannersQuietly(context) {
12147
+ const allFindings = [];
12148
+ const seenFingerprints = /* @__PURE__ */ new Set();
12149
+ for (const pack of ALL_SCANNER_PACKS) {
12150
+ for (const scanner of pack.scanners) {
12151
+ try {
12152
+ const findings = await scanner(context);
12153
+ for (const finding of findings) {
12154
+ if (!seenFingerprints.has(finding.fingerprint)) {
12155
+ seenFingerprints.add(finding.fingerprint);
12156
+ allFindings.push(finding);
12157
+ }
12158
+ }
12159
+ } catch {
12160
+ }
12161
+ }
12162
+ }
12163
+ return allFindings;
12164
+ }
12165
+ function createArtifact2(findings, targetDir, durationMs, filesScanned) {
12166
+ const summary = computeSummary(findings);
12167
+ const gitInfo = getGitInfo(targetDir);
12168
+ const name = getRepoName(targetDir);
12169
+ return {
12170
+ artifactVersion: ARTIFACT_VERSION,
12171
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12172
+ tool: {
12173
+ name: "vibecheck",
12174
+ version: CLI_VERSION
12175
+ },
12176
+ repo: {
12177
+ name,
12178
+ rootPathHash: hashPath(targetDir),
12179
+ git: gitInfo
12180
+ },
12181
+ summary,
12182
+ findings,
12183
+ metrics: {
12184
+ filesScanned,
12185
+ linesOfCode: 0,
12186
+ scanDurationMs: durationMs,
12187
+ rulesExecuted: ALL_SCANNERS.length
12188
+ }
12189
+ };
12190
+ }
12191
+ async function runScan(targetDir, runNumber, includeSarif) {
12192
+ const startTime = Date.now();
12193
+ const context = await buildScanContext(targetDir);
12194
+ const findings = await runScannersQuietly(context);
12195
+ const endTime = Date.now();
12196
+ const durationMs = endTime - startTime;
12197
+ const artifact = createArtifact2(
12198
+ findings,
12199
+ targetDir,
12200
+ durationMs,
12201
+ context.fileIndex.allSourceFiles.length
12202
+ );
12203
+ const normalizedArtifact = normalizeArtifact(artifact);
12204
+ const jsonContent = JSON.stringify(normalizedArtifact, null, 2);
12205
+ const jsonHash = hashContent(jsonContent);
12206
+ const result = {
12207
+ runNumber,
12208
+ artifact,
12209
+ jsonHash,
12210
+ jsonContent,
12211
+ durationMs
12212
+ };
12213
+ if (includeSarif) {
12214
+ const sarifLog = toSarif(normalizedArtifact);
12215
+ const sarifContent = sarifToJson(sarifLog);
12216
+ result.sarifHash = hashContent(sarifContent);
12217
+ result.sarifContent = sarifContent;
12218
+ }
12219
+ return result;
12220
+ }
12221
+ function compareRuns(run1, run2) {
12222
+ const differences = [];
12223
+ const jsonMatch = run1.jsonHash === run2.jsonHash;
12224
+ let sarifMatch = true;
12225
+ if (!jsonMatch) {
12226
+ const json1 = JSON.parse(run1.jsonContent);
12227
+ const json2 = JSON.parse(run2.jsonContent);
12228
+ if (json1.summary?.totalFindings !== json2.summary?.totalFindings) {
12229
+ differences.push(
12230
+ `Finding count differs: run${run1.runNumber}=${json1.summary?.totalFindings}, run${run2.runNumber}=${json2.summary?.totalFindings}`
12231
+ );
12232
+ }
12233
+ const fp1 = new Set((json1.findings || []).map((f) => f.fingerprint));
12234
+ const fp2 = new Set((json2.findings || []).map((f) => f.fingerprint));
12235
+ const onlyIn1 = [...fp1].filter((fp) => !fp2.has(fp));
12236
+ const onlyIn2 = [...fp2].filter((fp) => !fp1.has(fp));
12237
+ if (onlyIn1.length > 0) {
12238
+ differences.push(`Fingerprints only in run${run1.runNumber}: ${onlyIn1.slice(0, 3).join(", ")}${onlyIn1.length > 3 ? "..." : ""}`);
12239
+ }
12240
+ if (onlyIn2.length > 0) {
12241
+ differences.push(`Fingerprints only in run${run2.runNumber}: ${onlyIn2.slice(0, 3).join(", ")}${onlyIn2.length > 3 ? "..." : ""}`);
12242
+ }
12243
+ const routes1 = json1.routeMap?.routes?.length ?? 0;
12244
+ const routes2 = json2.routeMap?.routes?.length ?? 0;
12245
+ if (routes1 !== routes2) {
12246
+ differences.push(`Route count differs: run${run1.runNumber}=${routes1}, run${run2.runNumber}=${routes2}`);
12247
+ }
12248
+ }
12249
+ if (run1.sarifHash && run2.sarifHash) {
12250
+ sarifMatch = run1.sarifHash === run2.sarifHash;
12251
+ if (!sarifMatch) {
12252
+ differences.push("SARIF output differs between runs");
12253
+ }
12254
+ }
12255
+ return { jsonMatch, sarifMatch, differences };
12256
+ }
12257
+ function generateCertificate(targetDir, results, comparisons) {
12258
+ const allJsonMatch = comparisons.every((c) => c.jsonMatch);
12259
+ const allSarifMatch = comparisons.every((c) => c.sarifMatch);
12260
+ const allDifferences = comparisons.flatMap((c) => c.differences);
12261
+ return {
12262
+ certified: allJsonMatch && allSarifMatch,
12263
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
12264
+ targetPath: targetDir,
12265
+ targetPathHash: hashPath(targetDir),
12266
+ runs: results.length,
12267
+ cliVersion: CLI_VERSION,
12268
+ artifactVersion: ARTIFACT_VERSION,
12269
+ totalFindings: results[0]?.artifact.summary.totalFindings ?? 0,
12270
+ jsonHashes: results.map((r) => r.jsonHash),
12271
+ sarifHashes: results[0]?.sarifHash ? results.map((r) => r.sarifHash) : void 0,
12272
+ comparisonDetails: {
12273
+ allJsonMatch,
12274
+ allSarifMatch,
12275
+ differences: allDifferences
12276
+ },
12277
+ runDurations: results.map((r) => r.durationMs)
12278
+ };
12279
+ }
12280
+ function printCertificate(cert, verbose) {
12281
+ const green = "\x1B[32m";
12282
+ const red = "\x1B[31m";
12283
+ const yellow = "\x1B[33m";
12284
+ const cyan = "\x1B[36m";
12285
+ const reset = "\x1B[0m";
12286
+ const bold = "\x1B[1m";
12287
+ console.log("\n" + "\u2550".repeat(70));
12288
+ console.log(`${bold}DETERMINISM VERIFICATION REPORT${reset}`);
12289
+ console.log("\u2550".repeat(70));
12290
+ console.log(`
12291
+ ${cyan}Target:${reset} ${cert.targetPath}`);
12292
+ console.log(`${cyan}Runs:${reset} ${cert.runs}`);
12293
+ console.log(`${cyan}CLI Version:${reset} ${cert.cliVersion}`);
12294
+ console.log(`${cyan}Total Findings:${reset} ${cert.totalFindings}`);
12295
+ console.log(`
12296
+ ${bold}Run Durations:${reset}`);
12297
+ for (let i = 0; i < cert.runDurations.length; i++) {
12298
+ console.log(` Run ${i + 1}: ${cert.runDurations[i]}ms`);
12299
+ }
12300
+ const avgDuration = Math.round(cert.runDurations.reduce((a, b) => a + b, 0) / cert.runDurations.length);
12301
+ console.log(` Average: ${avgDuration}ms`);
12302
+ console.log(`
12303
+ ${bold}JSON Output Hashes:${reset}`);
12304
+ for (let i = 0; i < cert.jsonHashes.length; i++) {
12305
+ const hash = cert.jsonHashes[i];
12306
+ const match = i === 0 || hash === cert.jsonHashes[0];
12307
+ const icon = match ? `${green}\u2713${reset}` : `${red}\u2717${reset}`;
12308
+ console.log(` Run ${i + 1}: ${hash.slice(0, 16)}... ${icon}`);
12309
+ }
12310
+ if (cert.sarifHashes) {
12311
+ console.log(`
12312
+ ${bold}SARIF Output Hashes:${reset}`);
12313
+ for (let i = 0; i < cert.sarifHashes.length; i++) {
12314
+ const hash = cert.sarifHashes[i];
12315
+ const match = i === 0 || hash === cert.sarifHashes[0];
12316
+ const icon = match ? `${green}\u2713${reset}` : `${red}\u2717${reset}`;
12317
+ console.log(` Run ${i + 1}: ${hash.slice(0, 16)}... ${icon}`);
12318
+ }
12319
+ }
12320
+ if (cert.comparisonDetails.differences.length > 0 && verbose) {
12321
+ console.log(`
12322
+ ${bold}${yellow}Differences Detected:${reset}`);
12323
+ for (const diff of cert.comparisonDetails.differences) {
12324
+ console.log(` ${red}\u2022${reset} ${diff}`);
12325
+ }
12326
+ }
12327
+ console.log("\n" + "\u2500".repeat(70));
12328
+ if (cert.certified) {
12329
+ console.log(`${bold}${green}\u2713 DETERMINISM CERTIFIED${reset}`);
12330
+ console.log(`${green}All ${cert.runs} runs produced identical output.${reset}`);
12331
+ } else {
12332
+ console.log(`${bold}${red}\u2717 DETERMINISM VERIFICATION FAILED${reset}`);
12333
+ console.log(`${red}Output differs between runs. See differences above.${reset}`);
12334
+ }
12335
+ console.log("\u2500".repeat(70) + "\n");
12336
+ }
12337
+ async function executeVerifyDeterminism(targetDir, options) {
12338
+ const absoluteTarget = resolvePath(targetDir);
12339
+ const { runs, sarif, out, verbose } = options;
12340
+ if (runs < 2) {
12341
+ console.error("\x1B[31mError: --runs must be at least 2\x1B[0m");
12342
+ return 1;
12343
+ }
12344
+ console.log(`
12345
+ \x1B[36m\x1B[1mVibeCheck Determinism Verification\x1B[0m`);
12346
+ console.log(`\x1B[90mTarget: ${absoluteTarget}\x1B[0m`);
12347
+ console.log(`\x1B[90mRuns: ${runs}${sarif ? " (including SARIF)" : ""}\x1B[0m
12348
+ `);
12349
+ const results = [];
12350
+ for (let i = 1; i <= runs; i++) {
12351
+ const spinner2 = new Spinner(`Running scan ${i}/${runs}`);
12352
+ spinner2.start();
12353
+ try {
12354
+ const result = await runScan(absoluteTarget, i, sarif);
12355
+ results.push(result);
12356
+ spinner2.succeed(`Run ${i}/${runs} complete (${result.durationMs}ms, ${result.artifact.summary.totalFindings} findings)`);
12357
+ } catch (error) {
12358
+ spinner2.fail(`Run ${i}/${runs} failed: ${error instanceof Error ? error.message : String(error)}`);
12359
+ return 1;
12360
+ }
12361
+ }
12362
+ const spinner = new Spinner("Comparing outputs");
12363
+ spinner.start();
12364
+ const comparisons = [];
12365
+ for (let i = 1; i < results.length; i++) {
12366
+ const comparison = compareRuns(results[0], results[i]);
12367
+ comparisons.push(comparison);
12368
+ }
12369
+ spinner.succeed("Comparison complete");
12370
+ const certificate = generateCertificate(absoluteTarget, results, comparisons);
12371
+ printCertificate(certificate, verbose);
12372
+ if (out) {
12373
+ const outPath = resolvePath(out);
12374
+ ensureDir(path12.dirname(outPath));
12375
+ const certPath = outPath.endsWith(".json") ? outPath : path12.join(outPath, "determinism-cert.json");
12376
+ writeFileSync(certPath, JSON.stringify(certificate, null, 2));
12377
+ console.log(`Certificate written to: ${certPath}
12378
+ `);
12379
+ }
12380
+ return certificate.certified ? 0 : 1;
12381
+ }
12382
+ function registerVerifyDeterminismCommand(program2) {
12383
+ program2.command("verify-determinism [target]").description("Verify scan output is deterministic across multiple runs").option(
12384
+ "-n, --runs <number>",
12385
+ "Number of runs to perform",
12386
+ (v) => parseInt(v, 10),
12387
+ 3
12388
+ ).option("--sarif", "Include SARIF output in verification").option("-o, --out <path>", "Output path for certificate JSON").option("-v, --verbose", "Show detailed differences").addHelpText(
12389
+ "after",
12390
+ `
12391
+ Examples:
12392
+ $ vibecheck verify-determinism Verify current directory with 3 runs
12393
+ $ vibecheck verify-determinism ./my-app Verify specific directory
12394
+ $ vibecheck verify-determinism --runs 5 Run 5 verification passes
12395
+ $ vibecheck verify-determinism --sarif Include SARIF in verification
12396
+ $ vibecheck verify-determinism -o ./cert.json Write certificate to file
12397
+ $ vibecheck verify-determinism -v Show detailed differences on failure
12398
+
12399
+ What it does:
12400
+ 1. Runs the scanner N times on the target directory
12401
+ 2. Normalizes output (removes timestamps, sorts arrays)
12402
+ 3. Computes SHA-256 hash of each output
12403
+ 4. Compares hashes to verify determinism
12404
+ 5. Generates a certification report
12405
+ 6. Exits 0 if deterministic, 1 if not
12406
+ `
12407
+ ).action(async (positionalTarget, cmdOptions) => {
12408
+ const targetDir = positionalTarget ?? process.cwd();
12409
+ const options = {
12410
+ runs: cmdOptions.runs,
12411
+ sarif: Boolean(cmdOptions.sarif),
12412
+ out: cmdOptions.out,
12413
+ verbose: Boolean(cmdOptions.verbose)
12414
+ };
12415
+ const exitCode = await executeVerifyDeterminism(targetDir, options);
12416
+ process.exit(exitCode);
12417
+ });
12418
+ }
12419
+
12420
+ // src/commands/badge.ts
12421
+ import path13 from "path";
12422
+ var COLORS = {
12423
+ brightgreen: "#4c1",
12424
+ green: "#97ca00",
12425
+ yellow: "#dfb317",
12426
+ orange: "#fe7d37",
12427
+ red: "#e05d44",
12428
+ blue: "#007ec6",
12429
+ gray: "#555"
12430
+ };
12431
+ function textWidth(text) {
12432
+ let width = 0;
12433
+ for (const char of text) {
12434
+ if ("1iltfj".includes(char)) {
12435
+ width += 4;
12436
+ } else if ("MWmw".includes(char)) {
12437
+ width += 9;
12438
+ } else if ("ABCDEFGHIJKLNOPQRSTUVXYZ".includes(char)) {
12439
+ width += 7;
12440
+ } else {
12441
+ width += 6;
12442
+ }
12443
+ }
12444
+ return width;
12445
+ }
12446
+ function generateSvgBadge(data, style) {
12447
+ const { label, message, color } = data;
12448
+ const labelWidth = textWidth(label) + 10;
12449
+ const messageWidth = textWidth(message) + 10;
12450
+ const totalWidth = labelWidth + messageWidth;
12451
+ const height = 20;
12452
+ const labelX = labelWidth / 2;
12453
+ const messageX = labelWidth + messageWidth / 2;
12454
+ const colorHex = COLORS[color];
12455
+ const labelColor = "#555";
12456
+ const radius = style === "flat" ? 3 : 0;
12457
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${label}: ${message}">
12458
+ <title>${label}: ${message}</title>
12459
+ <linearGradient id="s" x2="0" y2="100%">
12460
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
12461
+ <stop offset="1" stop-opacity=".1"/>
12462
+ </linearGradient>
12463
+ <clipPath id="r">
12464
+ <rect width="${totalWidth}" height="${height}" rx="${radius}" fill="#fff"/>
12465
+ </clipPath>
12466
+ <g clip-path="url(#r)">
12467
+ <rect width="${labelWidth}" height="${height}" fill="${labelColor}"/>
12468
+ <rect x="${labelWidth}" width="${messageWidth}" height="${height}" fill="${colorHex}"/>
12469
+ <rect width="${totalWidth}" height="${height}" fill="url(#s)"/>
12470
+ </g>
12471
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
12472
+ <text x="${labelX}" y="14" fill="#010101" fill-opacity=".3">${escapeXml(label)}</text>
12473
+ <text x="${labelX}" y="13" fill="#fff">${escapeXml(label)}</text>
12474
+ <text x="${messageX}" y="14" fill="#010101" fill-opacity=".3">${escapeXml(message)}</text>
12475
+ <text x="${messageX}" y="13" fill="#fff">${escapeXml(message)}</text>
12476
+ </g>
12477
+ </svg>`;
12478
+ return svg;
12479
+ }
12480
+ function escapeXml(text) {
12481
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
12482
+ }
12483
+ function generateScanStatusBadge(artifact) {
12484
+ const { summary } = artifact;
12485
+ const hasCritical = summary.bySeverity.critical > 0;
12486
+ const hasHigh = summary.bySeverity.high > 0;
12487
+ let message;
12488
+ let color;
12489
+ if (hasCritical) {
12490
+ message = "CRITICAL";
12491
+ color = "red";
12492
+ } else if (hasHigh) {
12493
+ message = "HIGH";
12494
+ color = "orange";
12495
+ } else if (summary.totalFindings > 0) {
12496
+ message = "PASS";
12497
+ color = "yellow";
12498
+ } else {
12499
+ message = "PASS";
12500
+ color = "brightgreen";
12501
+ }
12502
+ return {
12503
+ label: "vibecheck",
12504
+ message,
12505
+ color,
12506
+ filename: "vibecheck-status.svg"
12507
+ };
12508
+ }
12509
+ function generateFindingsCountBadge(artifact) {
12510
+ const { summary } = artifact;
12511
+ const count = summary.totalFindings;
12512
+ let color;
12513
+ if (count === 0) {
12514
+ color = "brightgreen";
12515
+ } else if (summary.bySeverity.critical > 0) {
12516
+ color = "red";
12517
+ } else if (summary.bySeverity.high > 0) {
12518
+ color = "orange";
12519
+ } else if (summary.bySeverity.medium > 0) {
12520
+ color = "yellow";
12521
+ } else {
12522
+ color = "green";
12523
+ }
12524
+ return {
12525
+ label: "findings",
12526
+ message: count.toString(),
12527
+ color,
12528
+ filename: "vibecheck-findings.svg"
12529
+ };
12530
+ }
12531
+ function generateCoverageBadge(artifact) {
12532
+ const metrics = artifact.metrics;
12533
+ if (!metrics?.authCoverage) {
12534
+ return null;
12535
+ }
12536
+ const { totalStateChanging, protectedCount } = metrics.authCoverage;
12537
+ if (totalStateChanging === 0) {
12538
+ return null;
12539
+ }
12540
+ const percentage = Math.round(protectedCount / totalStateChanging * 100);
12541
+ let color;
12542
+ if (percentage >= 90) {
12543
+ color = "brightgreen";
12544
+ } else if (percentage >= 70) {
12545
+ color = "green";
12546
+ } else if (percentage >= 50) {
12547
+ color = "yellow";
12548
+ } else if (percentage >= 30) {
12549
+ color = "orange";
12550
+ } else {
12551
+ color = "red";
12552
+ }
12553
+ return {
12554
+ label: "auth coverage",
12555
+ message: `${percentage}%`,
12556
+ color,
12557
+ filename: "vibecheck-coverage.svg"
12558
+ };
12559
+ }
12560
+ function generateSeverityBadge(artifact) {
12561
+ const { summary } = artifact;
12562
+ const parts = [];
12563
+ if (summary.bySeverity.critical > 0) {
12564
+ parts.push(`${summary.bySeverity.critical}C`);
12565
+ }
12566
+ if (summary.bySeverity.high > 0) {
12567
+ parts.push(`${summary.bySeverity.high}H`);
12568
+ }
12569
+ if (summary.bySeverity.medium > 0) {
12570
+ parts.push(`${summary.bySeverity.medium}M`);
12571
+ }
12572
+ if (summary.bySeverity.low > 0) {
12573
+ parts.push(`${summary.bySeverity.low}L`);
12574
+ }
12575
+ const message = parts.length > 0 ? parts.join(" ") : "clean";
12576
+ let color;
12577
+ if (summary.bySeverity.critical > 0) {
12578
+ color = "red";
12579
+ } else if (summary.bySeverity.high > 0) {
12580
+ color = "orange";
12581
+ } else if (summary.bySeverity.medium > 0) {
12582
+ color = "yellow";
12583
+ } else if (summary.bySeverity.low > 0) {
12584
+ color = "green";
12585
+ } else {
12586
+ color = "brightgreen";
12587
+ }
12588
+ return {
12589
+ label: "severity",
12590
+ message,
12591
+ color,
12592
+ filename: "vibecheck-severity.svg"
12593
+ };
12594
+ }
12595
+ function generateScoreBadge(artifact) {
12596
+ const { summary } = artifact;
12597
+ const score = Math.max(
12598
+ 0,
12599
+ 100 - (summary.bySeverity.critical * 25 + summary.bySeverity.high * 10 + summary.bySeverity.medium * 3 + summary.bySeverity.low * 1)
12600
+ );
12601
+ let color;
12602
+ if (score >= 90) {
12603
+ color = "brightgreen";
12604
+ } else if (score >= 70) {
12605
+ color = "green";
12606
+ } else if (score >= 50) {
12607
+ color = "yellow";
12608
+ } else if (score >= 30) {
12609
+ color = "orange";
12610
+ } else {
12611
+ color = "red";
12612
+ }
12613
+ return {
12614
+ label: "security score",
12615
+ message: `${score}/100`,
12616
+ color,
12617
+ filename: "vibecheck-score.svg"
12618
+ };
12619
+ }
12620
+ async function executeBadge(options) {
12621
+ const { artifact: artifactPath, out, style } = options;
12622
+ const absoluteArtifactPath = resolvePath(artifactPath);
12623
+ const content = readFileSync(absoluteArtifactPath);
12624
+ if (!content) {
12625
+ console.error(`\x1B[31mError: Cannot read artifact file: ${absoluteArtifactPath}\x1B[0m`);
12626
+ return 1;
12627
+ }
12628
+ let artifact;
12629
+ try {
12630
+ const json = JSON.parse(content);
12631
+ artifact = validateArtifact(json);
12632
+ } catch (error) {
12633
+ console.error(`\x1B[31mError: Invalid artifact file: ${error instanceof Error ? error.message : String(error)}\x1B[0m`);
12634
+ return 1;
12635
+ }
12636
+ const absoluteOutPath = resolvePath(out);
12637
+ ensureDir(absoluteOutPath);
12638
+ console.log(`
12639
+ \x1B[36m\x1B[1mVibeCheck Badge Generator\x1B[0m`);
12640
+ console.log(`\x1B[90mArtifact: ${absoluteArtifactPath}\x1B[0m`);
12641
+ console.log(`\x1B[90mOutput: ${absoluteOutPath}\x1B[0m
12642
+ `);
12643
+ const badges = [
12644
+ generateScanStatusBadge(artifact),
12645
+ generateFindingsCountBadge(artifact),
12646
+ generateSeverityBadge(artifact),
12647
+ generateScoreBadge(artifact)
12648
+ ];
12649
+ const coverageBadge = generateCoverageBadge(artifact);
12650
+ if (coverageBadge) {
12651
+ badges.push(coverageBadge);
12652
+ }
12653
+ const writtenFiles = [];
12654
+ for (const badge of badges) {
12655
+ const svg = generateSvgBadge(badge, style);
12656
+ const filePath = path13.join(absoluteOutPath, badge.filename);
12657
+ writeFileSync(filePath, svg);
12658
+ writtenFiles.push(filePath);
12659
+ console.log(` \x1B[32m\u2713\x1B[0m ${badge.filename} (${badge.label}: ${badge.message})`);
12660
+ }
12661
+ console.log(`
12662
+ \x1B[32m${writtenFiles.length} badges generated\x1B[0m
12663
+ `);
12664
+ console.log(`\x1B[90mUsage in README.md:\x1B[0m`);
12665
+ console.log(` ![VibeCheck Status](${path13.basename(absoluteOutPath)}/vibecheck-status.svg)`);
12666
+ console.log(` ![VibeCheck Score](${path13.basename(absoluteOutPath)}/vibecheck-score.svg)
12667
+ `);
12668
+ return 0;
12669
+ }
12670
+ function registerBadgeCommand(program2) {
12671
+ program2.command("badge").description("Generate static SVG badges from scan artifact").requiredOption("-a, --artifact <file>", "Input artifact JSON file").option("-o, --out <dir>", "Output directory for badges", "./badges").option("--style <style>", "Badge style: flat, flat-square", "flat").addHelpText(
12672
+ "after",
12673
+ `
12674
+ Examples:
12675
+ $ vibecheck badge --artifact scan.json Generate badges in ./badges
12676
+ $ vibecheck badge -a scan.json -o ./docs/badges Custom output directory
12677
+ $ vibecheck badge -a scan.json --style flat-square Flat-square style
12678
+
12679
+ Generated badges:
12680
+ vibecheck-status.svg - PASS/FAIL status based on critical/high findings
12681
+ vibecheck-findings.svg - Total findings count
12682
+ vibecheck-severity.svg - Severity breakdown (e.g., "2C 5H 10M")
12683
+ vibecheck-score.svg - Security score (0-100)
12684
+ vibecheck-coverage.svg - Auth coverage percentage (if available)
12685
+
12686
+ Usage in README:
12687
+ ![VibeCheck Status](./badges/vibecheck-status.svg)
12688
+ ![Security Score](./badges/vibecheck-score.svg)
12689
+ `
12690
+ ).action(async (cmdOptions) => {
12691
+ const options = {
12692
+ artifact: cmdOptions.artifact,
12693
+ out: cmdOptions.out,
12694
+ style: cmdOptions.style ?? "flat"
12695
+ };
12696
+ const exitCode = await executeBadge(options);
12697
+ process.exit(exitCode);
12698
+ });
12699
+ }
12700
+
9005
12701
  // src/index.ts
9006
12702
  var program = new Command();
9007
- program.name("vibecheck").description("Security scanner for modern web applications").version("0.0.1");
12703
+ program.name("vibecheck").description("Security scanner for modern web applications").version(CLI_VERSION);
9008
12704
  registerScanCommand(program);
9009
12705
  registerExplainCommand(program);
9010
12706
  registerDemoArtifactCommand(program);
@@ -9013,4 +12709,9 @@ registerEvaluateCommand(program);
9013
12709
  registerWaiversCommand(program);
9014
12710
  registerViewCommand(program);
9015
12711
  registerLicenseCommand(program);
12712
+ registerVerifyDeterminismCommand(program);
12713
+ registerBadgeCommand(program);
9016
12714
  program.parse();
12715
+ export {
12716
+ CLI_VERSION
12717
+ };