guardvibe 3.1.38 → 3.1.40

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,26 @@ 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.1.40] - 2026-06-07
9
+
10
+ ### Added — recall: Mongoose direct mass-assignment (no rule-count change, 438 / 36)
11
+ - **VG953** now also flags request bodies passed *directly* as the update document to Mongoose writes — `findByIdAndUpdate(id, req.body)`, `findOneAndUpdate(q, req.body)`, `updateOne/updateMany/findOneAndReplace/replaceOne(q, req.body)` — not just the `{ ...req.body }` spread form. Explicit-field updates (`findByIdAndUpdate(id, { name, email })`) are not flagged. Zero new corpus hits (no false positives).
12
+
13
+ ### Internal — test coverage
14
+ - Overall test coverage raised from ~90.6% to ~97% via 24 new offline, deterministic test files; the MCP server entry point (`src/index.ts`) is excluded from coverage (it is the stdio bootstrap, exercised via integration, not unit tests).
15
+
16
+ Gate green (build / lint / test / self-audit PASS / A / 0).
17
+
18
+ ## [3.1.39] - 2026-06-07
19
+
20
+ ### Added — SSRF taint detection + taint-engine precision (no rule-count change, 438 / 36)
21
+ - **SSRF taint sink** — user input flowing into the URL of an outbound request (fetch/axios/got/http.request) is now detected on the check path and in the audit. It is **host-position-aware**: only a tainted host is flagged, so a tainted path/query on a fixed host (`fetch(`${BASE}/api?${q}`)`), a tainted POST body, a same-origin relative URL, or a `new URL(path, base)` with a fixed base are NOT false-positived. Client components, test files, and SSRF-validated code are skipped. Validated against the corpus: 1 hit, a real SSRF, with zero false positives — far more precise than a plain `fetch(variable)` regex.
22
+ - **Minified/generated bundles are skipped for taint** (shared `looksMinified` heuristic), removing a false-positive class where mangled `e`/`t` parameters in bundles masquerade as taint sources.
23
+ - **Cross-file taint** now also detects command injection (`exec`/`execSync` via an imported helper), matching the per-file analyzer.
24
+ - The audit's secret section now skips test fixtures (matching the check path), so fake keys in `*.spec`/test files no longer surface as findings.
25
+
26
+ Gate green (build / lint / test / self-audit PASS / A / 0).
27
+
8
28
  ## [3.1.38] - 2026-06-07
9
29
 
10
30
  ### Fixed — false-positive precision, verified one rule at a time (no rule-count change, 438 / 36)
