guardvibe 3.1.36 → 3.1.38

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,31 @@ 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.38] - 2026-06-07
9
+
10
+ ### Fixed — false-positive precision, verified one rule at a time (no rule-count change, 438 / 36)
11
+ Each claimed false positive was checked against the actual code in the real-world corpus; only genuine FP classes were narrowed, with an old-vs-new diff confirming zero true-positive loss (14 FPs removed corpus-wide, 0 TP lost).
12
+ - **VG434** (Drizzle) retargeted from the *safe* `sql\`${value}\`` tag (which parameterizes interpolations) to the real injection vector — `sql.raw()` interpolated into an executed query. Renamed to "Drizzle sql.raw() Injection".
13
+ - **VG514** (Docker Compose secret) no longer fires when the value is an env-var reference (`${VAR}` / `$VAR`); hardcoded literals still fire.
14
+ - **VG001** (hardcoded credentials) skips kebab-case slug values under an uppercase-led enum/constant name (e.g. `UserMissingPassword = "missing-password"`) and values explicitly marked as mock/placeholder (e.g. `"MOCK_DAILY_API_KEY"`).
15
+ - **VG139** (TLS verification disabled) is skipped in test files and no longer matches a `skipVerify = false` substring; real `rejectUnauthorized: false` in production code still fires (the prior "FP" claim was wrong — those are real vulnerabilities).
16
+
17
+ Gate green (build / lint / test / self-audit PASS / A / 0).
18
+
19
+ ## [3.1.37] - 2026-06-07
20
+
21
+ ### Added — taint + secret scanning on the `check` path (no rule-count change, 438 / 36)
22
+ 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:
23
+ - **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.
24
+ - **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.
25
+ - **Hardcoded secrets** — PEM private keys, cloud keys and tokens are flagged on the check path even when the variable name is innocuous.
26
+
27
+ ### Fixed — taint precision (improves both `check` and `audit`)
28
+ - **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.
29
+ - 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.
30
+
31
+ Gate green (build / lint / test / self-audit PASS / A / 0).
32
+
8
33
  ## [3.1.36] - 2026-06-07
9
34
 
10
35
  ### 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);
