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 +19 -0
- package/build/tools/ast-engine.d.ts +19 -0
- package/build/tools/ast-engine.js +154 -21
- package/build/tools/check-code.js +13 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
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.
|
|
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",
|