@@ -46,7 +46,7 @@ export const apiSecurityRules = [
46
46
  severity: "high",
47
47
  owasp: "API3:2023 Broken Object Property Level Authorization",
48
48
  description: "Request body is spread directly into a database create/update operation. Attackers can inject extra fields (like role, isAdmin, price) that the API didn't intend to accept.",
49
- pattern: /(?:create|update|upsert|insert)\s*\(\s*\{[\s\S]{0,100}?(?:\.\.\.(?:req\.body|body|input|data|args)|(?:data|values)\s*:\s*(?:req\.body|body|input))\s*\}/gi,
49
+ pattern: /(?:create|update|upsert|insert)\s*\(\s*\{[\s\S]{0,100}?(?:\.\.\.(?:req\.body|body|input|data|args)|(?:data|values)\s*:\s*(?:req\.body|body|input))\s*\}|(?:findByIdAndUpdate|findOneAndUpdate|findOneAndReplace|updateOne|updateMany|replaceOne)\s*\(\s*[^,()]+,\s*req\.(?:body|query)\s*[,)]/gi,
50
50
  languages: ["javascript", "typescript"],
51
51
  fix: "Explicitly pick allowed fields instead of spreading the entire request body. Use a validation schema (zod) to define exactly which fields are accepted.",
52
52
  fixCode: '// BAD: mass assignment\nawait prisma.user.update({ where: { id }, data: { ...req.body } });\n\n// GOOD: explicit fields\nconst { name, email } = schema.parse(req.body);\nawait prisma.user.update({ where: { id }, data: { name, email } });',
@@ -226,6 +226,8 @@ function parseExports(file, content) {
226
226
  function extractFunctions(file, content) {
227
227
  const functions = [];
228
228
  const lines = content.split("\n");
229
+ // guardvibe-ignore VG011 — these are function-detection regex literals (this file defines
230
+ // analysis patterns, not vulnerable code); the `WORD = /...\(/` shape self-matches VG011.
229
231
  const funcPattern = /(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([\w$]+)\s*\(([^)]*)\)/;
230
232
  const arrowPattern = /(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s+)?(?:\(([^)]*)\)\s*=>|\([^)]*\)\s*:\s*\w+\s*=>|function\s*\(([^)]*)\))/;
231
233
  for (let i = 0; i < lines.length; i++) {
@@ -279,6 +281,10 @@ const SINK_PATTERNS = [
279
281
  { pattern: /new\s+Function\s*\(/g, type: "code-injection" },
280
282
  { pattern: /writeFileSync?\s*\(/g, type: "path-traversal" },
281
283
  { pattern: /readFileSync?\s*\(/g, type: "path-traversal" },
284
+ // Bare child_process exec()/execSync() (shell-invoking). Lookbehind excludes method
285
+ // calls like regex.exec()/db.execSync(). Mirrors the per-file taint sink so cross-file
286
+ // command injection (input flows through an imported helper into exec) is caught too.
287
+ { pattern: /(?<!\.)\bexec(?:Sync)?\s*\(/g, type: "command-injection" },
282
288
  ];
283
289
  // Patterns that break the taint chain (validation/sanitization)
284
290
  const SANITIZER_PATTERNS = [
@@ -595,7 +601,7 @@ function extractCallArgs(line, funcName) {
595
601
  return args;
596
602
  }
597
603
  function deriveSeverity(sinkType) {
598
- if (sinkType === "code-injection" || sinkType === "sql-injection")
604
+ if (sinkType === "code-injection" || sinkType === "sql-injection" || sinkType === "command-injection")
599
605
  return "critical";
600
606
  if (sinkType === "xss" || sinkType === "path-traversal")
601
607
  return "high";
@@ -606,6 +612,7 @@ function getSinkFix(sinkType) {
606
612
  const fixes = {
607
613
  "sql-injection": "Use parameterized queries instead of string interpolation.",
608
614
  "code-injection": "Never pass user input to eval() or Function constructor.",
615
+ "command-injection": "Use execFile()/spawn() with an argument array (no shell) and validate input against an allowlist.",
609
616
  "xss": "Use textContent instead of innerHTML, or sanitize with DOMPurify.",
610
617
  "open-redirect": "Validate redirect URLs against a trusted domain allowlist.",
611
618
  "path-traversal": "Validate file paths with path.resolve() and check they stay within allowed directories.",
@@ -24,7 +24,7 @@ import { basename } from "path";
24
24
  import { analyzeCode } from "./check-code.js";
25
25
  import { analyzeTaint } from "./taint-analysis.js";
26
26
  import { scanContent } from "./scan-secrets.js";
27
- import { isExcludedFilename } from "../utils/constants.js";
27
+ import { isExcludedFilename, looksMinified } from "../utils/constants.js";
28
28
  const TAINT_OWASP = {
29
29
  "sql-injection": "A03:2021 Injection",
30
30
  "command-injection": "A03:2021 Injection",
@@ -32,6 +32,7 @@ const TAINT_OWASP = {
32
32
  "xss": "A03:2021 Injection",
33
33
  "open-redirect": "A01:2021 Broken Access Control",
34
34
  "path-traversal": "A01:2021 Broken Access Control",
35
+ "ssrf": "A10:2021 Server-Side Request Forgery",
35
36
  };
36
37
  // Regex VG rules that already represent the same vuln class as a taint sink type.
37
38
  // When one fires on the exact sink line, the taint finding is redundant — drop it.
@@ -42,6 +43,7 @@ const TAINT_REGEX_OVERLAP = {
42
43
  "xss": new Set(["VG012", "VG408", "VG852", "VG1080", "VG1084"]),
43
44
  "open-redirect": new Set(["VG101", "VG409", "VG425", "VG660"]),
44
45
  "path-traversal": new Set(["VG102"]),
46
+ "ssrf": new Set(["VG120"]),
45
47
  };
46
48
  // Regex rules that already report a hardcoded secret; drop a secret-pattern hit on the
47
49
  // same line as one of these to avoid double-reporting.
@@ -53,26 +55,6 @@ const TEST_FILE_RE = /(?:\.(?:[\w-]+-)?(?:spec|test|e2e|stories|cy)\.(?:ts|tsx|j
53
55
  // Synthetic regex that never matches — synthetic rules are only ever attached to
54
56
  // pre-computed findings, never run against source.
55
57
  const NEVER_MATCH = /(?!)/;
56
- // Minified bundles pack everything onto a few enormous lines; mangled `e`/`t` params
57
- // then masquerade as taint sources (`e.target.value`) feeding innerHTML sinks — a pure
58
- // FP class. The audit excludes these from taint via collectJsFiles+isExcludedFilename;
59
- // the check path mirrors that with a name pattern plus a content fallback for bundles
60
- // that aren't named `.min.js`.
61
- function looksMinified(code) {
62
- if (code.length < 5000)
63
- return false;
64
- let lineLen = 0;
65
- for (let i = 0; i < code.length; i++) {
66
- if (code[i] === "\n") {
67
- if (lineLen > 1000)
68
- return true;
69
- lineLen = 0;
70
- }
71
- else
72
- lineLen++;
73
- }
74
- return lineLen > 1000;
75
- }
76
58
  function taintToFinding(t) {
77
59
  const rule = {
78
60
  id: `TAINT:${t.sink.type}`,
@@ -195,9 +195,12 @@ export async function runFullAudit(path, options) {
195
195
  const secretsJson = scanSecrets(projectRoot, true, "json");
196
196
  const parsed = safeJsonParse(secretsJson);
197
197
  if (parsed) {
198
- // Filter out gitignored secrets — they're local dev files, not a security risk
198
+ // Filter out gitignored secrets — they're local dev files, not a security risk
199
+ // and secrets in test fixtures, which carry fake keys by design (e.g. a test PEM in
200
+ // a *.spec.ts crypto test). Mirrors the check path's file-security test-file skip.
201
+ const isTestSecretFile = (f) => /(?:\.(?:[\w-]+-)?(?:spec|test|e2e|stories|cy)\.(?:ts|tsx|js|jsx|mjs|cjs)$|_test\.go$|\/__tests__\/|\/__mocks__\/|\/tests?\/|\/cypress\/|\/playwright\/|\/dockertest\/|\/testutil\/|\/testhelpers?\/|\/testfixtures?\/|\/fixtures?\/)/i.test(f);
199
202
  const rawFindings = parsed.findings ?? [];
200
- const actionableFindings = rawFindings.filter((f) => f.gitStatus !== "ignored");
203
+ const actionableFindings = rawFindings.filter((f) => f.gitStatus !== "ignored" && !isTestSecretFile((f.file ?? "")));
201
204
  const ignoredCount = rawFindings.length - actionableFindings.length;
202
205
  const actionableCritical = actionableFindings.filter((f) => f.severity === "critical").length;
203
206
  const actionableHigh = actionableFindings.filter((f) => f.severity === "high").length;
@@ -4,6 +4,7 @@
4
4
  * Not a full AST/CFG analysis, but follows variable assignments through lines.
5
5
  */
6
6
  import { isRuleDefinitionFile } from "./check-code.js";
7
+ import { looksMinified } from "../utils/constants.js";
7
8
  // User input sources (tainted data entry points)
8
9
  const TAINT_SOURCES = [
9
10
  { pattern: /(?:req|request)\.(?:body|query|params|headers|cookies)\b/g, type: "http-input" },
@@ -84,6 +85,10 @@ function isSafeParameterizedSqlSink(lines, sinkIdx) {
84
85
  const interps = tpl.match(/\$\{[^}]*\}/g) || [];
85
86
  return interps.every(s => /\$\{\s*[\w$.]*(?:hash|sha\d*|md5|bcrypt|argon2?|hmac|digest|encode|escape|encodeURIComponent|toString|String|Number|parseInt|parseFloat)\b/i.test(s));
86
87
  }
88
+ // Outbound-request calls whose FIRST argument is the URL. The capture group grabs the
89
+ // first argument (up to the first top-level comma) so SSRF detection can scope to the URL
90
+ // position only. Covers fetch, axios.*, got.*, http(s).get/request, superagent.*.
91
+ const SSRF_CALL = /\b(?:fetch|axios(?:\.(?:get|post|put|delete|patch|head|request))?|got(?:\.(?:get|post|put|delete|patch|head))?|https?\.(?:get|request)|superagent\.(?:get|post|del|put))\s*\(\s*([^,)]+)/g;
87
92
  /**
88
93
  * A `redirect(...)` whose target is a root-relative, same-origin path
89
94
  * (e.g. redirect("/login") or redirect(`/${slug}/settings`)) cannot be an open
@@ -155,6 +160,9 @@ export function analyzeTaint(code, language, filePath) {
155
160
  // code snippets in pattern regexes, fixCode strings, and exploit examples.
156
161
  if (isRuleDefinitionFile(code, filePath))
157
162
  return [];
163
+ // Skip minified/generated bundles — mangled `e`/`t` params masquerade as taint sources.
164
+ if (looksMinified(code))
165
+ return [];
158
166
  const lines = code.split("\n");
159
167
  const findings = [];
160
168
  const assignments = extractAssignments(lines);
@@ -221,6 +229,77 @@ export function analyzeTaint(code, language, filePath) {
221
229
  }
222
230
  }
223
231
  }
232
+ // SSRF: a tainted value used as the HOST of the URL (FIRST argument) of an outbound
233
+ // request. Scoped to the first arg so a tainted POST *body* (axios.post(url, body)) is
234
+ // not a false positive, and to the host region so a tainted path/query on a FIXED host
235
+ // (`fetch(`${WEBAPP_URL}/api?${q}`)`) is not flagged — only an attacker-controlled host
236
+ // can reach internal services. Root-relative URLs stay same-origin and are excluded.
237
+ // Client components (browser fetch ≠ SSRF) and test files are skipped. This is far more
238
+ // precise than the VG120 regex, which flags any fetch(variable).
239
+ const isClientComponent = /^\s*['"]use client['"]/m.test(code.slice(0, 400));
240
+ const isTestPath = filePath ? /(?:\.(?:[\w-]+-)?(?:spec|test|e2e|stories|cy)\.[cm]?[jt]sx?$|_test\.go$|\/__tests__\/|\/__mocks__\/|\/tests?\/|\/cypress\/|\/playwright\/|\/fixtures?\/)/i.test(filePath) : false;
241
+ // SSRF-validated files (allowlist / private-IP block / SSRF-specific validators) are
242
+ // treated as protected — the user URL is checked before the request.
243
+ const hasSsrfGuard = /\b(?:validateUrlForSSRF|isTrustedInternalUrl|isAllowedUrl|assertSafeUrl|ssrfFilter|blockPrivateIp|isPublicUrl)\b/i.test(code);
244
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
245
+ if (!isClientComponent && !isTestPath && !hasSsrfGuard)
246
+ for (let i = 0; i < lines.length; i++) {
247
+ const line = lines[i];
248
+ SSRF_CALL.lastIndex = 0;
249
+ let m;
250
+ while ((m = SSRF_CALL.exec(line)) !== null) {
251
+ const urlArg = m[1].trim();
252
+ // Same-origin root-relative target (fetch("/api/x"), fetch(`/api/${id}`)) is not SSRF.
253
+ if (/^["'`]\/(?!\/)/.test(urlArg))
254
+ continue;
255
+ // Host region: strip a leading quote/backtick and scheme, then take up to the first
256
+ // path/query separator. Only a tainted value HERE (the host) is SSRF.
257
+ const stripped = urlArg.replace(/^[`'"]/, "");
258
+ const hasScheme = /^https?:\/\//i.test(stripped);
259
+ const host = stripped.replace(/^https?:\/\//i, "").split(/[/?#`'"]/)[0];
260
+ let srcType = null;
261
+ let srcVar = "(inline)";
262
+ let direct = false;
263
+ // Word-boundary match so a tainted `req` does not match the substring of `request`.
264
+ const tv = taintedVars.find(v => new RegExp(`\\b${escapeRe(v.name)}\\b`).test(host));
265
+ if (tv) {
266
+ srcType = tv.sourceType ?? "propagated";
267
+ srcVar = tv.name;
268
+ direct = srcType !== "propagated" && srcType !== "return-propagated";
269
+ }
270
+ if (!srcType) {
271
+ for (const source of TAINT_SOURCES) {
272
+ source.pattern.lastIndex = 0;
273
+ if (source.pattern.test(host)) {
274
+ srcType = source.type;
275
+ direct = true;
276
+ break;
277
+ }
278
+ }
279
+ }
280
+ if (!srcType)
281
+ continue;
282
+ // A no-scheme host (bare var or `${x}/...`) is only SSRF when the value is the WHOLE
283
+ // user-controlled URL (a direct source) — a propagated var may be a relative/fixed
284
+ // path. A scheme-prefixed external URL with a tainted host is always SSRF.
285
+ if (!hasScheme && !direct)
286
+ continue;
287
+ // `new URL(path, base)` resolves its host from the 2nd argument (base). When the var
288
+ // was built that way, the tainted part is only the path — the host is the fixed base.
289
+ if (tv && srcType === "url-input" && /new\s+URL\s*\([^;\n]*,/.test(lines[tv.line - 1] ?? ""))
290
+ continue;
291
+ if (findings.some(f => f.sink.line === i + 1 && f.sink.type === "ssrf"))
292
+ continue;
293
+ findings.push({
294
+ source: { type: srcType, line: tv ? tv.line : i + 1, variable: srcVar },
295
+ sink: { type: "ssrf", line: i + 1, code: line.trim().substring(0, 100) },
296
+ chain: [`[SOURCE] ${srcType} -> URL`, `[SINK] ssrf (line ${i + 1})`],
297
+ severity: "high",
298
+ description: "User input flows into the URL of a server-side request, enabling SSRF (internal services, cloud metadata at 169.254.169.254).",
299
+ fix: "Validate the URL against an allowlist of trusted hosts and block private/internal IP ranges before making the request.",
300
+ });
301
+ }
302
+ }
224
303
  return findings;
225
304
  }
226
305
  export function formatTaintFindings(findings, format) {
@@ -11,3 +11,9 @@ export declare const DEFAULT_EXCLUDES: Set<string>;
11
11
  /** File-name patterns excluded from scans — minified bundles, vendor libs, generated artifacts. */
12
12
  export declare const DEFAULT_FILE_PATTERN_EXCLUDES: RegExp[];
13
13
  export declare function isExcludedFilename(name: string): boolean;
14
+ /**
15
+ * Heuristic: does this source look like a minified/generated bundle? Such files pack
16
+ * everything onto a few enormous lines, and their mangled `e`/`t` params masquerade as
17
+ * taint sources — a pure false-positive class. Detect by a very long single line.
18
+ */
19
+ export declare function looksMinified(code: string): boolean;
@@ -44,3 +44,23 @@ export const DEFAULT_FILE_PATTERN_EXCLUDES = [
44
44
  export function isExcludedFilename(name) {
45
45
  return DEFAULT_FILE_PATTERN_EXCLUDES.some((pattern) => pattern.test(name));
46
46
  }
47
+ /**
48
+ * Heuristic: does this source look like a minified/generated bundle? Such files pack
49
+ * everything onto a few enormous lines, and their mangled `e`/`t` params masquerade as
50
+ * taint sources — a pure false-positive class. Detect by a very long single line.
51
+ */
52
+ export function looksMinified(code) {
53
+ if (code.length < 5000)
54
+ return false;
55
+ let lineLen = 0;
56
+ for (let i = 0; i < code.length; i++) {
57
+ if (code[i] === "\n") {
58
+ if (lineLen > 1000)
59
+ return true;
60
+ lineLen = 0;
61
+ }
62
+ else
63
+ lineLen++;
64
+ }
65
+ return lineLen > 1000;
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.1.38",
3
+ "version": "3.1.40",
4
4
  "mcpName": "io.github.goklab/guardvibe",
5
5
  "description": "Security MCP for vibe coding. 438 rules, 36 tools, CLI + doctor. Host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. 67 CVE rules refreshed daily from GHSA/OSV/CISA KEV — 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",