guardvibe 3.1.36 → 3.1.37

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,20 @@ 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.37] - 2026-06-07
9
+
10
+ ### Added — taint + secret scanning on the `check` path (no rule-count change, 438 / 36)
11
+ Until now only `audit` ran taint analysis and secret-pattern scanning; the everyday `check` / `scan <file>` commands, the MCP `check_code` / `scan_file` / `scan_changed_files` tools, the `diff` path and the pre-commit hook ran regex rules only. They now share one combined analyzer, so two-step variable-indirection flows and hardcoded secrets are caught before code is committed:
12
+ - **Two-step taint** — a query, file path or shell command assembled into a variable before reaching the sink (path traversal, SQL/code injection, XSS) is now reported on the check path, not just inline patterns.
13
+ - **Command-injection taint sink** — `exec()` / `execSync()` fed tainted input is now a sink (the lookbehind excludes method calls like `regex.exec()` / `db.execSync()`). Validated against the corpus: 2 hits, both real RCE, zero hits on 9 production repos.
14
+ - **Hardcoded secrets** — PEM private keys, cloud keys and tokens are flagged on the check path even when the variable name is innocuous.
15
+
16
+ ### Fixed — taint precision (improves both `check` and `audit`)
17
+ - **Open redirect** no longer fires on same-origin root-relative targets (`redirect("/path")`, `` redirect(`/${slug}/settings`) ``); external (`https://…`) and protocol-relative (`//host`) targets are still flagged.
18
+ - Taint and secrets are skipped on minified/vendor bundles (`.min.js` and long-line content), matching the audit, and secret patterns are skipped in test fixtures that carry fake keys by design.
19
+
20
+ Gate green (build / lint / test / self-audit PASS / A / 0).
21
+
8
22
  ## [3.1.36] - 2026-06-07
9
23
 
10
24
  ### Added — high-value recall rules (436 → 438, 36 tools)
package/build/cli/scan.js CHANGED
@@ -76,7 +76,7 @@ export async function runDirectoryScan(targetPath, flags) {
76
76
  }