@@ -127,7 +127,7 @@ export const advancedSecurityRules = [
127
127
  severity: "critical",
128
128
  owasp: "A02:2025 Cryptographic Failures",
129
129
  description: "TLS certificate verification is disabled, allowing man-in-the-middle attacks. All HTTPS connections become insecure.",
130
- pattern: /(?:NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*["']0["']|rejectUnauthorized\s*:\s*false|verify\s*=\s*False|InsecureSkipVerify\s*:\s*true)/gi,
130
+ pattern: /(?:NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*["']0["']|rejectUnauthorized\s*:\s*false|(?<![\w$])verify\s*=\s*False|InsecureSkipVerify\s*:\s*true)/gi,
131
131
  languages: ["javascript", "typescript", "python", "go"],
132
132
  fix: "Never disable TLS verification in production. Fix certificate issues instead.",
133
133
  fixCode: '// BAD: disables all TLS verification\n// process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";\n// const agent = new https.Agent({ rejectUnauthorized: false });\n\n// GOOD: fix the certificate issue\n// - Use valid certificates (Let\'s Encrypt)\n// - Add CA certificate to Node: --use-openssl-ca\n// - For self-signed dev certs: only disable in NODE_ENV=development',
@@ -41,13 +41,13 @@ export const databaseRules = [
41
41
  },
42
42
  {
43
43
  id: "VG434",
44
- name: "Drizzle Unsafe SQL Interpolation",
44
+ name: "Drizzle sql.raw() Injection",
45
45
  severity: "critical",
46
46
  owasp: "A03:2025 Injection",
47
- description: "Drizzle sql tagged template with direct variable interpolation. Use sql.placeholder() for safe parameterization.",
48
- pattern: /(?:db\.execute|db\.run|db\.get|db\.all)\s*\(\s*sql`[^`]*\$\{/g,
47
+ description: "sql.raw() interpolated into an executed Drizzle query bypasses parameter binding, enabling SQL injection. Drizzle's sql`${value}` tag binds interpolations safely — only sql.raw() escapes that.",
48
+ pattern: /(?:db\.execute|db\.run|db\.get|db\.all)\s*\(\s*sql`[^`]*\$\{\s*sql\.raw\b/g,
49
49
  languages: ["javascript", "typescript"],
50
- fix: "Use sql.placeholder() for dynamic values in Drizzle queries.",
50
+ fix: "Avoid sql.raw() with dynamic input. Use bound ${value} interpolation, or restrict raw fragments to a hardcoded allowlist of column/table identifiers.",
51
51
  fixCode: 'import { sql } from "drizzle-orm";\n\nconst result = await db.execute(\n sql`SELECT * FROM users WHERE id = ${sql.placeholder("id")}`,\n { id: userId }\n);',
52
52
  compliance: ["SOC2:CC7.1", "PCI-DSS:Req6.5.1"],
53
53
  },
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;
@@ -451,7 +451,7 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
451
451
  // agent.get('/?q=' + sqlPayload) which match the regex but aren't database calls
452
452
  // - VG042/VG678: HTTP-response/security-header rules (tests don't serve to real users)
453
453
  const isTestFile = filePath && /(?:\.(?:[\w-]+-)?(?:spec|test|e2e|stories|cy)\.(?:ts|tsx|js|jsx|mjs|cjs)$|_test\.go$|\/__tests__\/|\/__mocks__\/|\/tests?\/|\/cypress\/|\/playwright\/|\/dockertest\/|\/testutil\/|\/testhelpers?\/|\/testfixtures?\/)/i.test(filePath);
454
- if (isTestFile && ["VG001", "VG003", "VG062", "VG010", "VG011", "VG012", "VG013", "VG014", "VG042", "VG100", "VG130", "VG678", "VG955", "VG133", "VG1021", "VG409", "VG148", "VG424", "VG137"].includes(rule.id))
454
+ if (isTestFile && ["VG001", "VG003", "VG062", "VG010", "VG011", "VG012", "VG013", "VG014", "VG042", "VG100", "VG130", "VG678", "VG955", "VG133", "VG1021", "VG409", "VG148", "VG424", "VG137", "VG139"].includes(rule.id))
455
455
  continue;
456
456
  // VG137 (Debug Endpoint Exposes System Information) also misfires on build/test config
457
457
  // files: a `<rootDir>/test/` mapper or a `/test` path string near `process.env` in
@@ -944,6 +944,24 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
944
944
  if (canonical(idValuePair[1]) === canonical(idValuePair[2]))
945
945
  continue;
946
946
  }
947
+ // Skip enum/constant members whose value is a kebab-case slug (lowercase words
948
+ // joined by hyphens, no digits) under an uppercase-led name — error codes like
949
+ // `UserMissingPassword = "missing-password"`. The uppercase-name requirement keeps a
950
+ // lowercase `password = "my-secret"` firing (only enum/const members get the pass).
951
+ // The value is captured with a flat (ReDoS-safe) char class, then validated by
952
+ // splitting on '-' rather than a nested-quantifier regex.
953
+ const slugPair = matchedLine.match(/\b([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*["']([a-z][a-z-]*)["']/);
954
+ if (slugPair && /^[A-Z]/.test(slugPair[1])) {
955
+ const parts = slugPair[2].split("-");
956
+ const isKebabSlug = parts.length >= 2 && parts.every(p => p.length > 0 && /^[a-z]+$/.test(p));
957
+ if (isKebabSlug)
958
+ continue;
959
+ }
960
+ // Skip values explicitly marked as mock/placeholder — `DAILY_API_KEY = "MOCK_DAILY_API_KEY"`,
961
+ // `apiKey: "your-api-key-here"`. A value literally named as a placeholder is not a secret.
962
+ const valuePair = matchedLine.match(/[:=]\s*["']([^"'\n]{3,})["']/);
963
+ if (valuePair && /^(?:mock|example|sample|demo|fake|dummy|stub|placeholder|changeme|change-me|your[-_]|xxx|todo|replace[-_]?me)/i.test(valuePair[1]))
964
+ continue;
947
965
  // Skip SCREAMING_SNAKE error/status codes whose value is digits-only.
948
966
  // e.g. `INVALID_PASSWORD = "5020"` — error code, not a credential.
949
967
  if (/\b[A-Z][A-Z0-9_]*\s*=\s*["']\d+["']/.test(matchedLine))
@@ -958,6 +976,16 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
958
976
  && /^[A-Za-z][A-Za-z .,!?'’()-]*\s[A-Za-z .,!?'’()-]+$/.test(msgPair[2]))
959
977
  continue;
960
978
  }
979
+ // VG514 (Docker Compose Hardcoded Secret): the match spans the `environment:` block,
980
+ // so the flagged secret VALUE is the last `KEY: value` in match[0]. When that value is
981
+ // an env-var reference (`${VAR}` / `$VAR`) it is NOT hardcoded — the canonical secure
982
+ // pattern (compose reads it from .env). Hardcoded literals (`POSTGRES_PASSWORD=magical`)
983
+ // still fire.
984
+ if (rule.id === "VG514") {
985
+ const secretVal = match[0].match(/(?:SECRET|PASSWORD|TOKEN|KEY|CREDENTIAL)\w*\s*[=:]\s*["']?([^"'\s]+)/i);
986
+ if (secretVal && /^\$\{?\w+\}?$/.test(secretVal[1]))
987
+ continue;
988
+ }
961
989
  // VG1083 (JWT verification bypass): jwt.decode() is fine when used only to peek at a
962
990
  // token that is ALSO verified (decode-then-verify). Skip the decode branch when a real
963
991
  // signature verification exists in the file. (The none-algorithm branch always fires.)
@@ -1497,6 +1525,14 @@ export function formatFindingsJson(findings, extra) {
1497
1525
  }
1498
1526
  export function checkCode(code, language, framework, filePath, configDir, format = "markdown", rules) {
1499
1527
  const findings = analyzeCode(code, language, framework, filePath, configDir, rules);
1528
+ return renderFindings(findings, language, framework, format, filePath);
1529
+ }
1530
+ /**
1531
+ * Render a pre-computed Finding[] into the requested output format. Split out of
1532
+ * `checkCode` so the `check` path can run the combined analyzer (`analyzeFileSecurity`
1533
+ * = regex + taint + secrets) and still reuse the exact same rendering.
1534
+ */
1535
+ export function renderFindings(findings, language, framework, format = "markdown", filePath) {
1500
1536
  if (format === "json") {
1501
1537
  return formatFindingsJson(findings);
1502
1538
  }
@@ -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.38",
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",