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 +25 -0
- package/build/cli/scan.js +6 -4
- package/build/data/rules/advanced-security.js +1 -1
- package/build/data/rules/database.js +4 -4
- package/build/index.js +7 -6
- package/build/tools/check-code.d.ts +6 -0
- package/build/tools/check-code.js +37 -1
- package/build/tools/file-security.d.ts +7 -0
- package/build/tools/file-security.js +144 -0
- package/build/tools/scan-staged.js +3 -2
- package/build/tools/taint-analysis.js +21 -0
- package/package.json +1 -1
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 {
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
44
|
+
name: "Drizzle sql.raw() Injection",
|
|
45
45
|
severity: "critical",
|
|
46
46
|
owasp: "A03:2025 Injection",
|
|
47
|
-
description: "Drizzle
|
|
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: "
|
|
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 {
|
|
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
|
|
79
|
-
const
|
|
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
|
|
511
|
-
const
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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.
|
|
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",
|