guardvibe 3.16.0 → 3.18.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/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to GuardVibe are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.18.0] - 2026-06-09
9
+
10
+ ### Added — FAZ 3 part c: AST BOLA mutation-guard detection for VG951 (442 rules / 37 tools)
11
+ - **VG951 (BOLA — delete/update without ownership) is now AST-aware for the `find → compare → mutate` pattern.** The rule's regex already excludes an ownership field inside the mutation's WHERE clause; its only blind spot was a bare-id mutation preceded by a separate ownership check. `bolaMutationGuarded` (AST) suppresses VG951 when the enclosing function performs a **post-fetch ownership comparison** of the fetched resource against the session (e.g. `const s = await prisma.schedule.findUnique({ where: { id } }); if (s?.userId !== user.id) throw; await prisma.schedule.delete({ where: { id } })`). Anything without that comparison keeps firing.
12
+ - Reuses the part-1/2 AST engine — factored a shared `callNearLine` anchor finder and `hasPostFetchOwnershipGuard` (the case-2 ownership-comparison check) out of `bolaOwnershipGuarded`, then anchored it on the mutation call instead of the find call.
13
+ - **Validated (clean stash diff): VG951 11 → 9; both removed are genuinely ownership-guarded** — cal `delete.handler` (`findUnique select userId → if (scheduleToDelete?.userId !== user.id) throw UNAUTHORIZED → delete`) and cal `ScheduleService.update` (`findUnique → if (userSchedule?.userId !== user.id) require team edit-permission else throw → update`). **0 true positives lost, 0 false positives added, 0 other-rule drift.** 5 new mutation-guard tests (10 BOLA tests total).
14
+ - No rule or tool changes (442 / 37). FAZ 3 part c.
15
+
16
+ Gate green (build / lint / test / self-audit PASS / A / 0).
17
+
18
+ ## [3.17.0] - 2026-06-09
19
+
20
+ ### Added — FAZ 3 part 3: AST constant-origin detection for VG126 (442 rules / 37 tools)
21
+ - **VG126 ("Dynamic RegExp from User Input") no longer fires when the argument is provably constant.** `regexpArgIsConstant` (AST) suppresses `new RegExp(x)` when `x` is: a string literal, a variable assigned from a string literal, the callback parameter of an iteration over a const string-array (incl. an imported SCREAMING_SNAKE_CASE list, by convention), or `someRegExp.source`/`.flags` (cloning a compiled RegExp). Minified bundles are skipped too. Anything not provably constant keeps firing.
22
+ - **Validated (clean stash diff): VG126 29 → 21; all 8 removed are confirmed non-user-input** — dub `detect-bot` ×2 (`UA_BOTS`/`REFERRER_BOTS` bot-pattern lists), a minified vendor bundle, and payload `deepCopyObject` ×5 (`new RegExp(cur.source, cur.flags)` RegExp clones). **0 true positives lost, 0 false positives added.** 10 RegExp tests.
23
+ - No rule or tool changes (442 / 37). FAZ 3 part 3 (reuses the part-1 AST engine).
24
+
25
+ Gate green (build / lint / test / self-audit PASS / A / 0).
26
+
8
27
  ## [3.16.0] - 2026-06-08
9
28
 
10
29
  ### Added — FAZ 3 part 2: AST BOLA ownership-guard detection for VG950 (442 rules / 37 tools)
@@ -16,3 +16,22 @@ export declare function paramReachesSink(code: string, filePath?: string): boole
16
16
  * firing — for a BOLA rule we prefer a false positive over hiding a real one.
17
17
  */
18
18
  export declare function bolaOwnershipGuarded(code: string, filePath: string | undefined, line: number): boolean;
19
+ /**
20
+ * BOLA ownership-guard detection for VG951 (delete/update). The rule's regex
21
+ * already suppresses an ownership field inside the mutation's WHERE clause (via a
22
+ * negative lookahead), so the only blind spot is the find → compare → mutate
23
+ * pattern: the mutation's where-clause is a bare id, but the enclosing function
24
+ * fetched the resource and compared its ownership field against the session first.
25
+ * Returns true (→ suppress) only when that post-fetch comparison is present;
26
+ * false on uncertainty so a genuinely unguarded mutation keeps firing.
27
+ */
28
+ export declare function bolaMutationGuarded(code: string, filePath: string | undefined, line: number): boolean;
29
+ /**
30
+ * True when the argument to a `new RegExp(...)` at `line` is PROVABLY a constant
31
+ * (a string literal, a variable assigned from a string literal, or the callback
32
+ * parameter of an iteration over a const string-array — the "bot list" pattern),
33
+ * so VG126 ("Dynamic RegExp from user input") is a false positive there. Returns
34
+ * false on any uncertainty so the rule keeps firing — a regex built from anything
35
+ * not provably constant stays flagged.
36
+ */
37
+ export declare function regexpArgIsConstant(code: string, filePath: string | undefined, line: number): boolean;
@@ -137,6 +137,10 @@ export function paramReachesSink(code, filePath) {
137
137
  return sinkArgs.some(args => refsTaint(args, tainted));
138
138
  }
