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 +9 -0
- package/build/tools/ast-engine.js +105 -12
- package/package.json +1 -1
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|