guardvibe 3.15.0 → 3.17.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 +20 -0
- package/build/tools/ast-engine.js +166 -0
- 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.17.0] - 2026-06-09
|
|
9
|
+
|
|
10
|
+
### Added — FAZ 3 part 3: AST constant-origin detection for VG126 (442 rules / 37 tools)
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
- No rule or tool changes (442 / 37). FAZ 3 part 3 (reuses the part-1 AST engine).
|
|
14
|
+
|
|
15
|
+
Gate green (build / lint / test / self-audit PASS / A / 0).
|
|
16
|
+
|
|
17
|
+
## [3.16.0] - 2026-06-08
|
|
18
|
+
|
|
19
|
+
### Added — FAZ 3 part 2: AST BOLA ownership-guard detection for VG950 (442 rules / 37 tools)
|
|
20
|
+
- **VG950 (BOLA — find-by-id without ownership) is now AST-aware.** It had no ownership handling at all (VG951 already excludes where-clause ownership). `bolaOwnershipGuarded` suppresses VG950 when the AST proves the query is ownership-guarded — EITHER an ownership field in the **WHERE clause** with a non-route-param value, OR a same-function **post-fetch ownership comparison** of the fetched resource against the session (e.g. `if (eventType.userId !== ctx.user.id) throw`).
|
|
21
|
+
- **Precise where the regex can't be:** it ignores a `userId` that only appears in `select` (a regex lookahead would suppress on that → false negative), it sees an ownership check that lives in a separate statement, and it refuses to count an ownership field whose value is itself a route param (still BOLA).
|
|
22
|
+
- **Validated (clean stash diff): VG950 22 → 15; all 7 removed are genuinely ownership-guarded** — 3 via where-clause ownership (dub rewards/oauth-apps/tokens), 4 via post-fetch comparison (cal schedule/ooo handlers, each with a real `…userId !== user.id` check). **0 true BOLA hidden (0 false negatives), 0 false positives introduced.** 10 new tests.
|
|
23
|
+
- No rule or tool changes (442 / 37). FAZ 3 part 2 (engine reused from part 1).
|
|
24
|
+
|
|
25
|
+
Gate green (build / lint / test / self-audit PASS / A / 0).
|
|
26
|
+
|
|
8
27
|
## [3.15.0] - 2026-06-08
|
|
9
28
|
|
|
10
29
|
### Added — FAZ 3 part 1: AST dataflow engine + precise VG406 (442 rules / 37 tools)
|
|
@@ -5,3 +5,23 @@
|
|
|
5
5
|
* the rule keeps its prior behavior rather than silently hiding a finding.
|
|
6
6
|
*/
|
|
7
7
|
export declare function paramReachesSink(code: string, filePath?: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* BOLA ownership-guard detection for VG950 (find-by-user-id). Returns true (the
|
|
10
|
+
* query is ownership-guarded → suppress the finding) when EITHER:
|
|
11
|
+
* (1) the find call's WHERE clause (not select!) contains an ownership field
|
|
12
|
+
* whose value is not itself a route param, OR
|
|
13
|
+
* (2) the enclosing function performs a post-fetch ownership comparison of an
|
|
14
|
+
* ownership field against a session/user value.
|
|
15
|
+
* Returns false on uncertainty (no parser, no matching call) so the rule keeps
|
|
16
|
+
* firing — for a BOLA rule we prefer a false positive over hiding a real one.
|
|
17
|
+
*/
|
|
18
|
+
export declare function bolaOwnershipGuarded(code: string, filePath: string | undefined, line: number): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* True when the argument to a `new RegExp(...)` at `line` is PROVABLY a constant
|
|
21
|
+
* (a string literal, a variable assigned from a string literal, or the callback
|
|
22
|
+
* parameter of an iteration over a const string-array — the "bot list" pattern),
|
|
23
|
+
* so VG126 ("Dynamic RegExp from user input") is a false positive there. Returns
|
|
24
|
+
* false on any uncertainty so the rule keeps firing — a regex built from anything
|
|
25
|
+
* not provably constant stays flagged.
|
|
26
|
+
*/
|
|
27
|
+
export declare function regexpArgIsConstant(code: string, filePath: string | undefined, line: number): boolean;
|
|
@@ -136,3 +136,169 @@ export function paramReachesSink(code, filePath) {
|
|
|
136
136
|
}
|
|
137
137
|
return sinkArgs.some(args => refsTaint(args, tainted));
|
|
138
138
|
}
|
|
139
|
+
const FIND_METHODS = new Set(["findUnique", "findFirst", "findById", "findOne", "getOne"]);
|
|
140
|
+
const OWNERSHIP_FIELDS = new Set([
|
|
141
|
+
"userId", "user_id", "ownerId", "owner_id", "authorId", "author_id", "createdById", "createdBy", "created_by",
|
|
142
|
+
"accountId", "account_id", "tenantId", "tenant_id", "orgId", "org_id", "organizationId",
|
|
143
|
+
"projectId", "project_id", "workspaceId", "workspace_id", "teamId", "team_id", "memberId", "member_id",
|
|
144
|
+
"programId", "customerId",
|
|
145
|
+
]);
|
|
146
|
+
// A comparison of a fetched resource's ownership field, on a line that also references a session/user.
|
|
147
|
+
const OWNERSHIP_COMPARE = /\.\s*(?:userId|ownerId|authorId|createdById|teamId|workspaceId|orgId|organizationId|tenantId|memberId|accountId|projectId)\b\s*(?:===|!==|==|!=)/i;
|
|
148
|
+
const SESSION_REF = /\b(?:session|ctx|auth|currentUser|viewer|member|account|workspace|team|org|self|me|user)\b/i;
|
|
149
|
+
/**
|
|
150
|
+
* BOLA ownership-guard detection for VG950 (find-by-user-id). Returns true (the
|
|
151
|
+
* query is ownership-guarded → suppress the finding) when EITHER:
|
|
152
|
+
* (1) the find call's WHERE clause (not select!) contains an ownership field
|
|
153
|
+
* whose value is not itself a route param, OR
|
|
154
|
+
* (2) the enclosing function performs a post-fetch ownership comparison of an
|
|
155
|
+
* ownership field against a session/user value.
|
|
156
|
+
* Returns false on uncertainty (no parser, no matching call) so the rule keeps
|
|
157
|
+
* firing — for a BOLA rule we prefer a false positive over hiding a real one.
|
|
158
|
+
*/
|
|
159
|
+
export function bolaOwnershipGuarded(code, filePath, line) {
|
|
160
|
+
const ts = getTs();
|
|
161
|
+
if (!ts)
|
|
162
|
+
return false;
|
|
163
|
+
let sf;
|
|
164
|
+
try {
|
|
165
|
+
sf = ts.createSourceFile(filePath ?? "file.ts", code, ts.ScriptTarget.Latest, true, scriptKindFor(ts, filePath));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
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);
|
|
186
|
+
if (!target)
|
|
187
|
+
return false;
|
|
188
|
+
// (1) ownership field in the WHERE clause with a non-param value.
|
|
189
|
+
const arg0 = target.arguments[0];
|
|
190
|
+
if (arg0 && ts.isObjectLiteralExpression(arg0)) {
|
|
191
|
+
const whereProp = arg0.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "where");
|
|
192
|
+
if (whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)) {
|
|
193
|
+
for (const prop of whereProp.initializer.properties) {
|
|
194
|
+
const nm = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
|
|
195
|
+
if (nm && OWNERSHIP_FIELDS.has(nm)) {
|
|
196
|
+
const valText = ts.isPropertyAssignment(prop) ? prop.initializer.getText(sf) : nm;
|
|
197
|
+
if (!/\b(?:params|searchParams)\b/.test(valText))
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// (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))) {
|
|
206
|
+
fn = fn.parent;
|
|
207
|
+
}
|
|
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))
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const ITER_METHODS = new Set(["map", "forEach", "some", "every", "filter", "find", "findIndex", "reduce", "flatMap"]);
|
|
216
|
+
/** First `const NAME = <initializer>` for NAME anywhere in the file (file-scope-ish). */
|
|
217
|
+
function findVarInit(ts, sf, name) {
|
|
218
|
+
let found;
|
|
219
|
+
const visit = (node) => {
|
|
220
|
+
if (!found && ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) {
|
|
221
|
+
found = node.initializer;
|
|
222
|
+
}
|
|
223
|
+
if (!found)
|
|
224
|
+
ts.forEachChild(node, visit);
|
|
225
|
+
};
|
|
226
|
+
visit(sf);
|
|
227
|
+
return found;
|
|
228
|
+
}
|
|
229
|
+
/** A non-empty array literal whose elements are all string/template literals (a const pattern list). */
|
|
230
|
+
function isConstStringArray(ts, sf, node) {
|
|
231
|
+
// SCREAMING_SNAKE_CASE identifier = constant by convention (often an imported list).
|
|
232
|
+
if (ts.isIdentifier(node) && /^[A-Z][A-Z0-9_]+$/.test(node.text))
|
|
233
|
+
return true;
|
|
234
|
+
let arr = node;
|
|
235
|
+
if (ts.isIdentifier(node))
|
|
236
|
+
arr = findVarInit(ts, sf, node.text);
|
|
237
|
+
if (arr && ts.isArrayLiteralExpression(arr)) {
|
|
238
|
+
return arr.elements.length > 0 &&
|
|
239
|
+
arr.elements.every(el => ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el));
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* True when the argument to a `new RegExp(...)` at `line` is PROVABLY a constant
|
|
245
|
+
* (a string literal, a variable assigned from a string literal, or the callback
|
|
246
|
+
* parameter of an iteration over a const string-array — the "bot list" pattern),
|
|
247
|
+
* so VG126 ("Dynamic RegExp from user input") is a false positive there. Returns
|
|
248
|
+
* false on any uncertainty so the rule keeps firing — a regex built from anything
|
|
249
|
+
* not provably constant stays flagged.
|
|
250
|
+
*/
|
|
251
|
+
export function regexpArgIsConstant(code, filePath, line) {
|
|
252
|
+
const ts = getTs();
|
|
253
|
+
if (!ts)
|
|
254
|
+
return false;
|
|
255
|
+
let sf;
|
|
256
|
+
try {
|
|
257
|
+
sf = ts.createSourceFile(filePath ?? "file.ts", code, ts.ScriptTarget.Latest, true, scriptKindFor(ts, filePath));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
let target;
|
|
263
|
+
const visit = (node) => {
|
|
264
|
+
if (!target && ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "RegExp"
|
|
265
|
+
&& node.arguments && node.arguments.length > 0) {
|
|
266
|
+
const startLine = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
267
|
+
if (Math.abs(startLine - line) <= 1)
|
|
268
|
+
target = node;
|
|
269
|
+
}
|
|
270
|
+
if (!target)
|
|
271
|
+
ts.forEachChild(node, visit);
|
|
272
|
+
};
|
|
273
|
+
visit(sf);
|
|
274
|
+
if (!target || !target.arguments)
|
|
275
|
+
return false;
|
|
276
|
+
const arg = target.arguments[0];
|
|
277
|
+
if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))
|
|
278
|
+
return true;
|
|
279
|
+
// `new RegExp(x.source, x.flags)` — cloning an existing compiled RegExp, not user input.
|
|
280
|
+
if (ts.isPropertyAccessExpression(arg) && (arg.name.text === "source" || arg.name.text === "flags"))
|
|
281
|
+
return true;
|
|
282
|
+
if (!ts.isIdentifier(arg))
|
|
283
|
+
return false;
|
|
284
|
+
const argName = arg.text;
|
|
285
|
+
// (a) const argName = "literal"
|
|
286
|
+
const init = findVarInit(ts, sf, argName);
|
|
287
|
+
if (init && (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)))
|
|
288
|
+
return true;
|
|
289
|
+
// (b) argName is the callback parameter of an iteration over a const string array.
|
|
290
|
+
let fn = target.parent;
|
|
291
|
+
while (fn && !((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))
|
|
292
|
+
&& fn.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === argName))) {
|
|
293
|
+
fn = fn.parent;
|
|
294
|
+
}
|
|
295
|
+
if (fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) {
|
|
296
|
+
const call = fn.parent;
|
|
297
|
+
if (call && ts.isCallExpression(call) && ts.isPropertyAccessExpression(call.expression)
|
|
298
|
+
&& ITER_METHODS.has(call.expression.name.text)
|
|
299
|
+
&& isConstStringArray(ts, sf, call.expression.expression)) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
@@ -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 } from "./ast-engine.js";
|
|
7
|
+
import { paramReachesSink, bolaOwnershipGuarded, 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. */
|
|
@@ -880,6 +880,18 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
880
880
|
const lineNumber = beforeMatch.split("\n").length;
|
|
881
881
|
if (isLineSuppressed(suppressions, lineNumber, rule.id))
|
|
882
882
|
continue;
|
|
883
|
+
// VG950 (BOLA find-by-id): suppress when AST proves the query is ownership-guarded —
|
|
884
|
+
// an ownership field in the WHERE clause (non-param value) or a post-fetch ownership
|
|
885
|
+
// comparison against the session. Precise where the regex can't be: it ignores a
|
|
886
|
+
// `userId` that only appears in `select`, and sees a separate comparison statement.
|
|
887
|
+
if (rule.id === "VG950" && filePath && bolaOwnershipGuarded(code, filePath, lineNumber))
|
|
888
|
+
continue;
|
|
889
|
+
// VG126 (Dynamic RegExp from User Input): the regex matches `new RegExp(anyVar)`,
|
|
890
|
+
// but the var is often a provable constant — a string literal or the callback
|
|
891
|
+
// param iterating a const string array (the classic bot-UA / referrer-list pattern).
|
|
892
|
+
// Suppress only when AST proves the argument is constant; anything else keeps firing.
|
|
893
|
+
if (rule.id === "VG126" && (looksMinified(code) || (filePath && regexpArgIsConstant(code, filePath, lineNumber))))
|
|
894
|
+
continue;
|
|
883
895
|
// VG202: skip when the FROM target matches a previous AS-alias in the same file.
|
|
884
896
|
if (dockerStageAliases) {
|
|
885
897
|
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.17.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",
|