@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/LICENSE +21 -21
- package/README.md +903 -903
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4081 -380
- package/package.json +2 -1
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/
|
|
221
|
+
// ../schema/dist/schemas/supply-chain.js
|
|
175
222
|
import { z as z5 } from "zod";
|
|
176
|
-
var
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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 =
|
|
195
|
-
critical:
|
|
196
|
-
high:
|
|
197
|
-
medium:
|
|
198
|
-
low:
|
|
199
|
-
info:
|
|
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 =
|
|
202
|
-
auth:
|
|
203
|
-
validation:
|
|
204
|
-
middleware:
|
|
205
|
-
secrets:
|
|
206
|
-
injection:
|
|
207
|
-
privacy:
|
|
208
|
-
config:
|
|
209
|
-
network:
|
|
210
|
-
crypto:
|
|
211
|
-
uploads:
|
|
212
|
-
hallucinations:
|
|
213
|
-
abuse:
|
|
214
|
-
|
|
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 =
|
|
217
|
-
totalFindings:
|
|
346
|
+
var SummarySchema = z6.object({
|
|
347
|
+
totalFindings: z6.number().int().nonnegative(),
|
|
218
348
|
bySeverity: SeverityCountsSchema,
|
|
219
349
|
byCategory: CategoryCountsSchema
|
|
220
350
|
});
|
|
221
|
-
var RouteEntrySchema =
|
|
222
|
-
routeId:
|
|
223
|
-
method:
|
|
224
|
-
path:
|
|
225
|
-
handler:
|
|
226
|
-
file:
|
|
227
|
-
startLine:
|
|
228
|
-
endLine:
|
|
229
|
-
handlerSymbol:
|
|
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:
|
|
232
|
-
middleware:
|
|
361
|
+
line: z6.number().int().positive().optional(),
|
|
362
|
+
middleware: z6.array(z6.string()).optional()
|
|
233
363
|
});
|
|
234
|
-
var MiddlewareCoverageEntrySchema =
|
|
235
|
-
routeId:
|
|
236
|
-
covered:
|
|
237
|
-
reason:
|
|
364
|
+
var MiddlewareCoverageEntrySchema = z6.object({
|
|
365
|
+
routeId: z6.string(),
|
|
366
|
+
covered: z6.boolean(),
|
|
367
|
+
reason: z6.string().optional()
|
|
238
368
|
});
|
|
239
|
-
var MiddlewareEntrySchema =
|
|
240
|
-
name:
|
|
241
|
-
file:
|
|
242
|
-
line:
|
|
243
|
-
matcher:
|
|
244
|
-
appliesTo:
|
|
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 =
|
|
247
|
-
middlewareFile:
|
|
248
|
-
matcher:
|
|
249
|
-
coverage:
|
|
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 =
|
|
252
|
-
intentId:
|
|
381
|
+
var IntentEntrySchema = z6.object({
|
|
382
|
+
intentId: z6.string(),
|
|
253
383
|
type: ClaimTypeSchema,
|
|
254
384
|
scope: ClaimScopeSchema,
|
|
255
|
-
targetRouteId:
|
|
385
|
+
targetRouteId: z6.string().optional(),
|
|
256
386
|
source: ClaimSourceSchema,
|
|
257
|
-
location:
|
|
258
|
-
file:
|
|
259
|
-
startLine:
|
|
260
|
-
endLine:
|
|
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:
|
|
393
|
+
textEvidence: z6.string()
|
|
264
394
|
});
|
|
265
|
-
var IntentMapSchema =
|
|
266
|
-
intents:
|
|
395
|
+
var IntentMapSchema = z6.object({
|
|
396
|
+
intents: z6.array(IntentEntrySchema)
|
|
267
397
|
});
|
|
268
|
-
var RouteMapSchema =
|
|
269
|
-
routes:
|
|
398
|
+
var RouteMapSchema = z6.object({
|
|
399
|
+
routes: z6.array(RouteEntrySchema)
|
|
270
400
|
});
|
|
271
|
-
var CoverageMetricsSchema =
|
|
272
|
-
authCoverage:
|
|
273
|
-
totalStateChanging:
|
|
274
|
-
protectedCount:
|
|
275
|
-
unprotectedCount:
|
|
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:
|
|
278
|
-
totalStateChanging:
|
|
279
|
-
validatedCount:
|
|
407
|
+
validationCoverage: z6.object({
|
|
408
|
+
totalStateChanging: z6.number().int().nonnegative(),
|
|
409
|
+
validatedCount: z6.number().int().nonnegative()
|
|
280
410
|
}).optional(),
|
|
281
|
-
middlewareCoverage:
|
|
282
|
-
totalApiRoutes:
|
|
283
|
-
coveredApiRoutes:
|
|
411
|
+
middlewareCoverage: z6.object({
|
|
412
|
+
totalApiRoutes: z6.number().int().nonnegative(),
|
|
413
|
+
coveredApiRoutes: z6.number().int().nonnegative()
|
|
284
414
|
}).optional()
|
|
285
415
|
});
|
|
286
|
-
var MetricsSchema =
|
|
287
|
-
filesScanned:
|
|
288
|
-
linesOfCode:
|
|
289
|
-
scanDurationMs:
|
|
290
|
-
rulesExecuted:
|
|
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
|
|
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:
|
|
462
|
+
generatedAt: z6.string().datetime(),
|
|
295
463
|
tool: ToolInfoSchema,
|
|
296
464
|
repo: RepoInfoSchema.optional(),
|
|
297
465
|
summary: SummarySchema,
|
|
298
|
-
findings:
|
|
466
|
+
findings: z6.array(FindingSchema),
|
|
299
467
|
// Phase 3: Enhanced maps
|
|
300
|
-
routeMap:
|
|
301
|
-
|
|
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:
|
|
307
|
-
|
|
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:
|
|
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/
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
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
|
|
3942
|
-
if (
|
|
3943
|
-
|
|
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
|
|
4188
|
+
return normalized;
|
|
3950
4189
|
}
|
|
3951
|
-
function
|
|
3952
|
-
|
|
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
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
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
|
|
3965
|
-
const
|
|
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
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
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
|
-
|
|
3975
|
-
|
|
4239
|
+
symbol: handler.method,
|
|
4240
|
+
route: routePath
|
|
3976
4241
|
});
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
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
|
-
|
|
3982
|
-
|
|
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
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
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
|
|
4275
|
+
}
|
|
4276
|
+
return findings;
|
|
4024
4277
|
}
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
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
|
|
4345
|
+
return normalized;
|
|
4040
4346
|
}
|
|
4041
|
-
function
|
|
4042
|
-
const
|
|
4043
|
-
|
|
4044
|
-
|
|
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
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
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
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
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
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
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
|
-
|
|
4088
|
-
const
|
|
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
|
|
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:
|
|
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 = `${
|
|
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
|
|
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:
|
|
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 = `${
|
|
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
|
|
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:
|
|
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:
|
|
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(`${
|
|
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 = `${
|
|
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
|
|
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:
|
|
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 = `${
|
|
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
|
|
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:
|
|
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
|
|
6651
|
-
var WaiverMatchSchema =
|
|
9518
|
+
import { z as z7 } from "zod";
|
|
9519
|
+
var WaiverMatchSchema = z7.object({
|
|
6652
9520
|
/** Exact fingerprint match */
|
|
6653
|
-
fingerprint:
|
|
9521
|
+
fingerprint: z7.string().optional(),
|
|
6654
9522
|
/** Rule ID (exact or prefix like "VC-AUTH-*") */
|
|
6655
|
-
ruleId:
|
|
9523
|
+
ruleId: z7.string().optional(),
|
|
6656
9524
|
/** Path glob pattern for evidence file matching */
|
|
6657
|
-
pathPattern:
|
|
9525
|
+
pathPattern: z7.string().optional()
|
|
6658
9526
|
});
|
|
6659
|
-
var WaiverSchema =
|
|
9527
|
+
var WaiverSchema = z7.object({
|
|
6660
9528
|
/** Unique waiver ID */
|
|
6661
|
-
id:
|
|
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:
|
|
9533
|
+
reason: z7.string().min(1),
|
|
6666
9534
|
/** Who created this waiver */
|
|
6667
|
-
createdBy:
|
|
9535
|
+
createdBy: z7.string().min(1),
|
|
6668
9536
|
/** When the waiver was created */
|
|
6669
|
-
createdAt:
|
|
9537
|
+
createdAt: z7.string().datetime(),
|
|
6670
9538
|
/** Optional expiration date */
|
|
6671
|
-
expiresAt:
|
|
9539
|
+
expiresAt: z7.string().datetime().optional(),
|
|
6672
9540
|
/** Optional ticket/issue reference */
|
|
6673
|
-
ticketRef:
|
|
9541
|
+
ticketRef: z7.string().optional()
|
|
6674
9542
|
});
|
|
6675
|
-
var WaiversFileSchema =
|
|
9543
|
+
var WaiversFileSchema = z7.object({
|
|
6676
9544
|
/** Schema version */
|
|
6677
|
-
version:
|
|
9545
|
+
version: z7.literal("0.1"),
|
|
6678
9546
|
/** List of waivers */
|
|
6679
|
-
waivers:
|
|
9547
|
+
waivers: z7.array(WaiverSchema)
|
|
6680
9548
|
});
|
|
6681
9549
|
|
|
6682
9550
|
// ../policy/dist/schemas/policy-config.js
|
|
6683
|
-
import { z as
|
|
6684
|
-
var ProfileNameSchema =
|
|
6685
|
-
var ThresholdsSchema =
|
|
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:
|
|
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:
|
|
9561
|
+
minConfidenceForWarn: z8.number().min(0).max(1).default(0.5),
|
|
6694
9562
|
/** Special lower confidence threshold for critical findings */
|
|
6695
|
-
minConfidenceCritical:
|
|
9563
|
+
minConfidenceCritical: z8.number().min(0).max(1).default(0.5),
|
|
6696
9564
|
/** Maximum number of findings before auto-fail (0 = unlimited) */
|
|
6697
|
-
maxFindings:
|
|
9565
|
+
maxFindings: z8.number().int().min(0).default(0),
|
|
6698
9566
|
/** Maximum number of critical findings before auto-fail (0 = unlimited) */
|
|
6699
|
-
maxCritical:
|
|
9567
|
+
maxCritical: z8.number().int().min(0).default(0),
|
|
6700
9568
|
/** Maximum number of high findings before auto-fail (0 = unlimited) */
|
|
6701
|
-
maxHigh:
|
|
9569
|
+
maxHigh: z8.number().int().min(0).default(0)
|
|
6702
9570
|
});
|
|
6703
|
-
var OverrideActionSchema =
|
|
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 =
|
|
9583
|
+
var OverrideSchema = z8.object({
|
|
6716
9584
|
/** Rule ID pattern (exact or prefix like "VC-AUTH-*") */
|
|
6717
|
-
ruleId:
|
|
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:
|
|
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:
|
|
9595
|
+
comment: z8.string().optional()
|
|
6728
9596
|
});
|
|
6729
|
-
var RegressionPolicySchema =
|
|
9597
|
+
var RegressionPolicySchema = z8.object({
|
|
6730
9598
|
/** Fail on any new high/critical findings */
|
|
6731
|
-
failOnNewHighCritical:
|
|
9599
|
+
failOnNewHighCritical: z8.boolean().default(true),
|
|
6732
9600
|
/** Fail on any severity regression (e.g., medium became high) */
|
|
6733
|
-
failOnSeverityRegression:
|
|
9601
|
+
failOnSeverityRegression: z8.boolean().default(false),
|
|
6734
9602
|
/** Fail on net increase in findings */
|
|
6735
|
-
failOnNetIncrease:
|
|
9603
|
+
failOnNetIncrease: z8.boolean().default(false),
|
|
6736
9604
|
/** Warn on any new findings */
|
|
6737
|
-
warnOnNewFindings:
|
|
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 =
|
|
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:
|
|
9619
|
+
overrides: z8.array(OverrideSchema).default([]),
|
|
6746
9620
|
/** Regression policy */
|
|
6747
9621
|
regression: RegressionPolicySchema.default({})
|
|
6748
9622
|
});
|
|
6749
|
-
var ConfigFileSchema =
|
|
9623
|
+
var ConfigFileSchema = z8.object({
|
|
6750
9624
|
/** Policy configuration */
|
|
6751
9625
|
policy: PolicyConfigSchema.optional(),
|
|
6752
9626
|
/** Path to waivers file */
|
|
6753
|
-
waiversPath:
|
|
9627
|
+
waiversPath: z8.string().optional()
|
|
6754
9628
|
});
|
|
6755
9629
|
|
|
6756
9630
|
// ../policy/dist/schemas/policy-report.js
|
|
6757
|
-
import { z as
|
|
9631
|
+
import { z as z9 } from "zod";
|
|
6758
9632
|
var POLICY_REPORT_VERSION = "0.1";
|
|
6759
|
-
var PolicyStatusSchema =
|
|
6760
|
-
var PolicyReasonSchema =
|
|
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:
|
|
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:
|
|
9654
|
+
message: z9.string(),
|
|
6776
9655
|
/** Optional finding IDs that triggered this */
|
|
6777
|
-
findingIds:
|
|
9656
|
+
findingIds: z9.array(z9.string()).optional(),
|
|
6778
9657
|
/** Optional details */
|
|
6779
|
-
details:
|
|
9658
|
+
details: z9.record(z9.unknown()).optional()
|
|
6780
9659
|
});
|
|
6781
|
-
var PolicySummaryCountsSchema =
|
|
9660
|
+
var PolicySummaryCountsSchema = z9.object({
|
|
6782
9661
|
/** Total findings after filtering */
|
|
6783
|
-
total:
|
|
9662
|
+
total: z9.number().int(),
|
|
6784
9663
|
/** By severity */
|
|
6785
|
-
bySeverity:
|
|
6786
|
-
critical:
|
|
6787
|
-
high:
|
|
6788
|
-
medium:
|
|
6789
|
-
low:
|
|
6790
|
-
info:
|
|
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:
|
|
9672
|
+
byCategory: z9.record(CategorySchema, z9.number().int()),
|
|
6794
9673
|
/** Count of waived findings */
|
|
6795
|
-
waived:
|
|
9674
|
+
waived: z9.number().int(),
|
|
6796
9675
|
/** Count of ignored by override */
|
|
6797
|
-
ignored:
|
|
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 =
|
|
9711
|
+
var RegressionSummarySchema = z9.object({
|
|
6800
9712
|
/** Baseline artifact path or identifier */
|
|
6801
|
-
baselineId:
|
|
9713
|
+
baselineId: z9.string(),
|
|
6802
9714
|
/** When baseline was generated */
|
|
6803
|
-
baselineGeneratedAt:
|
|
9715
|
+
baselineGeneratedAt: z9.string(),
|
|
6804
9716
|
/** New findings (not in baseline) */
|
|
6805
|
-
newFindings:
|
|
6806
|
-
findingId:
|
|
6807
|
-
fingerprint:
|
|
9717
|
+
newFindings: z9.array(z9.object({
|
|
9718
|
+
findingId: z9.string(),
|
|
9719
|
+
fingerprint: z9.string(),
|
|
6808
9720
|
severity: SeveritySchema,
|
|
6809
|
-
ruleId:
|
|
6810
|
-
title:
|
|
9721
|
+
ruleId: z9.string(),
|
|
9722
|
+
title: z9.string()
|
|
6811
9723
|
})),
|
|
6812
9724
|
/** Resolved findings (in baseline but not current) */
|
|
6813
|
-
resolvedFindings:
|
|
6814
|
-
fingerprint:
|
|
9725
|
+
resolvedFindings: z9.array(z9.object({
|
|
9726
|
+
fingerprint: z9.string(),
|
|
6815
9727
|
severity: SeveritySchema,
|
|
6816
|
-
ruleId:
|
|
6817
|
-
title:
|
|
9728
|
+
ruleId: z9.string(),
|
|
9729
|
+
title: z9.string()
|
|
6818
9730
|
})),
|
|
6819
9731
|
/** Persisting findings (in both) */
|
|
6820
|
-
persistingCount:
|
|
9732
|
+
persistingCount: z9.number().int(),
|
|
6821
9733
|
/** Severity regressions (same fingerprint but higher severity) */
|
|
6822
|
-
severityRegressions:
|
|
6823
|
-
fingerprint:
|
|
6824
|
-
ruleId:
|
|
9734
|
+
severityRegressions: z9.array(z9.object({
|
|
9735
|
+
fingerprint: z9.string(),
|
|
9736
|
+
ruleId: z9.string(),
|
|
6825
9737
|
previousSeverity: SeveritySchema,
|
|
6826
9738
|
currentSeverity: SeveritySchema,
|
|
6827
|
-
title:
|
|
9739
|
+
title: z9.string()
|
|
6828
9740
|
})),
|
|
6829
9741
|
/** Net change in finding count */
|
|
6830
|
-
netChange:
|
|
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 =
|
|
9748
|
+
var WaivedFindingSchema = z9.object({
|
|
6833
9749
|
/** The finding that was waived */
|
|
6834
|
-
finding:
|
|
6835
|
-
id:
|
|
6836
|
-
fingerprint:
|
|
6837
|
-
ruleId:
|
|
9750
|
+
finding: z9.object({
|
|
9751
|
+
id: z9.string(),
|
|
9752
|
+
fingerprint: z9.string(),
|
|
9753
|
+
ruleId: z9.string(),
|
|
6838
9754
|
severity: SeveritySchema,
|
|
6839
|
-
title:
|
|
9755
|
+
title: z9.string()
|
|
6840
9756
|
}),
|
|
6841
9757
|
/** The waiver that matched */
|
|
6842
9758
|
waiver: WaiverSchema,
|
|
6843
9759
|
/** Whether waiver is expired */
|
|
6844
|
-
expired:
|
|
9760
|
+
expired: z9.boolean()
|
|
6845
9761
|
});
|
|
6846
|
-
var PolicyReportSchema =
|
|
9762
|
+
var PolicyReportSchema = z9.object({
|
|
6847
9763
|
/** Report schema version */
|
|
6848
|
-
policyVersion:
|
|
9764
|
+
policyVersion: z9.literal(POLICY_REPORT_VERSION),
|
|
6849
9765
|
/** When evaluation was performed */
|
|
6850
|
-
evaluatedAt:
|
|
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:
|
|
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:
|
|
9780
|
+
reasons: z9.array(PolicyReasonSchema),
|
|
6865
9781
|
/** Regression summary (if baseline provided) */
|
|
6866
9782
|
regression: RegressionSummarySchema.optional(),
|
|
6867
9783
|
/** Waived findings */
|
|
6868
|
-
waivedFindings:
|
|
9784
|
+
waivedFindings: z9.array(WaivedFindingSchema),
|
|
6869
9785
|
/** Active (non-waived, non-ignored) findings included in evaluation */
|
|
6870
|
-
activeFindings:
|
|
6871
|
-
id:
|
|
6872
|
-
fingerprint:
|
|
6873
|
-
ruleId:
|
|
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:
|
|
6877
|
-
title:
|
|
9792
|
+
confidence: z9.number(),
|
|
9793
|
+
title: z9.string(),
|
|
6878
9794
|
category: CategorySchema,
|
|
6879
|
-
evidencePaths:
|
|
9795
|
+
evidencePaths: z9.array(z9.string())
|
|
6880
9796
|
})),
|
|
6881
9797
|
/** Recommended exit code (0 = pass/warn, 1 = fail) */
|
|
6882
|
-
exitCode:
|
|
9798
|
+
exitCode: z9.union([z9.literal(0), z9.literal(1)]),
|
|
6883
9799
|
/** Source artifact info */
|
|
6884
|
-
artifact:
|
|
6885
|
-
path:
|
|
6886
|
-
generatedAt:
|
|
6887
|
-
repoName:
|
|
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((
|
|
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.
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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.svg)`);
|
|
12666
|
+
console.log(` }/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
|
+

|
|
12688
|
+

|
|
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(
|
|
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
|
+
};
|