139
139
  const FIND_METHODS = new Set(["findUnique", "findFirst", "findById", "findOne", "getOne"]);
140
+ // Mutation sinks for VG951 (delete/update BOLA) — the last identifier of the callee.
141
+ const MUTATION_METHODS = new Set([
142
+ "delete", "update", "destroy", "remove", "deleteMany", "updateMany", "deleteOne", "updateOne",
143
+ ]);
140
144
  const OWNERSHIP_FIELDS = new Set([
141
145
  "userId", "user_id", "ownerId", "owner_id", "authorId", "author_id", "createdById", "createdBy", "created_by",
142
146
  "accountId", "account_id", "tenantId", "tenant_id", "orgId", "org_id", "organizationId",
@@ -146,6 +150,44 @@ const OWNERSHIP_FIELDS = new Set([
146
150
  // A comparison of a fetched resource's ownership field, on a line that also references a session/user.
147
151
  const OWNERSHIP_COMPARE = /\.\s*(?:userId|ownerId|authorId|createdById|teamId|workspaceId|orgId|organizationId|tenantId|memberId|accountId|projectId)\b\s*(?:===|!==|==|!=)/i;
148
152
  const SESSION_REF = /\b(?:session|ctx|auth|currentUser|viewer|member|account|workspace|team|org|self|me|user)\b/i;
153
+ /** The first CallExpression near `line` whose last-identifier method is in `methods`. */
154
+ function callNearLine(ts, sf, line, methods) {
155
+ let target;
156
+ const visit = (node) => {
157
+ if (!target && ts.isCallExpression(node)) {
158
+ const callee = node.expression;
159
+ const method = ts.isPropertyAccessExpression(callee) ? callee.name.text
160
+ : ts.isIdentifier(callee) ? callee.text : undefined;
161
+ if (method && methods.has(method)) {
162
+ const startLine = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
163
+ if (Math.abs(startLine - line) <= 1)
164
+ target = node;
165
+ }
166
+ }
167
+ if (!target)
168
+ ts.forEachChild(node, visit);
169
+ };
170
+ visit(sf);
171
+ return target;
172
+ }
173
+ /**
174
+ * True when the function enclosing `node` performs a post-fetch ownership
175
+ * comparison — an ownership field (`fetched.userId`) compared against a
176
+ * session/user value on the same line (`!== ctx.user.id`). Same-function only
177
+ * (no inter-procedural tracing), matching the line/regex engine's precision.
178
+ */
179
+ function hasPostFetchOwnershipGuard(ts, sf, node) {
180
+ let fn = node;
181
+ while (fn && !(ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isArrowFunction(fn) || ts.isMethodDeclaration(fn))) {
182
+ fn = fn.parent;
183
+ }
184
+ const body = fn ? fn.getText(sf) : sf.getText();
185
+ for (const ln of body.split("\n")) {
186
+ if (OWNERSHIP_COMPARE.test(ln) && SESSION_REF.test(ln))
187
+ return true;
188
+ }
189
+ return false;
190
+ }
149
191
  /**
150
192
  * BOLA ownership-guard detection for VG950 (find-by-user-id). Returns true (the
151
193
  * query is ownership-guarded → suppress the finding) when EITHER:
@@ -167,22 +209,7 @@ export function bolaOwnershipGuarded(code, filePath, line) {
167
209
  catch {
168
210
  return false;
169
211
  }
170
- let target;
171
- const visit = (node) => {
172
- if (!target && ts.isCallExpression(node)) {
173
- const callee = node.expression;
174
- const method = ts.isPropertyAccessExpression(callee) ? callee.name.text
175
- : ts.isIdentifier(callee) ? callee.text : undefined;
176
- if (method && FIND_METHODS.has(method)) {
177
- const startLine = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
178
- if (Math.abs(startLine - line) <= 1)
179
- target = node;
180
- }
181
- }
182
- if (!target)
183
- ts.forEachChild(node, visit);
184
- };
185
- visit(sf);
212
+ const target = callNearLine(ts, sf, line, FIND_METHODS);
186
213
  if (!target)
187
214
  return false;
188
215
  // (1) ownership field in the WHERE clause with a non-param value.
@@ -201,14 +228,120 @@ export function bolaOwnershipGuarded(code, filePath, line) {
201
228
  }
202
229
  }
203
230
  // (2) post-fetch ownership comparison against a session/user value, in the same function.
204
- let fn = target;
205
- while (fn && !(ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isArrowFunction(fn) || ts.isMethodDeclaration(fn))) {
231
+ return hasPostFetchOwnershipGuard(ts, sf, target);
232
+ }
233
+ /**
234
+ * BOLA ownership-guard detection for VG951 (delete/update). The rule's regex
235
+ * already suppresses an ownership field inside the mutation's WHERE clause (via a
236
+ * negative lookahead), so the only blind spot is the find → compare → mutate
237
+ * pattern: the mutation's where-clause is a bare id, but the enclosing function
238
+ * fetched the resource and compared its ownership field against the session first.
239
+ * Returns true (→ suppress) only when that post-fetch comparison is present;
240
+ * false on uncertainty so a genuinely unguarded mutation keeps firing.
241
+ */
242
+ export function bolaMutationGuarded(code, filePath, line) {
243
+ const ts = getTs();
244
+ if (!ts)
245
+ return false;
246
+ let sf;
247
+ try {
248
+ sf = ts.createSourceFile(filePath ?? "file.ts", code, ts.ScriptTarget.Latest, true, scriptKindFor(ts, filePath));
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ const target = callNearLine(ts, sf, line, MUTATION_METHODS);
254
+ if (!target)
255
+ return false;
256
+ return hasPostFetchOwnershipGuard(ts, sf, target);
257
+ }
258
+ const ITER_METHODS = new Set(["map", "forEach", "some", "every", "filter", "find", "findIndex", "reduce", "flatMap"]);
259
+ /** First `const NAME = <initializer>` for NAME anywhere in the file (file-scope-ish). */
260
+ function findVarInit(ts, sf, name) {
261
+ let found;
262
+ const visit = (node) => {
263
+ if (!found && ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) {
264
+ found = node.initializer;
265
+ }
266
+ if (!found)
267
+ ts.forEachChild(node, visit);
268
+ };
269
+ visit(sf);
270
+ return found;
271
+ }
272
+ /** A non-empty array literal whose elements are all string/template literals (a const pattern list). */
273
+ function isConstStringArray(ts, sf, node) {
274
+ // SCREAMING_SNAKE_CASE identifier = constant by convention (often an imported list).
275
+ if (ts.isIdentifier(node) && /^[A-Z][A-Z0-9_]+$/.test(node.text))
276
+ return true;
277
+ let arr = node;
278
+ if (ts.isIdentifier(node))
279
+ arr = findVarInit(ts, sf, node.text);
280
+ if (arr && ts.isArrayLiteralExpression(arr)) {
281
+ return arr.elements.length > 0 &&
282
+ arr.elements.every(el => ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el));
283
+ }
284
+ return false;
285
+ }
286
+ /**
287
+ * True when the argument to a `new RegExp(...)` at `line` is PROVABLY a constant
288
+ * (a string literal, a variable assigned from a string literal, or the callback
289
+ * parameter of an iteration over a const string-array — the "bot list" pattern),
290
+ * so VG126 ("Dynamic RegExp from user input") is a false positive there. Returns
291
+ * false on any uncertainty so the rule keeps firing — a regex built from anything
292
+ * not provably constant stays flagged.
293
+ */
294
+ export function regexpArgIsConstant(code, filePath, line) {
295
+ const ts = getTs();
296
+ if (!ts)
297
+ return false;
298
+ let sf;
299
+ try {
300
+ sf = ts.createSourceFile(filePath ?? "file.ts", code, ts.ScriptTarget.Latest, true, scriptKindFor(ts, filePath));
301
+ }
302
+ catch {
303
+ return false;
304
+ }
305
+ let target;
306
+ const visit = (node) => {
307
+ if (!target && ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "RegExp"
308
+ && node.arguments && node.arguments.length > 0) {
309
+ const startLine = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
310
+ if (Math.abs(startLine - line) <= 1)
311
+ target = node;
312
+ }
313
+ if (!target)
314
+ ts.forEachChild(node, visit);
315
+ };
316
+ visit(sf);
317
+ if (!target || !target.arguments)
318
+ return false;
319
+ const arg = target.arguments[0];
320
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))
321
+ return true;
322
+ // `new RegExp(x.source, x.flags)` — cloning an existing compiled RegExp, not user input.
323
+ if (ts.isPropertyAccessExpression(arg) && (arg.name.text === "source" || arg.name.text === "flags"))
324
+ return true;
325
+ if (!ts.isIdentifier(arg))
326
+ return false;
327
+ const argName = arg.text;
328
+ // (a) const argName = "literal"
329
+ const init = findVarInit(ts, sf, argName);
330
+ if (init && (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)))
331
+ return true;
332
+ // (b) argName is the callback parameter of an iteration over a const string array.
333
+ let fn = target.parent;
334
+ while (fn && !((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))
335
+ && fn.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === argName))) {
206
336
  fn = fn.parent;
207
337
  }