77
77
  export async function runDiffScan(base, flags) {
78
78
  const { execFileSync } = await import("child_process");
79
- const { analyzeCode } = await import("../tools/check-code.js");
79
+ const { analyzeFileSecurity } = await import("../tools/file-security.js");
80
80
  const { EXTENSION_MAP, CONFIG_FILE_MAP } = await import("../utils/constants.js");
81
81
  const format = validateFormat(flags);
82
82
  const outputFile = getOutputPath(flags);
@@ -109,7 +109,7 @@ export async function runDiffScan(base, flags) {
109
109
  continue;
110
110
  try {
111
111
  const content = readFileSync(fullPath, "utf-8");
112
- const findings = analyzeCode(content, language, undefined, fullPath, root);
112
+ const findings = analyzeFileSecurity(content, language, undefined, fullPath, root);
113
113
  for (const f of findings) {
114
114
  allFindings.push({ file: relPath, severity: f.rule.severity, name: f.rule.name, id: f.rule.id, line: f.line, fix: f.rule.fix });
115
115
  }
@@ -167,7 +167,8 @@ export async function runDiffScan(base, flags) {
167
167
  }
168
168
  }
169
169
  export async function runFileCheck(filePath, flags) {
170
- const { checkCode } = await import("../tools/check-code.js");
170
+ const { renderFindings } = await import("../tools/check-code.js");
171
+ const { analyzeFileSecurity } = await import("../tools/file-security.js");
171
172
  const resolved = resolve(filePath);
172
173
  if (!existsSync(resolved)) {
173
174
  console.error(` [ERR] File not found: ${resolved}`);
@@ -191,7 +192,8 @@ export async function runFileCheck(filePath, flags) {
191
192
  }
192
193
  const format = validateFormat(flags);
193
194
  const formatArg = format === "json" ? "json" : format === "buddy" ? "buddy" : "markdown";
194
- const result = checkCode(content, language, undefined, resolved, undefined, formatArg);
195
+ const findings = analyzeFileSecurity(content, language, undefined, resolved, undefined);
196
+ const result = renderFindings(findings, language, undefined, formatArg, resolved);
195
197
  const outputFile = getOutputPath(flags);
196
198
  if (outputFile) {
197
199
  safeWriteOutput(outputFile, result);
package/build/index.js CHANGED
@@ -3,7 +3,8 @@ import { createRequire } from "module";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
- import { checkCode, analyzeCode } from "./tools/check-code.js";
6
+ import { renderFindings } from "./tools/check-code.js";
7
+ import { analyzeFileSecurity } from "./tools/file-security.js";
7
8
  const require = createRequire(import.meta.url);
8
9
  const pkg = require("../package.json");
9
10
  import { checkProject } from "./tools/check-project.js";
@@ -75,8 +76,8 @@ server.tool("check_code", "Analyze inline code for security vulnerabilities (OWA
75
76
  format: z.enum(["markdown", "json"]).default("markdown").describe("Output format: markdown (human) or json (machine-readable for agents)"),
76
77
  }, async ({ code, language, framework, format }) => {
77
78
  const rules = getRules();
78
- const results = checkCode(code, language, framework, undefined, undefined, format, rules);
79
- const findings = analyzeCode(code, language, framework, undefined, undefined, rules);
79
+ const findings = analyzeFileSecurity(code, language, framework, undefined, undefined, rules);
80
+ const results = renderFindings(findings, language, framework, format, undefined);
80
81
  const cwd = process.cwd();
81
82
  recordScan(cwd, { toolName: "check_code", filesScanned: 1, findings: findings.map(f => ({ severity: f.rule.severity, ruleId: f.rule.id })) });
82
83
  const summary = getSummaryLine(cwd, findings.length, format);
@@ -507,8 +508,8 @@ server.tool("scan_file", "Scan a single file on disk by path for security vulner
507
508
  return { content: [{ type: "text", text: format === "json" ? JSON.stringify({ summary: { total: 0 }, findings: [] }) : "Unsupported file type." }] };
508
509
  }
509
510
  const rules = getRules();
510
- const result = checkCode(content, language, undefined, resolved, dirname(resolved), format, rules);
511
- const findings = analyzeCode(content, language, undefined, resolved, dirname(resolved), rules);
511
+ const findings = analyzeFileSecurity(content, language, undefined, resolved, dirname(resolved), rules);
512
+ const result = renderFindings(findings, language, undefined, format, resolved);
512
513
  const cwd = dirname(resolved);
513
514
  recordScan(cwd, { toolName: "scan_file", filesScanned: 1, findings: findings.map(f => ({ severity: f.rule.severity, ruleId: f.rule.id })) });
514
515
  const summary = getSummaryLine(cwd, findings.length, format);
@@ -569,7 +570,7 @@ server.tool("scan_changed_files", "Scan only files that have changed since a giv
569
570
  continue;
570
571
  try {
571
572
  const content = readFileSync(fullPath, "utf-8");
572
- const findings = analyzeCode(content, language, undefined, fullPath, root, rules);
573
+ const findings = analyzeFileSecurity(content, language, undefined, fullPath, root, rules);
573
574
  for (const f of findings) {
574
575
  allFindings.push({
575
576
  file: relPath, id: f.rule.id, name: f.rule.name,
@@ -14,3 +14,9 @@ export declare function isRuleDefinitionFile(code: string, filePath?: string): b
14
14
  export declare function analyzeCode(code: string, language: string, framework?: string, filePath?: string, configDir?: string, rules?: SecurityRule[]): Finding[];
15
15
  export declare function formatFindingsJson(findings: Finding[], extra?: Record<string, unknown>): string;
16
16
  export declare function checkCode(code: string, language: string, framework?: string, filePath?: string, configDir?: string, format?: "markdown" | "json" | "buddy", rules?: SecurityRule[]): string;
17
+ /**
18
+ * Render a pre-computed Finding[] into the requested output format. Split out of
19
+ * `checkCode` so the `check` path can run the combined analyzer (`analyzeFileSecurity`
20
+ * = regex + taint + secrets) and still reuse the exact same rendering.
21
+ */
22
+ export declare function renderFindings(findings: Finding[], language: string, framework?: string, format?: "markdown" | "json" | "buddy", filePath?: string): string;
@@ -1497,6 +1497,14 @@ export function formatFindingsJson(findings, extra) {
1497
1497
  }
1498
1498
  export function checkCode(code, language, framework, filePath, configDir, format = "markdown", rules) {
1499
1499
  const findings = analyzeCode(code, language, framework, filePath, configDir, rules);
1500
+ return renderFindings(findings, language, framework, format, filePath);
1501
+ }
1502
+ /**
1503
+ * Render a pre-computed Finding[] into the requested output format. Split out of
1504
+ * `checkCode` so the `check` path can run the combined analyzer (`analyzeFileSecurity`
1505
+ * = regex + taint + secrets) and still reuse the exact same rendering.
1506
+ */
1507
+ export function renderFindings(findings, language, framework, format = "markdown", filePath) {
1500
1508
  if (format === "json") {
1501
1509
  return formatFindingsJson(findings);
1502
1510
  }
@@ -0,0 +1,7 @@
1
+ import { type Finding } from "./check-code.js";
2
+ import type { SecurityRule } from "../data/rules/types.js";
3
+ /**
4
+ * Run regex rules + per-file taint analysis + secret patterns on a single file's
5
+ * content and return a merged, de-duplicated `Finding[]`.
6
+ */
7
+ export declare function analyzeFileSecurity(code: string, language: string, framework?: string, filePath?: string, configDir?: string, rules?: SecurityRule[]): Finding[];
@@ -0,0 +1,144 @@
1
+ // guardvibe-ignore — defines the combined per-file analyzer; references vulnerability
2
+ // category names (sql-injection, command-injection, etc.) as plain strings, not vulnerable code.
3
+ /**
4
+ * Combined per-file security analysis for the `check` path.
5
+ *
6
+ * `analyzeCode` (regex rules) alone runs on the `check`/`scan <file>`/`check_code`/
7
+ * `scan_file` and pre-commit paths, while taint analysis and secret-pattern scanning
8
+ * historically only ran inside `audit`. That left two-step variable-indirection flows
9
+ * (a query/path/command assembled into a variable before reaching the sink) and
10
+ * hardcoded secrets (e.g. PEM private keys in innocuously-named variables) invisible to
11
+ * the most-used commands and to the pre-commit hook.
12
+ *
13
+ * `analyzeFileSecurity` merges all three — regex + per-file taint + secret patterns —
14
+ * into one `Finding[]`, converting taint/secret hits into synthetic-rule findings so the
15
+ * existing rendering, scoring and JSON pipeline consumes them unchanged. Taint/secret
16
+ * findings that land on a line a regex rule already covers are dropped to avoid
17
+ * double-reporting.
18
+ *
19
+ * NOTE: this is intentionally NOT wired into the directory `scanDirectory` path, because
20
+ * `full-audit` already runs the code, secrets and taint sections separately — adding them
21
+ * to `scanDirectory` would double-count inside `audit`.
22
+ */
23
+ import { basename } from "path";
24
+ import { analyzeCode } from "./check-code.js";
25
+ import { analyzeTaint } from "./taint-analysis.js";
26
+ import { scanContent } from "./scan-secrets.js";
27
+ import { isExcludedFilename } from "../utils/constants.js";
28
+ const TAINT_OWASP = {
29
+ "sql-injection": "A03:2021 Injection",
30
+ "command-injection": "A03:2021 Injection",
31
+ "code-injection": "A03:2021 Injection",
32
+ "xss": "A03:2021 Injection",
33
+ "open-redirect": "A01:2021 Broken Access Control",
34
+ "path-traversal": "A01:2021 Broken Access Control",
35
+ };
36
+ // Regex VG rules that already represent the same vuln class as a taint sink type.
37
+ // When one fires on the exact sink line, the taint finding is redundant — drop it.
38
+ const TAINT_REGEX_OVERLAP = {
39
+ "sql-injection": new Set(["VG010", "VG013", "VG123", "VG543", "VG1002"]),
40
+ "command-injection": new Set(["VG011"]),
41
+ "code-injection": new Set(["VG014", "VG070"]),
42
+ "xss": new Set(["VG012", "VG408", "VG852", "VG1080", "VG1084"]),
43
+ "open-redirect": new Set(["VG101", "VG409", "VG425", "VG660"]),
44
+ "path-traversal": new Set(["VG102"]),
45
+ };
46
+ // Regex rules that already report a hardcoded secret; drop a secret-pattern hit on the
47
+ // same line as one of these to avoid double-reporting.
48
+ const SECRET_REGEX_OVERLAP = new Set(["VG001", "VG062", "VG003", "VG506"]);
49
+ // Mirrors analyzeCode's test-file skip for credential rules — fixtures legitimately
50
+ // embed fake keys (e.g. a test PEM for a crypto unit test). Real keys in production
51
+ // files (e.g. a hardcoded private key in lib/insecurity.ts) are still flagged.
52
+ const TEST_FILE_RE = /(?:\.(?:[\w-]+-)?(?:spec|test|e2e|stories|cy)\.(?:ts|tsx|js|jsx|mjs|cjs)$|_test\.go$|\/__tests__\/|\/__mocks__\/|\/tests?\/|\/cypress\/|\/playwright\/|\/dockertest\/|\/testutil\/|\/testhelpers?\/|\/testfixtures?\/)/i;
53
+ // Synthetic regex that never matches — synthetic rules are only ever attached to
54
+ // pre-computed findings, never run against source.
55
+ 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
+ function taintToFinding(t) {
77
+ const rule = {
78
+ id: `TAINT:${t.sink.type}`,
79
+ name: `Tainted flow: ${t.source.type} → ${t.sink.type}`,
80
+ severity: t.severity,
81
+ owasp: TAINT_OWASP[t.sink.type] ?? "A03:2021 Injection",
82
+ description: t.description,
83
+ pattern: NEVER_MATCH,
84
+ languages: ["javascript", "typescript"],
85
+ fix: t.fix,
86
+ };
87
+ return { rule, match: t.sink.code, line: t.sink.line, confidence: "medium" };
88
+ }
89
+ function secretToFinding(s) {
90
+ const rule = {
91
+ id: `SECRET:${s.provider}`,
92
+ name: `Hardcoded secret: ${s.provider}`,
93
+ severity: s.severity,
94
+ owasp: "A07:2021 Identification and Authentication Failures",
95
+ description: `Possible ${s.provider} found in source — move it to an environment variable.`,
96
+ pattern: NEVER_MATCH,
97
+ languages: [],
98
+ fix: s.fix,
99
+ };
100
+ return { rule, match: s.match, line: s.line, confidence: "high" };
101
+ }
102
+ /**
103
+ * Run regex rules + per-file taint analysis + secret patterns on a single file's
104
+ * content and return a merged, de-duplicated `Finding[]`.
105
+ */
106
+ export function analyzeFileSecurity(code, language, framework, filePath, configDir, rules) {
107
+ const regexFindings = analyzeCode(code, language, framework, filePath, configDir, rules);
108
+ const regexIdsByLine = new Map();
109
+ for (const f of regexFindings) {
110
+ const set = regexIdsByLine.get(f.line) ?? new Set();
111
+ set.add(f.rule.id);
112
+ regexIdsByLine.set(f.line, set);
113
+ }
114
+ // --- Per-file taint (JS/TS only; analyzeTaint no-ops for other languages) ---
115
+ const taintFindings = [];
116
+ const seenTaint = new Set();
117
+ const isVendorBundle = (filePath && isExcludedFilename(basename(filePath))) || looksMinified(code);
118
+ for (const t of (isVendorBundle ? [] : analyzeTaint(code, language, filePath))) {
119
+ const overlap = TAINT_REGEX_OVERLAP[t.sink.type];
120
+ const onLine = regexIdsByLine.get(t.sink.line);
121
+ if (overlap && onLine && [...onLine].some(id => overlap.has(id)))
122
+ continue;
123
+ const key = `${t.sink.type}:${t.sink.line}`;
124
+ if (seenTaint.has(key))
125
+ continue;
126
+ seenTaint.add(key);
127
+ taintFindings.push(taintToFinding(t));
128
+ }
129
+ // --- Secret patterns (skipped in test fixtures, which carry fake keys by design) ---
130
+ const secretFindings = [];
131
+ const isTestFile = filePath ? TEST_FILE_RE.test(filePath) : false;
132
+ const seenSecret = new Set();
133
+ for (const s of (isTestFile ? [] : scanContent(code, filePath ?? "inline"))) {
134
+ const onLine = regexIdsByLine.get(s.line);
135
+ if (onLine && [...onLine].some(id => SECRET_REGEX_OVERLAP.has(id)))
136
+ continue;
137
+ const key = `${s.provider}:${s.line}`;
138
+ if (seenSecret.has(key))
139
+ continue;
140
+ seenSecret.add(key);
141
+ secretFindings.push(secretToFinding(s));
142
+ }
143
+ return [...regexFindings, ...taintFindings, ...secretFindings];
144
+ }
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import { extname, basename } from "path";
3
- import { analyzeCode, formatFindingsJson } from "./check-code.js";
3
+ import { formatFindingsJson } from "./check-code.js";
4
+ import { analyzeFileSecurity } from "./file-security.js";
4
5
  import { securityBanner } from "../utils/banner.js";
5
6
  const EXTENSION_MAP = {
6
7
  ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
@@ -79,7 +80,7 @@ export function scanStaged(cwd = process.cwd(), format = "markdown", rules) {
79
80
  skippedFiles.push(filePath);
80
81
  continue;
81
82
  }
82
- const findings = analyzeCode(content, language, undefined, filePath, cwd, rules);
83
+ const findings = analyzeFileSecurity(content, language, undefined, filePath, cwd, rules);
83
84
  if (findings.length > 0) {
84
85
  results.push({ path: filePath, findings });
85
86
  }
@@ -39,6 +39,12 @@ const TAINT_SINKS = [
39
39
  { pattern: /new\s+Function\s*\(/g, type: "code-injection", severity: "critical",
40
40
  description: "User input flows into Function constructor, enabling arbitrary code execution.",
41
41
  fix: "Never construct functions from user input. Use a safe evaluator or predefined functions." },
42
+ // Command injection: bare child_process exec()/execSync() (the shell-invoking forms).
43
+ // The negative lookbehind excludes method calls like `regex.exec(...)`, `query.exec()`,
44
+ // and `db.execSync(...)` — only the imported, shell-spawning function is a sink.
45
+ { pattern: /(?<!\.)\bexec(?:Sync)?\s*\(/g, type: "command-injection", severity: "critical",
46
+ description: "User input flows into a shell command (exec/execSync), enabling OS command injection.",
47
+ fix: "Use execFile()/spawn() with an argument array (no shell) and validate input against an allowlist." },
42
48
  { pattern: /writeFileSync?\s*\(/g, type: "path-traversal", severity: "high",
43
49
  description: "User input flows into file write path, enabling arbitrary file overwrite.",
44
50
  fix: "Validate and sanitize file paths. Use path.resolve() and verify the result is within allowed directories." },
@@ -78,6 +84,17 @@ function isSafeParameterizedSqlSink(lines, sinkIdx) {
78
84
  const interps = tpl.match(/\$\{[^}]*\}/g) || [];
79
85
  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));
80
86
  }
87
+ /**
88
+ * A `redirect(...)` whose target is a root-relative, same-origin path
89
+ * (e.g. redirect("/login") or redirect(`/${slug}/settings`)) cannot be an open
90
+ * redirect — the browser stays on the current origin. Only external URLs
91
+ * (`https://…`), protocol-relative URLs (`//host`), or non-literal targets are
92
+ * candidates. This kills the dominant open-redirect FP class on Next.js pages,
93
+ * which routinely build internal navigation paths from route params/searchParams.
94
+ */
95
+ function isSameOriginRedirect(line) {
96
+ return /\bredirect\s*\(\s*["'`]\/(?!\/)/.test(line);
97
+ }
81
98
  function extractAssignments(lines) {
82
99
  const assignments = [];
83
100
  const assignPattern = /(?:const|let|var)\s+([\w]+)\s*=\s*(.*)/;
@@ -152,6 +169,8 @@ export function analyzeTaint(code, language, filePath) {
152
169
  continue;
153
170
  if (sink.type === "sql-injection" && isSafeParameterizedSqlSink(lines, i))
154
171
  continue;
172
+ if (sink.type === "open-redirect" && isSameOriginRedirect(line))
173
+ continue;
155
174
  for (const tVar of taintedVars) {
156
175
  if (line.includes(tVar.name)) {
157
176
  const chain = [];
@@ -183,6 +202,8 @@ export function analyzeTaint(code, language, filePath) {
183
202
  continue;
184
203
  if (sink.type === "sql-injection" && isSafeParameterizedSqlSink(lines, i))
185
204
  continue;
205
+ if (sink.type === "open-redirect" && isSameOriginRedirect(line))
206
+ continue;
186
207
  for (const source of TAINT_SOURCES) {
187
208
  source.pattern.lastIndex = 0;
188
209
  if (source.pattern.test(line)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.1.36",
3
+ "version": "3.1.37",
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",