guardvibe 3.1.35 → 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 +22 -0
- package/README.md +5 -5
- package/build/cli/scan.js +6 -4
- package/build/data/rules/web-security.js +24 -0
- package/build/index.js +7 -6
- package/build/tools/check-code.d.ts +6 -0
- package/build/tools/check-code.js +13 -0
- 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 +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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
|
+
|
|
22
|
+
## [3.1.36] - 2026-06-07
|
|
23
|
+
|
|
24
|
+
### Added — high-value recall rules (436 → 438, 36 tools)
|
|
25
|
+
- **VG1083** JWT verification bypass — flags `jwt.decode()` of a request-supplied token used without a real signature check, and `jwt.verify(..., { algorithms: ['none'] })` (algorithm-confusion / signature stripping). The decode branch is suppressed when the same file also verifies the token (decode-then-verify is legitimate).
|
|
26
|
+
- **VG1084** DOM XSS via jQuery HTML insertion — `.html()/.append()/.prepend()/.after()/.before()/.replaceWith()` with user-controlled or concatenated/interpolated content (skips `.text()` and static literals).
|
|
27
|
+
|
|
28
|
+
Both validated against the real-world corpus: zero false positives (the one borderline juice-shop `jwt.decode`-then-`verify` hit is correctly suppressed). The ReDoS guard now re-measures any over-budget pattern and uses the minimum across runs, so CPU/GC load spikes can no longer cause a flaky failure while genuine backtracking (consistently slow) is still caught.
|
|
29
|
+
|
|
8
30
|
## [3.1.35] - 2026-06-07
|
|
9
31
|
|
|
10
32
|
### Fixed — false-positive precision on real production apps (no rule-count change, 436 / 36)
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/guardvibe)
|
|
7
7
|
[](https://codecov.io/gh/goklab/guardvibe)
|
|
8
8
|
|
|
9
|
-
**The security MCP built for vibe coding.**
|
|
9
|
+
**The security MCP built for vibe coding.** 438 security rules, 36 tools covering the entire AI-generated code journey — from first line to production deployment.
|
|
10
10
|
|
|
11
11
|
Works with **Claude Code, Cursor, Gemini CLI, Codex, VS Code (Copilot), Windsurf**, and any MCP-compatible coding agent.
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ Works with **Claude Code, Cursor, Gemini CLI, Codex, VS Code (Copilot), Windsurf
|
|
|
14
14
|
|
|
15
15
|
Most security tools are built for enterprise security teams. GuardVibe is built for **you** — the developer using AI to build and ship web apps fast.
|
|
16
16
|
|
|
17
|
-
- **
|
|
17
|
+
- **438 security rules, 36 tools** purpose-built for the stacks AI agents generate
|
|
18
18
|
- **Zero setup friction** — `npx guardvibe` and you're scanning
|
|
19
19
|
- **No account required** — runs 100% locally, no API keys, no cloud
|
|
20
20
|
- **Understands your stack** — not generic SAST, but rules that know Next.js, Supabase, Stripe, Clerk, and the tools you actually use
|
|
@@ -52,7 +52,7 @@ GuardVibe is purpose-built for the AI coding workflow. Traditional tools are exc
|
|
|
52
52
|
| CVE version detection | 67 packages, refreshed daily | Extensive | Extensive |
|
|
53
53
|
| Compliance mapping (SOC2, PCI-DSS, HIPAA) | Built-in | Paid tier | None |
|
|
54
54
|
| SARIF CI/CD export | Yes | Yes | Limited |
|
|
55
|
-
| Rule count |
|
|
55
|
+
| Rule count | 438 (focused, 68 AI-native) | 5000+ (broad) | N/A |
|
|
56
56
|
|
|
57
57
|
**When to use GuardVibe:** You're building with AI agents and want security scanning integrated into your coding workflow — no dashboard, no account, no CI setup.
|
|
58
58
|
|
|
@@ -242,7 +242,7 @@ Malicious postinstall scripts, unpinned GitHub Actions, CI `npm` provenance / `-
|
|
|
242
242
|
|
|
243
243
|
All scanning tools support `format: "json"` for machine-readable output.
|
|
244
244
|
|
|
245
|
-
## Security Rules (
|
|
245
|
+
## Security Rules (438 rules across 25 modules)
|
|
246
246
|
|
|
247
247
|
| Category | Rules | Coverage |
|
|
248
248
|
|----------|-------|----------|
|
|
@@ -457,7 +457,7 @@ If your AI agent cannot connect to GuardVibe:
|
|
|
457
457
|
|
|
458
458
|
1. **Restart your IDE/agent.** MCP servers are started by the host application. After running `npx guardvibe init`, restart Claude Code, Cursor, or Gemini CLI for the config to take effect.
|
|
459
459
|
2. **Check the config path.** Run `npx guardvibe init claude` again and verify the output shows the correct config file location (`.mcp.json` in your project root for Claude Code, `.cursor/mcp.json` for Cursor).
|
|
460
|
-
3. **Re-run `init` to upgrade.** When upgrading GuardVibe, re-run `npx guardvibe init claude` — `.mcp.json` is pinned to a specific version (e.g. `guardvibe@3.1.
|
|
460
|
+
3. **Re-run `init` to upgrade.** When upgrading GuardVibe, re-run `npx guardvibe init claude` — `.mcp.json` is pinned to a specific version (e.g. `guardvibe@3.1.36`) at init time for fast deterministic startup. As of v3.1.2 the re-run also rewrites stale pins automatically (`Upgraded GuardVibe pin (3.1.27 → 3.1.28)`); since v3.1.27 the PostToolUse hook command is pinned to the same version (was `@latest`) and re-run upgrades a stale hook too. The same applies to `npx guardvibe hook install` and `npx guardvibe ci github` (since v3.1.3) — both are version-pinned at install/generate time and re-run to upgrade.
|
|
461
461
|
4. **Pre-3.1.1 users won't see the auto-update banner.** GuardVibe started writing a once-per-day "newer version available" notice to stderr in v3.1.1. If your install predates that, you'll never see it — run `npx -y guardvibe@latest init <host>` once to bake in the latest pin and start receiving banners on subsequent sessions.
|
|
462
462
|
5. **Verify Node.js version.** GuardVibe requires Node.js >= 18.0.0. Check with `node --version`.
|
|
463
463
|
6. **Check npx cache.** If you upgraded GuardVibe and the old version is cached, run `npx -y guardvibe@latest` to force the latest version.
|
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);
|
|
@@ -221,4 +221,28 @@ export const webSecurityRules = [
|
|
|
221
221
|
fixCode: "// BAD: ejs.render(req.body.template, data)\n// GOOD: fixed template, user value as data only\nconst tpl = ejs.compile(STATIC_TEMPLATE);\nres.send(tpl({ name: req.body.name }));",
|
|
222
222
|
compliance: ["SOC2:CC7.1", "PCI-DSS:Req6.5.1"],
|
|
223
223
|
},
|
|
224
|
+
{
|
|
225
|
+
id: "VG1083",
|
|
226
|
+
name: "JWT Verification Bypass (decode/none-algorithm)",
|
|
227
|
+
severity: "critical",
|
|
228
|
+
owasp: "A07:2025 Auth Failures",
|
|
229
|
+
description: "A JWT is trusted without a real signature check: jwt.decode() of a request-supplied token returns the payload WITHOUT verifying the signature (any forged token is accepted), or jwt.verify() is called with algorithms including 'none' (algorithm-confusion / signature-stripping). Either lets an attacker mint arbitrary identities/claims.",
|
|
230
|
+
pattern: /(?:jwt\.verify\s*\([^;]{0,200}?algorithms\s*:\s*\[[^\]]*["']none["']|(?:jwt|jsonwebtoken|jose)\s*\.\s*decode\s*\(\s*(?:req\.|request\.|token\b|authToken|bearerToken|accessToken|authorization\b|headers\b))/gi,
|
|
231
|
+
languages: ["javascript", "typescript"],
|
|
232
|
+
fix: "Always verify the signature with an explicit algorithm allowlist: jwt.verify(token, secret, { algorithms: ['HS256'] }). Never use jwt.decode() for authentication/authorization, and never include 'none' in the algorithms list.",
|
|
233
|
+
fixCode: "// BAD: const user = jwt.decode(req.headers.authorization);\n// GOOD:\nconst user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });",
|
|
234
|
+
compliance: ["SOC2:CC6.6", "PCI-DSS:Req6.5.10", "HIPAA:§164.312(d)"],
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: "VG1084",
|
|
238
|
+
name: "DOM XSS via jQuery HTML insertion",
|
|
239
|
+
severity: "high",
|
|
240
|
+
owasp: "A03:2025 Injection",
|
|
241
|
+
description: "jQuery DOM-insertion methods (.html(), .append(), .prepend(), .after(), .before(), .replaceWith(), .wrap*) parse their argument as HTML. Passing user-controlled or concatenated/interpolated content (location, query params, .val(), .data()) into them causes DOM-based cross-site scripting.",
|
|
242
|
+
pattern: /\$\([^)]*\)(?:\.\w+\([^)]*\))*?\.(?:html|append|prepend|after|before|replaceWith|wrap|wrapAll|wrapInner)\s*\(\s*(?:[^)]*?(?:location|document\.(?:URL|cookie|referrer)|searchParams|req\.|request\.|params\.|query\.|window\.name|\.val\s*\(\s*\)|\.data\s*\()|`[^`]*\$\{|["'][^"']*["']\s*\+)/gi,
|
|
243
|
+
languages: ["javascript", "typescript"],
|
|
244
|
+
fix: "Use .text() instead of .html() for untrusted content, or sanitize with DOMPurify before insertion. Build elements with $('<div>').text(value) rather than concatenating HTML strings.",
|
|
245
|
+
fixCode: "// BAD: $('#out').html(location.hash)\n// GOOD:\n$('#out').text(userValue); // auto-escaped\n// or: $('#out').html(DOMPurify.sanitize(html));",
|
|
246
|
+
compliance: ["SOC2:CC7.1", "PCI-DSS:Req6.5.7"],
|
|
247
|
+
},
|
|
224
248
|
];
|
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;
|
|
@@ -958,6 +958,11 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
958
958
|
&& /^[A-Za-z][A-Za-z .,!?'’()-]*\s[A-Za-z .,!?'’()-]+$/.test(msgPair[2]))
|
|
959
959
|
continue;
|
|
960
960
|
}
|
|
961
|
+
// VG1083 (JWT verification bypass): jwt.decode() is fine when used only to peek at a
|
|
962
|
+
// token that is ALSO verified (decode-then-verify). Skip the decode branch when a real
|
|
963
|
+
// signature verification exists in the file. (The none-algorithm branch always fires.)
|
|
964
|
+
if (rule.id === "VG1083" && /\.decode\s*\(/.test(match[0]) && /jwt\.verify\s*\(|jwtVerify\s*\(|jose[\s\S]{0,60}?(?:jwtVerify|verify)/i.test(code))
|
|
965
|
+
continue;
|
|
961
966
|
// VG138 (Plaintext Password Comparison): skip benign non-credential comparisons.
|
|
962
967
|
// (1) Confirm-password match: `req.body.password == req.body.cpassword` compares two
|
|
963
968
|
// user inputs from the same form, not a submission against a stored secret.
|
|
@@ -1492,6 +1497,14 @@ export function formatFindingsJson(findings, extra) {
|
|
|
1492
1497
|
}
|
|
1493
1498
|
export function checkCode(code, language, framework, filePath, configDir, format = "markdown", rules) {
|
|
1494
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) {
|
|
1495
1508
|
if (format === "json") {
|
|
1496
1509
|
return formatFindingsJson(findings);
|
|
1497
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 {
|
|
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,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardvibe",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.37",
|
|
4
4
|
"mcpName": "io.github.goklab/guardvibe",
|
|
5
|
-
"description": "Security MCP for vibe coding.
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"guardvibe": "build/cli.js",
|