guardvibe 3.14.2 → 3.16.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,27 @@ 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.16.0] - 2026-06-08
9
+
10
+ ### Added — FAZ 3 part 2: AST BOLA ownership-guard detection for VG950 (442 rules / 37 tools)
11
+ - **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`).
12
+ - **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).
13
+ - **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.
14
+ - No rule or tool changes (442 / 37). FAZ 3 part 2 (engine reused from part 1).
15
+
16
+ Gate green (build / lint / test / self-audit PASS / A / 0).
17
+
18
+ ## [3.15.0] - 2026-06-08
19
+
20
+ ### Added — FAZ 3 part 1: AST dataflow engine + precise VG406 (442 rules / 37 tools)
21
+ - **New AST/dataflow engine** (`src/tools/ast-engine.ts`), backed by the TypeScript compiler (loaded lazily, used only on the AST path). Brings real intra-file dataflow the line/regex engine structurally can't do.
22
+ - **VG406 (Unsanitized Dynamic Route Params) is now dataflow-aware.** Its regex bridged a `params`/`searchParams` access to ANY later DB sink via an unbounded match, false-positiving when the param never flows to that sink. `paramReachesSink` does intra-procedural taint — seeding from params/searchParams and propagating through variable assignments and query-builder calls — so VG406 fires only on a real param → sink flow (multi-hop included, the case a name-only regex misses).
23
+ - **Validated (clean stash diff): VG406 24 → 20; all 4 removed are confirmed false positives** where `params` is a function/constructor/callback argument named "params" (not a route param) — dub `get-events`/`create-bounty-submission`, plane `filter.store`, unkey `use-logs-query`. **0 true positives lost, 0 new findings.** 10 tests (engine + integration).
24
+ - **Runtime dependency:** added `typescript` (^5.7.0) — pure-JS, zero sub-dependencies, no native bindings, deterministic everywhere. The README claim is updated from "zero runtime dependencies" to "minimal, fully-audited runtime dependencies (MCP SDK, Zod, TypeScript compiler)". The publish workflow's npm step is already idempotent.
25
+ - No rule or tool changes (442 / 37). First of several FAZ 3 releases (next: extend the engine to IDOR/BOLA and VG950).
26
+
27
+ Gate green (build / lint / test / self-audit PASS / A / 0).
28
+
8
29
  ## [3.14.2] - 2026-06-08
9
30
 
10
31
  ### Fixed — VG964 false positives on App Router route segments (442 rules / 37 tools)
package/README.md CHANGED
@@ -584,7 +584,7 @@ GuardVibe takes supply chain security seriously:
584
584
  - **Branch protection** — force push disabled on main, admin enforcement enabled
585
585
  - **Tag protection** — version tags (`v*`) cannot be deleted or force-pushed
586
586
  - **Minimal CI permissions** — GitHub Actions workflows use `permissions: contents: read` only
587
- - **Zero runtime dependencies** — only MCP SDK and Zod (both widely audited)
587
+ - **Minimal, fully-audited runtime dependencies** — only the MCP SDK, Zod, and the TypeScript compiler (used for AST-based dataflow analysis). All three are widely-audited, zero-sub-dependency packages — no native bindings, no obscure transitive deps
588
588
 
589
589
  To report a vulnerability, please email info@goklab.com or open a GitHub issue.
590
590
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * True if a route parameter (params / searchParams) reaches a DB/query sink in
3
+ * this file, following assignments and query-builder calls. Returns true (the safe
4
+ * default — don't suppress) when TypeScript is unavailable or parsing fails, so
5
+ * the rule keeps its prior behavior rather than silently hiding a finding.
6
+ */
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;
@@ -0,0 +1,214 @@
1
+ // guardvibe-ignore — AST-engine helpers; the sink-method names and taint regex
2
+ // below are detector patterns, not vulnerable code.
3
+ /**
4
+ * AST engine (FAZ 3) — real parsing + intra-file dataflow for precision the
5
+ * line/regex engine structurally can't reach.
6
+ *
7
+ * Backed by the TypeScript compiler's parser, loaded LAZILY and synchronously
8
+ * (createRequire) so non-AST scan paths pay nothing for it. `typescript` is a
9
+ * pure-JS, zero-sub-dependency, no-native-bindings package — the lowest
10
+ * supply-chain-risk parser available — and is a bundled runtime dependency so the
11
+ * analysis is deterministic everywhere (not dependent on the scanned project
12
+ * happening to have its own copy). If it can't be loaded, every helper degrades
13
+ * to a safe default that preserves the prior (regex-only) behavior.
14
+ *
15
+ * First capability: precise param → sink reachability for VG406. The rule's regex
16
+ * bridges a `params`/`searchParams` access to ANY later DB sink in the file via an
17
+ * unbounded `[\s\S]*?`, so it false-positives when the param never actually flows
18
+ * to that sink. `paramReachesSink` does real intra-procedural taint — seeding from
19
+ * params/searchParams and propagating through variable assignments and
20
+ * query-builder calls — so a param routed through an intermediate variable still
21
+ * counts (the case a name-only regex misses) while an unrelated sink does not.
22
+ */
23
+ import { createRequire } from "module";
24
+ let _ts = null;
25
+ let _loadFailed = false;
26
+ function getTs() {
27
+ if (_ts)
28
+ return _ts;
29
+ if (_loadFailed)
30
+ return null;
31
+ try {
32
+ const require = createRequire(import.meta.url);
33
+ _ts = require("typescript");
34
+ return _ts;
35
+ }
36
+ catch {
37
+ _loadFailed = true;
38
+ return null;
39
+ }
40
+ }
41
+ function scriptKindFor(ts, filePath) {
42
+ const f = (filePath ?? "").toLowerCase();
43
+ if (f.endsWith(".tsx"))
44
+ return ts.ScriptKind.TSX;
45
+ if (f.endsWith(".jsx"))
46
+ return ts.ScriptKind.JSX;
47
+ if (f.endsWith(".js") || f.endsWith(".mjs") || f.endsWith(".cjs"))
48
+ return ts.ScriptKind.JS;
49
+ return ts.ScriptKind.TS;
50
+ }
51
+ // DB / query sink method names (last identifier of the callee).
52
+ const SINK_METHODS = new Set([
53
+ "query", "execute", "findUnique", "findFirst", "findMany", "delete", "update", "create",
54
+ "upsert", "aggregate", "count", "groupBy", "createMany", "updateMany", "deleteMany",
55
+ "raw", "$queryRaw", "$executeRaw", "$queryRawUnsafe", "$executeRawUnsafe",
56
+ ]);
57
+ const PARAM_ROOT = /\b(?:params|searchParams)\b/;
58
+ /** Does `text` reference a taint root (params/searchParams) or any tainted identifier? */
59
+ function refsTaint(text, tainted) {
60
+ if (PARAM_ROOT.test(text))
61
+ return true;
62
+ for (const t of tainted) {
63
+ if (new RegExp("\\b" + t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\b").test(text))
64
+ return true;
65
+ }
66
+ return false;
67
+ }
68
+ /**
69
+ * True if a route parameter (params / searchParams) reaches a DB/query sink in
70
+ * this file, following assignments and query-builder calls. Returns true (the safe
71
+ * default — don't suppress) when TypeScript is unavailable or parsing fails, so
72
+ * the rule keeps its prior behavior rather than silently hiding a finding.
73
+ */
74
+ export function paramReachesSink(code, filePath) {
75
+ const ts = getTs();
76
+ if (!ts)
77
+ return true;
78
+ let sf;
79
+ try {
80
+ sf = ts.createSourceFile(filePath ?? "file.ts", code, ts.ScriptTarget.Latest, true, scriptKindFor(ts, filePath));
81
+ }
82
+ catch {
83
+ return true;
84
+ }
85
+ const namesFromBinding = (name) => {
86
+ if (ts.isIdentifier(name))
87
+ return [name.text];
88
+ const out = [];
89
+ if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
90
+ for (const el of name.elements) {
91
+ if (ts.isBindingElement(el))
92
+ out.push(...namesFromBinding(el.name));
93
+ }
94
+ }
95
+ return out;
96
+ };
97
+ const assigns = [];
98
+ const sinkArgs = [];
99
+ const visit = (node) => {
100
+ if (ts.isVariableDeclaration(node) && node.initializer) {
101
+ assigns.push({ names: namesFromBinding(node.name), rhs: node.initializer.getText(sf) });
102
+ }
103
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)) {
104
+ assigns.push({ names: [node.left.text], rhs: node.right.getText(sf) });
105
+ }
106
+ else if (ts.isCallExpression(node)) {
107
+ const callee = node.expression;
108
+ let method;
109
+ if (ts.isPropertyAccessExpression(callee))
110
+ method = callee.name.text;
111
+ else if (ts.isIdentifier(callee))
112
+ method = callee.text;
113
+ if (method && SINK_METHODS.has(method) && node.arguments.length > 0) {
114
+ sinkArgs.push(node.arguments.map(a => a.getText(sf)).join(", "));
115
+ }
116
+ }
117
+ ts.forEachChild(node, visit);
118
+ };
119
+ visit(sf);
120
+ // Fixpoint taint propagation through assignments.
121
+ const tainted = new Set();
122
+ let changed = true;
123
+ let guard = 0;
124
+ while (changed && guard++ < 25) {
125
+ changed = false;
126
+ for (const a of assigns) {
127
+ if (refsTaint(a.rhs, tainted)) {
128
+ for (const n of a.names) {
129
+ if (!tainted.has(n)) {
130
+ tainted.add(n);
131
+ changed = true;
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ return sinkArgs.some(args => refsTaint(args, tainted));
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
+ }
@@ -4,6 +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
8
  /** CVE version-pin rule IDs are VG900-VG931 (and only these). Other VG9xx IDs
8
9
  * (VG983 Turso, VG990 SVG, VG998 OpenAI browser flag, etc.) are regular code-pattern
9
10
  * rules and should NOT be exempted from comment / string-literal skip logic. */
@@ -674,6 +675,17 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
674
675
  continue;
675
676
  }
676
677
  }
678
+ // VG406 (Unsanitized Dynamic Route Params): the regex bridges a params/searchParams
679
+ // access to ANY later DB sink in the file via an unbounded match, so it
680
+ // false-positives when the param never actually flows to that sink. Use AST
681
+ // dataflow (FAZ 3) to require a real param→sink flow — through assignments and
682
+ // query-builders, the case a name-only regex misses. Cheap guards gate the parse
683
+ // to files that actually contain a param + a sink (i.e. real VG406 candidates).
684
+ if (rule.id === "VG406" && filePath
685
+ && /\b(?:params|searchParams)\b/.test(code)
686
+ && /\b(?:query|execute|findUnique|findFirst|findMany|delete|update|create|upsert|aggregate|count|groupBy)\s*\(/.test(code)
687
+ && !paramReachesSink(code, filePath))
688
+ continue;
677
689
  // Skip SQL injection rules in schema/migration .sql files (DDL, not user input)
678
690
  if (rule.id === "VG543" && (isMigrationFile || isSqlSchemaFile))
679
691
  continue;
@@ -868,6 +880,12 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
868
880
  const lineNumber = beforeMatch.split("\n").length;
869
881
  if (isLineSuppressed(suppressions, lineNumber, rule.id))
870
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;
871
889
  // VG202: skip when the FROM target matches a previous AS-alias in the same file.
872
890
  if (dockerStageAliases) {
873
891
  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.14.2",
3
+ "version": "3.16.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",
@@ -107,6 +107,7 @@
107
107
  },
108
108
  "dependencies": {
109
109
  "@modelcontextprotocol/sdk": "^1.26.0",
110
+ "typescript": "^5.7.0",
110
111
  "zod": "^3.25.0"
111
112
  },
112
113
  "overrides": {
@@ -119,7 +120,6 @@
119
120
  "c8": "^11.0.0",
120
121
  "eslint": "^10.2.0",
121
122
  "tsx": "^4.21.0",
122
- "typescript": "^5.7.0",
123
123
  "typescript-eslint": "^8.58.0"
124
124
  },
125
125
  "engines": {