guardvibe 3.1.38 → 3.1.39
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 +10 -0
- package/build/tools/cross-file-taint.js +8 -1
- package/build/tools/file-security.js +3 -21
- package/build/tools/full-audit.js +5 -2
- package/build/tools/taint-analysis.js +79 -0
- package/build/utils/constants.d.ts +6 -0
- package/build/utils/constants.js +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ 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.39] - 2026-06-07
|
|
9
|
+
|
|
10
|
+
### Added — SSRF taint detection + taint-engine precision (no rule-count change, 438 / 36)
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **Cross-file taint** now also detects command injection (`exec`/`execSync` via an imported helper), matching the per-file analyzer.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
Gate green (build / lint / test / self-audit PASS / A / 0).
|
|
17
|
+
|
|
8
18
|
## [3.1.38] - 2026-06-07
|
|
9
19
|
|
|
10
20
|
### Fixed — false-positive precision, verified one rule at a time (no rule-count change, 438 / 36)
|
|
@@ -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;
|
package/build/utils/constants.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "3.1.39",
|
|
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",
|