208
- const body = fn ? fn.getText(sf) : code;
209
- for (const ln of body.split("\n")) {
210
- if (OWNERSHIP_COMPARE.test(ln) && SESSION_REF.test(ln))
338
+ if (fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) {
339
+ const call = fn.parent;
340
+ if (call && ts.isCallExpression(call) && ts.isPropertyAccessExpression(call.expression)
341
+ && ITER_METHODS.has(call.expression.name.text)
342
+ && isConstStringArray(ts, sf, call.expression.expression)) {
211
343
  return true;
344
+ }
212
345
  }
213
346
  return false;
214
347
  }
@@ -4,7 +4,7 @@ import { loadConfig } from "../utils/config.js";
4
4
  import { loadIgnoreFile, isIgnored } from "../utils/ignore.js";
5
5
  import { securityBanner } from "../utils/banner.js";
6
6
  import { looksMinified } from "../utils/constants.js";
7
- import { paramReachesSink, bolaOwnershipGuarded } from "./ast-engine.js";
7
+ import { paramReachesSink, bolaOwnershipGuarded, bolaMutationGuarded, regexpArgIsConstant } from "./ast-engine.js";
8
8
  /** CVE version-pin rule IDs are VG900-VG931 (and only these). Other VG9xx IDs
9
9
  * (VG983 Turso, VG990 SVG, VG998 OpenAI browser flag, etc.) are regular code-pattern
10
10
  * rules and should NOT be exempted from comment / string-literal skip logic. */
