guardvibe 3.25.0 → 3.26.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,15 @@ 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.26.0] - 2026-06-25
9
+
10
+ ### Improved — AST engine: inter-procedural & nested ownership for BOLA/IDOR (no rule/tool count change: 450 rules / 39 tools)
11
+ - **VG950 (find-by-id BOLA) precision via the AST engine.** The ownership guard now also recognizes two real-world authorization shapes the same-function analysis structurally could not see: (1) an ownership field nested inside a relation filter (`members: { some: { userId } }`, `teams.some.team.members.some.userId`), and (2) an **inter-procedural** check — an authorization helper the function calls *before* the query, passing both a session value and the same id (`isAdminForUser(ctx.user.id, targetId)` → throw, then `findUnique({ where: { id: targetId } })`). The same inter-procedural guard now also applies to VG951 (delete/update BOLA).
12
+ - **Soundness preserved:** only a session/auth-derived ownership value counts — a request-controlled value (`req.body.UserId`) is attacker-chosen and keeps firing. Deterministic (bundled TypeScript parser, no resolution of the scanned project's copy).
13
+ - Corpus delta: 3 confirmed false positives removed, zero true positives lost, zero drift on other rules. 8 new tests.
14
+
15
+ Gate green (build / lint / test / self-audit PASS / A / 0).
16
+
8
17
  ## [3.25.0] - 2026-06-24
9
18
 
10
19
  ### Fixed — QA hardening pass (no rule/tool count change: 450 rules / 39 tools)
@@ -150,6 +150,96 @@ const OWNERSHIP_FIELDS = new Set([
150
150
  // A comparison of a fetched resource's ownership field, on a line that also references a session/user.
151
151
  const OWNERSHIP_COMPARE = /\.\s*(?:userId|ownerId|authorId|createdById|teamId|workspaceId|orgId|organizationId|tenantId|memberId|accountId|projectId)\b\s*(?:===|!==|==|!=)/i;
152
152
  const SESSION_REF = /\b(?:session|ctx|auth|currentUser|viewer|member|account|workspace|team|org|self|me|user)\b/i;
153
+ // A value text that is directly request/route-controlled is attacker-chosen, so an
154
+ // ownership field scoped to it (`UserId: req.body.UserId`, `workspaceId: params.x`)
155
+ // is NOT a real guard — the request can name any owner. Only session/auth-derived
156
+ // values count. (Mirrors the existing top-level `params|searchParams` exclusion,
157
+ // extended to req/request so juice-shop's `req.body.UserId` scoping keeps firing.)
158
+ const REQUEST_CONTROLLED = /\b(?:req|request|params|searchParams)\b/;
159
+ /**
160
+ * Recursively scan a `where` object literal for an ownership field (at any nesting
161
+ * depth, e.g. `members: { some: { userId: ... } }`) whose value is session-derived
162
+ * (not request-controlled). The line/regex engine and the prior top-level-only scan
163
+ * miss ownership nested inside relation filters.
164
+ */
165
+ function whereHasNestedOwnership(ts, sf, obj) {
166
+ for (const prop of obj.properties) {
167
+ const nm = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
168
+ if (ts.isPropertyAssignment(prop)) {
169
+ if (nm && OWNERSHIP_FIELDS.has(nm)) {
170
+ const valText = prop.initializer.getText(sf);
171
+ if (!REQUEST_CONTROLLED.test(valText))
172
+ return true;
173
+ }
174
+ if (ts.isObjectLiteralExpression(prop.initializer) && whereHasNestedOwnership(ts, sf, prop.initializer))
175
+ return true;
176
+ }
177
+ else if (ts.isShorthandPropertyAssignment(prop) && nm && OWNERSHIP_FIELDS.has(nm)) {
178
+ // `where: { userId }` — the bound variable carries the ownership scope.
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+ // An authz/ownership-check helper: an action verb + an authz noun (isAdminForUser,
185
+ // assertOwnership, checkAccess, requirePermission, ensureMemberRole…) or a bare
186
+ // authorize/authorise. Names like formatId/getUserById deliberately do NOT match.
187
+ const AUTHZ_HELPER = /^(?:authoris|authoriz)e|^(?:is|assert|ensure|require|check|verify|can|has|validate|guard|protect|enforce)[A-Za-z]*(?:owner|admin|member|access|permission|auth|allowed|belongs|role)/i;
188
+ /** The text of the `where.id` value (or the call's first-arg id) the call is keyed by. */
189
+ function findKeyedIdText(ts, sf, call) {
190
+ const arg0 = call.arguments[0];
191
+ if (arg0 && ts.isObjectLiteralExpression(arg0)) {
192
+ const whereProp = arg0.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "where");
193
+ const whereObj = whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)
194
+ ? whereProp.initializer : arg0;
195
+ const idProp = whereObj.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "id");
196
+ if (idProp && ts.isPropertyAssignment(idProp))
197
+ return idProp.initializer.getText(sf);
198
+ }
199
+ return undefined;
200
+ }
201
+ /**
202
+ * Inter-procedural ownership guard: the enclosing function calls an authz-named
203
+ * helper BEFORE the find/mutation, passing both a session value and the same id the
204
+ * query is keyed by (`isAdminForUser(ctx.user.id, input.forUserId)` → throw, then
205
+ * `findUnique({ where: { id: input.forUserId } })`). This is the case VG950/VG951's
206
+ * same-function analysis structurally can't see. Conservative on every axis (authz
207
+ * name + session ref + exact id-sharing + textually-before) so an unrelated guard
208
+ * can't hide a real BOLA.
209
+ */
210
+ function hasInterProceduralOwnershipGuard(ts, sf, target) {
211
+ const idText = findKeyedIdText(ts, sf, target);
212
+ // Require a specific id expression (a member access or a sufficiently long name);
213
+ // a bare `id` is too generic to match a helper argument soundly.
214
+ if (!idText || (!idText.includes(".") && idText.length < 5))
215
+ return false;
216
+ let fn = target;
217
+ while (fn && !(ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isArrowFunction(fn) || ts.isMethodDeclaration(fn))) {
218
+ fn = fn.parent;
219
+ }
220
+ if (!fn)
221
+ return false;
222
+ const targetStart = target.getStart(sf);
223
+ let guarded = false;
224
+ const visit = (node) => {
225
+ if (guarded)
226
+ return;
227
+ if (ts.isCallExpression(node) && node !== target && node.getStart(sf) < targetStart) {
228
+ const callee = node.expression;
229
+ const method = ts.isPropertyAccessExpression(callee) ? callee.name.text
230
+ : ts.isIdentifier(callee) ? callee.text : undefined;
231
+ if (method && AUTHZ_HELPER.test(method)) {
232
+ const argsText = node.arguments.map(a => a.getText(sf)).join(", ");
233
+ if (SESSION_REF.test(argsText) && argsText.includes(idText))
234
+ guarded = true;
235
+ }
236
+ }
237
+ if (!guarded)
238
+ ts.forEachChild(node, visit);
239
+ };
240
+ visit(fn);
241
+ return guarded;
242
+ }
153
243
  /** The first CallExpression near `line` whose last-identifier method is in `methods`. */
154
244
  function callNearLine(ts, sf, line, methods) {
155
245
  let target;
@@ -212,23 +302,22 @@ export function bolaOwnershipGuarded(code, filePath, line) {
212
302
  const target = callNearLine(ts, sf, line, FIND_METHODS);
213
303
  if (!target)
214
304
  return false;
215
- // (1) ownership field in the WHERE clause with a non-param value.
305
+ // (1) ownership field in the WHERE clause with a non-param value — now scanned
306
+ // recursively so ownership nested inside a relation filter (`members.some.userId`)
307
+ // counts too, with a session-derived (not request-controlled) value.
216
308
  const arg0 = target.arguments[0];
217
309
  if (arg0 && ts.isObjectLiteralExpression(arg0)) {
218
310
  const whereProp = arg0.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "where");
219
- if (whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)) {
220
- for (const prop of whereProp.initializer.properties) {
221
- const nm = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
222
- if (nm && OWNERSHIP_FIELDS.has(nm)) {
223
- const valText = ts.isPropertyAssignment(prop) ? prop.initializer.getText(sf) : nm;
224
- if (!/\b(?:params|searchParams)\b/.test(valText))
225
- return true;
226
- }
227
- }
311
+ if (whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)
312
+ && whereHasNestedOwnership(ts, sf, whereProp.initializer)) {
313
+ return true;
228
314
  }
229
315
  }
230
316
  // (2) post-fetch ownership comparison against a session/user value, in the same function.
231
- return hasPostFetchOwnershipGuard(ts, sf, target);
317
+ if (hasPostFetchOwnershipGuard(ts, sf, target))
318
+ return true;
319
+ // (3) inter-procedural: an authz helper checks session + this id before the find.
320
+ return hasInterProceduralOwnershipGuard(ts, sf, target);
232
321
  }
233
322
  /**
234
323
  * BOLA ownership-guard detection for VG951 (delete/update). The rule's regex
@@ -253,7 +342,11 @@ export function bolaMutationGuarded(code, filePath, line) {
253
342
  const target = callNearLine(ts, sf, line, MUTATION_METHODS);
254
343
  if (!target)
255
344
  return false;
256
- return hasPostFetchOwnershipGuard(ts, sf, target);
345
+ // Same-function post-fetch comparison, OR an inter-procedural authz helper that
346
+ // checked session + this id before the mutation (the helper-guard blind spot).
347
+ if (hasPostFetchOwnershipGuard(ts, sf, target))
348
+ return true;
349
+ return hasInterProceduralOwnershipGuard(ts, sf, target);
257
350
  }
258
351
  const ITER_METHODS = new Set(["map", "forEach", "some", "every", "filter", "find", "findIndex", "reduce", "flatMap"]);
259
352
  /** First `const NAME = <initializer>` for NAME anywhere in the file (file-scope-ish). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.25.0",
3
+ "version": "3.26.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. 450 rules, 39 tools, CLI + doctor. Prompt-level shift-left security (secure_prompt — embed security requirements BEFORE code generation), host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. 77 CVE rules refreshed daily from GHSA/OSV/CISA KEV — js-cookie cookie-attribute injection, PostCSS </style> stringify XSS, Axios proxy prototype-pollution gadget, 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",