@@ -886,6 +886,18 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
886
886
  // `userId` that only appears in `select`, and sees a separate comparison statement.
887
887
  if (rule.id === "VG950" && filePath && bolaOwnershipGuarded(code, filePath, lineNumber))
888
888
  continue;
889
+ // VG951 (BOLA delete/update): the regex already suppresses an ownership field
890
+ // inside the mutation's WHERE clause, so its only blind spot is the
891
+ // find → compare → mutate pattern — a bare-id mutation preceded by a post-fetch
892
+ // ownership comparison against the session. Suppress only when AST proves it.
893
+ if (rule.id === "VG951" && filePath && bolaMutationGuarded(code, filePath, lineNumber))
894
+ continue;
895
+ // VG126 (Dynamic RegExp from User Input): the regex matches `new RegExp(anyVar)`,
896
+ // but the var is often a provable constant — a string literal or the callback
897
+ // param iterating a const string array (the classic bot-UA / referrer-list pattern).
898
+ // Suppress only when AST proves the argument is constant; anything else keeps firing.
899
+ if (rule.id === "VG126" && (looksMinified(code) || (filePath && regexpArgIsConstant(code, filePath, lineNumber))))
900
+ continue;
889
901
  // VG202: skip when the FROM target matches a previous AS-alias in the same file.
890
902
  if (dockerStageAliases) {
891
903
  const target = match[0].replace(/^FROM\s+/i, "").split(/[:@\s]/)[0].toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.16.0",
3
+ "version": "3.18.0",
4
4
  "mcpName": "io.github.goklab/guardvibe",
5
5
  "description": "Security infrastructure your AI can't be — deterministic, current past your model's training cutoff, whole-repo-aware, author-independent. Security MCP for vibe coding. 442 rules, 37 tools, CLI + doctor. Host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. 71 CVE rules refreshed daily from GHSA/OSV/CISA KEV — Vite dev-server RCE, React Router 7 cluster, DOMPurify XSS, Better Auth bypass, Miasma @redhat-cloud-services compromise, Next.js May 2026 13-advisory cluster, Drizzle/MikroORM/Kysely SQL injection, Axios proxy-auth redirect leak, Hono setCookie attribute injection, Clerk SSRF, tRPC prototype pollution, @tanstack supply-chain, node-ipc protestware, OpenClaude sandbox bypass, plus the full AI-generated stack (Supabase, Stripe, Prisma, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK). 68 AI-native rules including OWASP MCP Top 10 tool-description prompt injection (VG1068), model-controlled sandbox-disable flag detection (VG1063), Session messenger exfil endpoint IOC (VG1075), and CI/CD supply-chain hardening (VG1070 npm --expect-provenance / --ignore-scripts enforcement).",
6
6
  "type": "module",