guardvibe 3.1.32 → 3.1.34
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 +31 -0
- package/README.md +5 -5
- package/build/data/rules/api-security.js +1 -1
- package/build/data/rules/core.js +9 -9
- package/build/data/rules/nextjs.js +1 -1
- package/build/data/rules/web-security.js +36 -0
- package/build/tools/check-code.js +152 -1
- package/build/tools/taint-analysis.js +23 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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.34] - 2026-06-07
|
|
9
|
+
|
|
10
|
+
### Added — recall (false-negative) improvements (433 → 436 rules, 36 tools)
|
|
11
|
+
Surfaced by a recall battery of canonical vulnerable snippets; each gap was reproduced, fixed, and given positive + negative tests. A ReDoS regression guard for all rule patterns (`tests/meta/redos.test.ts`) was added and caught a polynomial backtrack in one of these very changes before release.
|
|
12
|
+
- **VG010** now catches the most common concat-SQLi style where the SQL string embeds a quote to wrap the value (`"... name = '" + name + "'"`), and Sequelize `literal()` raw fragments.
|
|
13
|
+
- **VG014** extended to `vm.runInNewContext`/`runInContext`/`runInThisContext`/`compileFunction` and `new vm.Script`.
|
|
14
|
+
- **VG070** extended to `unserialize()` (node-serialize), funcster, cryo.
|
|
15
|
+
- **VG103** extended to user-controlled bracket assignment (`obj[req.body.key] = …`) and lodash `_.set`/`objectPath.set`.
|
|
16
|
+
- **VG102** extended to `res.sendFile`/`res.download` path traversal.
|
|
17
|
+
- **VG409** extended to open redirect via `res.setHeader("Location", userInput)`.
|
|
18
|
+
- **VG1080** (new) DOM XSS via `document.write()`/`writeln()` with user input.
|
|
19
|
+
- **VG1081** (new) insecure block cipher mode — AES/DES ECB and the deprecated `crypto.createCipher`.
|
|
20
|
+
- **VG1082** (new) server-side template injection — `Handlebars.compile`/`ejs.render`/`pug`/`nunjucks`/lodash `_.template` on user-controlled template source.
|
|
21
|
+
|
|
22
|
+
Self-audit PASS / A / 0, gate green, determinism preserved across the corpus.
|
|
23
|
+
|
|
24
|
+
## [3.1.33] - 2026-06-07
|
|
25
|
+
|
|
26
|
+
### Fixed — false-positive precision (no rule-count change, stays 433 / 36)
|
|
27
|
+
Surfaced by an end-to-end accuracy sweep across the labeled fixture set and the real-world corpus; each change has positive + negative tests and was cross-checked against an uncapped before/after diff (removal-only, zero real findings lost).
|
|
28
|
+
- **Engine:** multi-line `/* */` block comments are now stripped before matching (string-aware, scoped to C-style languages) so rules no longer fire on commented-out code; YAML/Python/shell/Dockerfile (which use `#`) are unaffected.
|
|
29
|
+
- **VG060** no longer flags MD5/SHA-1 used for file/build-artifact checksums (keeps real password-hashing).
|
|
30
|
+
- **VG1002** only flags query operators whose value is attacker-controlled (a static `{ $ne: true }` literal is skipped; `$where` built from a variable/concat/interpolation still fires).
|
|
31
|
+
- **VG123 / VG010 / taint** skip queries that are parameterized (`bind`/`replacements`/`$1`/`:name`) and whose only interpolation is a hash/encode helper.
|
|
32
|
+
- **VG951** recognizes ownership fields (`author`, `email`, `accountId`, …) in the where-clause.
|
|
33
|
+
- **VG138** ignores confirm-password (`password === cpassword`) and emptiness checks.
|
|
34
|
+
- **VG001** ignores UI/error-message string variables; **VG148**/**VG424** skip test (`.spec`) files.
|
|
35
|
+
- **VG013** renamed to "ORM/NoSQL query injection risk" with stack-aware remediation (Sequelize/TypeORM operator injection, not Mongo-only).
|
|
36
|
+
|
|
37
|
+
Tests 1820 → 1848. Self-audit PASS / A / 0. Determinism unchanged across the corpus.
|
|
38
|
+
|
|
8
39
|
## [3.1.32] - 2026-06-06
|
|
9
40
|
|
|
10
41
|
### Added — 4 new CVE rules (429 → 433), sourced via `npm run intel`
|
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.** 436 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
|
+
- **436 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 | 436 (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 (436 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.34`) 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.
|
|
@@ -20,7 +20,7 @@ export const apiSecurityRules = [
|
|
|
20
20
|
severity: "critical",
|
|
21
21
|
owasp: "API1:2023 Broken Object Level Authorization",
|
|
22
22
|
description: "Delete or update operation uses user-supplied ID without verifying resource ownership. Any authenticated user can modify or delete other users' resources.",
|
|
23
|
-
pattern: /(?:delete|update|destroy|remove)\s*\(\s*\{?\s*(?:where\s*:\s*\{)?\s*(?:id|_id)\s*:\s*(?:req\.(?:params|query|body)|params\.|args\.|input\.)(?:(?!userId|user_id|ownerId|owner_id|createdBy|created_by)[\s\S]){0,200}?\}/gi,
|
|
23
|
+
pattern: /(?:delete|update|destroy|remove)\s*\(\s*\{?\s*(?:where\s*:\s*\{)?\s*(?:id|_id)\s*:\s*(?:req\.(?:params|query|body)|params\.|args\.|input\.)(?:(?!userId|user_id|ownerId|owner_id|createdBy|created_by|author|authorId|author_id|email|userEmail|accountId|account_id|tenantId|tenant_id|orgId|org_id|organizationId)[\s\S]){0,200}?\}/gi,
|
|
24
24
|
languages: ["javascript", "typescript"],
|
|
25
25
|
fix: "Include the authenticated user's ID in the where clause to prevent unauthorized modifications.",
|
|
26
26
|
fixCode: '// Scope mutations to the authenticated user\nconst { userId } = await auth();\nawait prisma.post.delete({\n where: { id: params.id, userId }, // ownership!\n});',
|
package/build/data/rules/core.js
CHANGED
|
@@ -55,7 +55,7 @@ export const coreRules = [
|
|
|
55
55
|
severity: "critical",
|
|
56
56
|
owasp: "A02:2025 Injection",
|
|
57
57
|
description: "String concatenation, template literals, or f-strings used in SQL queries — whether inline in the DB call or assembled in a variable/return first. This allows SQL injection attacks.",
|
|
58
|
-
pattern: /(?:query|execute|raw|sql|all|run|get|exec|prepare|QueryRow|QueryContext)\s*\(\s*(?:`[^`]*\$\{|[
|
|
58
|
+
pattern: /(?:query|execute|raw|sql|all|run|get|exec|prepare|literal|QueryRow|QueryContext)\s*\(\s*(?:`[^`]*\$\{|(?:"[^"]*"|'[^']*')\s*\+\s*|f"[^"]*\{|f'[^']*\{|['"][^'"]*['"]\s*%\s*|['"][^'"]*['"]\s*\.format\s*\(|['"][^'"]*['"]\s*,\s*(?:req\.|request\.|params\.|body\.|args))|(?:=|return)\s*(?:`\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^`]*\b(?:FROM|INTO|SET|WHERE|VALUES)\b[^`]*\$\{|['"]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^\n]*?\b(?:FROM|INTO|SET|WHERE|VALUES)\b[^\n]*?['"]\s*\+\s*\w)/gi,
|
|
59
59
|
languages: ["javascript", "typescript", "python", "go"],
|
|
60
60
|
fix: "Use parameterized queries: db.query('SELECT * FROM users WHERE id = $1', [userId]). Python: cursor.execute('SELECT * FROM users WHERE id = %s', (user_id,)). Never concatenate user input into SQL strings.",
|
|
61
61
|
fixCode: "// Use parameterized queries\ndb.query('SELECT * FROM users WHERE id = $1', [userId]);\n// Python: cursor.execute('SELECT * FROM users WHERE id = %s', (user_id,))",
|
|
@@ -100,14 +100,14 @@ export const coreRules = [
|
|
|
100
100
|
},
|
|
101
101
|
{
|
|
102
102
|
id: "VG013",
|
|
103
|
-
name: "NoSQL injection risk",
|
|
103
|
+
name: "ORM/NoSQL query injection risk",
|
|
104
104
|
severity: "high",
|
|
105
105
|
owasp: "A02:2025 Injection",
|
|
106
|
-
description: "User input passed directly
|
|
106
|
+
description: "User input passed directly into an ORM/NoSQL query filter object — a MongoDB/Mongoose .find()/.findOne() or a SQL-ORM where clause (Sequelize .find({where}), TypeORM). When the value is an object instead of a scalar, an attacker can inject query operators (MongoDB $ne/$gt/$where, or Sequelize string-operator aliases like $gt/$ne in v4) to bypass authentication or filters. Express parses req.query via qs into nested objects, so query-param values reach the filter as objects unless coerced.",
|
|
107
107
|
pattern: /(?:find|findOne|updateOne|deleteOne|aggregate)\s*\(\s*\{[^}]*(?:req\.|request\.|body\.|params\.)/gi,
|
|
108
108
|
languages: ["javascript", "typescript"],
|
|
109
|
-
fix: "
|
|
110
|
-
fixCode: "//
|
|
109
|
+
fix: "Coerce filter values to scalars before querying and reject objects where strings are expected: const id = typeof req.params.id === 'string' ? req.params.id : ''. For Mongoose use schema validation; for Sequelize wrap values (String(req.query.id)) and never spread raw req objects into a where clause.",
|
|
110
|
+
fixCode: "// Coerce to a scalar before using in any ORM/NoSQL query filter\nconst id = typeof req.params.id === 'string' ? req.params.id : '';\n// Mongoose: await collection.findOne({ _id: new ObjectId(id) });\n// Sequelize: await Model.findOne({ where: { id: String(req.query.id) } });",
|
|
111
111
|
compliance: ["SOC2:CC7.1", "PCI-DSS:Req6.5.1"],
|
|
112
112
|
},
|
|
113
113
|
{
|
|
@@ -116,7 +116,7 @@ export const coreRules = [
|
|
|
116
116
|
severity: "critical",
|
|
117
117
|
owasp: "A02:2025 Injection",
|
|
118
118
|
description: "Dynamic code execution function detected. This can run arbitrary code and is a major security risk.",
|
|
119
|
-
pattern: /(?:\beval\s*\(|new\s+Function\s*\()/gi,
|
|
119
|
+
pattern: /(?:\beval\s*\(|new\s+Function\s*\(|\bvm\s*\.\s*(?:runInNewContext|runInContext|runInThisContext|compileFunction)\s*\(|new\s+vm\s*\.\s*Script\s*\()/gi,
|
|
120
120
|
languages: ["javascript", "typescript", "python"],
|
|
121
121
|
fix: "Avoid dynamic code execution. Use JSON.parse() for JSON data. Use a sandboxed environment if absolutely required.",
|
|
122
122
|
fixCode: "// Use JSON.parse for data\nconst data = JSON.parse(input);\n// Alternatives: use a proper parser for your data format\n// const fn = new " + "Function('x', 'return x * 2'); // only if absolutely needed",
|
|
@@ -224,7 +224,7 @@ export const coreRules = [
|
|
|
224
224
|
severity: "high",
|
|
225
225
|
owasp: "A08:2025 Data Integrity Failures",
|
|
226
226
|
description: "Deserializing untrusted data can lead to remote code execution.",
|
|
227
|
-
pattern: /(?:JSON\.parse\s*\(\s*(?:req\.|request\.|body)|pickle\.loads?\s*\(|yaml\.(?:load|unsafe_load)\s*\()/gi,
|
|
227
|
+
pattern: /(?:JSON\.parse\s*\(\s*(?:req\.|request\.|body)|pickle\.loads?\s*\(|yaml\.(?:load|unsafe_load)\s*\(|\bunserialize\s*\(|\bfuncster\s*\.|\bcryo\s*\.\s*parse\s*\()/gi,
|
|
228
228
|
languages: ["javascript", "typescript", "python"],
|
|
229
229
|
fix: "Validate all deserialized data with a schema (zod, joi) before processing.",
|
|
230
230
|
fixCode: "// Validate with schema after parsing\nimport { z } from 'zod';\nconst schema = z.object({ name: z.string() });\nconst data = schema.parse(JSON.parse(req.body));",
|
|
@@ -284,7 +284,7 @@ export const coreRules = [
|
|
|
284
284
|
severity: "high",
|
|
285
285
|
owasp: "A01:2025 Broken Access Control",
|
|
286
286
|
description: "User input used in file paths without sanitization.",
|
|
287
|
-
pattern: /(?:readFile|readFileSync|createReadStream|open|path\.join|path\.resolve)\s*\([^)]*(?:req\.|request\.|params\.|body\.|query\.)/gi,
|
|
287
|
+
pattern: /(?:readFile|readFileSync|createReadStream|open|sendFile|download|path\.join|path\.resolve)\s*\([^)]*(?:req\.|request\.|params\.|body\.|query\.)/gi,
|
|
288
288
|
languages: ["javascript", "typescript", "python", "go"],
|
|
289
289
|
fix: "Sanitize file paths: remove ../ sequences, verify the result is within the expected directory.",
|
|
290
290
|
fixCode: "import path from 'path';\nconst safePath = path.resolve('/uploads', filename);\nif (!safePath.startsWith('/uploads/')) throw new Error('Invalid path');",
|
|
@@ -296,7 +296,7 @@ export const coreRules = [
|
|
|
296
296
|
severity: "high",
|
|
297
297
|
owasp: "A02:2025 Injection",
|
|
298
298
|
description: "Deep merge or object assignment from user input can lead to prototype pollution.",
|
|
299
|
-
pattern: /(?:Object\.assign|\bmerge\b|deepMerge|\bextend\b)\s*\([^)]*(?:req\.|request\.|\bbody\b|\bparams\b)/gi,
|
|
299
|
+
pattern: /(?:(?:Object\.assign|\bmerge\b|deepMerge|\bextend\b|(?:_|lodash)\.set(?:With)?|objectPath\.set|dotProp\.set)\s*\([^)]*(?:req\.|request\.|\bbody\b|\bparams\b)|\[\s*(?:req|request)\.(?:body|params|query)\.[\w.]+\s*\]\s*=(?!=))/gi,
|
|
300
300
|
languages: ["javascript", "typescript"],
|
|
301
301
|
fix: "Use Object.create(null) for lookup objects. Validate that keys don't include __proto__, constructor, or prototype.",
|
|
302
302
|
fixCode: "// Use Object.create(null) for lookups\nconst lookup = Object.create(null);\n// Validate keys\nconst forbidden = ['__proto__', 'constructor', 'prototype'];\nif (forbidden.includes(key)) throw new Error('Invalid key');",
|
|
@@ -117,7 +117,7 @@ export const nextjsRules = [
|
|
|
117
117
|
severity: "medium",
|
|
118
118
|
owasp: "A01:2025 Broken Access Control",
|
|
119
119
|
description: "redirect() or NextResponse.redirect() uses user-controlled input (searchParams, query) which can redirect users to malicious sites.",
|
|
120
|
-
pattern: /(?:redirect|NextResponse\.redirect|res\.redirect|Response\.redirect)\s*\(\s*(?:searchParams|params|req\.query|request\.url|url|query|returnTo|callbackUrl|next|goto|returnUrl|redirectUrl|destination)\b/gi,
|
|
120
|
+
pattern: /(?:(?:redirect|NextResponse\.redirect|res\.redirect|Response\.redirect)\s*\(\s*(?:searchParams|params|req\.query|request\.url|url|query|returnTo|callbackUrl|next|goto|returnUrl|redirectUrl|destination)\b|(?:res|reply)\.setHeader\s*\(\s*['"][Ll]ocation['"]\s*,\s*(?:req\.|request\.|searchParams|params\b|url\b|query\b|returnTo|callbackUrl|returnUrl|redirectUrl|destination))/gi,
|
|
121
121
|
languages: ["javascript", "typescript"],
|
|
122
122
|
fix: "Validate redirect URLs against an allowlist of trusted domains.",
|
|
123
123
|
fixCode: '// Validate redirect URL\nconst ALLOWED_HOSTS = ["example.com"];\nconst target = searchParams.get("next") ?? "/";\ntry {\n const url = new URL(target, request.url);\n if (!ALLOWED_HOSTS.includes(url.hostname)) redirect("/");\n redirect(url.pathname);\n} catch {\n redirect("/");\n}',
|
|
@@ -185,4 +185,40 @@ export const webSecurityRules = [
|
|
|
185
185
|
fixCode: '// Set nosniff header for uploaded file responses\nres.setHeader("X-Content-Type-Options", "nosniff");\nres.setHeader("Content-Disposition", "attachment"); // force download for unknown types\nres.sendFile(filePath);',
|
|
186
186
|
compliance: ["SOC2:CC6.1"],
|
|
187
187
|
},
|
|
188
|
+
{
|
|
189
|
+
id: "VG1080",
|
|
190
|
+
name: "DOM XSS via document.write()",
|
|
191
|
+
severity: "high",
|
|
192
|
+
owasp: "A03:2025 Injection",
|
|
193
|
+
description: "document.write()/document.writeln() called with user-controlled or concatenated/interpolated content. document.write parses its argument as HTML, so attacker-influenced input (location, query params, cookies, window.name) leads to DOM-based cross-site scripting.",
|
|
194
|
+
pattern: /document\.write(?:ln)?\s*\(\s*(?:[^)]*?(?:location|document\.(?:URL|cookie|referrer)|searchParams|req\.|request\.|params\.|query\.|window\.name|\binput\b)|`[^`]*\$\{|["'][^"']*["']\s*\+)/gi,
|
|
195
|
+
languages: ["javascript", "typescript"],
|
|
196
|
+
fix: "Never build HTML with document.write from untrusted input. Use safe DOM APIs (textContent, createElement) or sanitize with DOMPurify before inserting.",
|
|
197
|
+
fixCode: "// BAD: document.write('<div>' + location.hash + '</div>')\n// GOOD:\nconst el = document.createElement('div');\nel.textContent = userValue; // auto-escaped\ncontainer.appendChild(el);",
|
|
198
|
+
compliance: ["SOC2:CC7.1", "PCI-DSS:Req6.5.7"],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "VG1081",
|
|
202
|
+
name: "Insecure Block Cipher Mode (ECB / deprecated createCipher)",
|
|
203
|
+
severity: "high",
|
|
204
|
+
owasp: "A02:2025 Cryptographic Failures",
|
|
205
|
+
description: "AES/DES used in ECB mode (createCipheriv with an *-ecb algorithm), or the deprecated crypto.createCipher() which derives a key/IV insecurely. ECB encrypts identical plaintext blocks to identical ciphertext blocks, leaking structure; createCipher is password-derived and IV-less. Both are cryptographically broken for confidentiality.",
|
|
206
|
+
pattern: /(?:createCipheriv\s*\(\s*["'][^"']*-ecb["']|createDecipheriv\s*\(\s*["'][^"']*-ecb["']|crypto\s*\.\s*createCipher\s*\(\s*["'])/gi,
|
|
207
|
+
languages: ["javascript", "typescript"],
|
|
208
|
+
fix: "Use an authenticated mode: aes-256-gcm with a random 12-byte IV per message (crypto.randomBytes), or aes-256-cbc with a random IV and a separate MAC. Never use ECB; replace createCipher with createCipheriv.",
|
|
209
|
+
fixCode: "// GOOD: AES-256-GCM with a random IV\nconst iv = crypto.randomBytes(12);\nconst cipher = crypto.createCipheriv('aes-256-gcm', key, iv);\nconst enc = Buffer.concat([cipher.update(data), cipher.final()]);\nconst tag = cipher.getAuthTag();",
|
|
210
|
+
compliance: ["SOC2:CC6.1", "PCI-DSS:Req3.5", "HIPAA:§164.312(a)(2)(iv)"],
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: "VG1082",
|
|
214
|
+
name: "Server-Side Template Injection (SSTI)",
|
|
215
|
+
severity: "critical",
|
|
216
|
+
owasp: "A03:2025 Injection",
|
|
217
|
+
description: "A template engine compiles/renders a user-controlled template SOURCE (not just user data bound into a fixed template). Handlebars.compile, ejs.render/compile, pug, nunjucks.renderString, or lodash _.template on attacker-influenced input allows server-side template injection — often a path to remote code execution.",
|
|
218
|
+
pattern: /(?:Handlebars\.compile|ejs\.(?:render|compile)|pug\.(?:compile|render)|nunjucks\.(?:renderString|compile)|_\.template|lodash\.template|dot\.template)\s*\(\s*(?:[^,)]*?(?:req\.|request\.|\bbody\b|\bparams\b|\bquery\b|userInput|\binput\b)|`[^`]*\$\{|[^,)]*\+)/gi,
|
|
219
|
+
languages: ["javascript", "typescript"],
|
|
220
|
+
fix: "Never compile a template from user input. Keep template sources static/server-owned and pass user values only as DATA to a precompiled template. If user-authored templates are required, use a sandboxed engine with no access to globals.",
|
|
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
|
+
compliance: ["SOC2:CC7.1", "PCI-DSS:Req6.5.1"],
|
|
223
|
+
},
|
|
188
224
|
];
|
|
@@ -70,6 +70,77 @@ function isInComment(lines, lineNumber) {
|
|
|
70
70
|
trimmed.startsWith("<!--") ||
|
|
71
71
|
trimmed.startsWith("/*"));
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Compute the set of 1-based line numbers that fall inside a multi-line block
|
|
75
|
+
* comment (slash-star ... star-slash). `isInComment` only catches lines whose
|
|
76
|
+
* trimmed start is a comment marker, so a line like ` res.cookie(...)` sitting
|
|
77
|
+
* INSIDE a commented-out block (common in teaching repos that keep "Fix for X"
|
|
78
|
+
* demos inline) was scanned as live code — a false-positive class for VG100,
|
|
79
|
+
* VG042 and any other non-CVE rule. This is a string-aware lexer pass (skips
|
|
80
|
+
* markers that appear inside ' " ` strings and after a // line comment) so URLs
|
|
81
|
+
* (`http://`), division, and regex-ish literals don't spuriously open a block.
|
|
82
|
+
*/
|
|
83
|
+
function computeBlockCommentLines(code) {
|
|
84
|
+
const inBlock = new Set();
|
|
85
|
+
let line = 1;
|
|
86
|
+
let state = "code";
|
|
87
|
+
for (let i = 0; i < code.length; i++) {
|
|
88
|
+
const c = code[i];
|
|
89
|
+
const c2 = i + 1 < code.length ? code[i + 1] : "";
|
|
90
|
+
if (c === "\n") {
|
|
91
|
+
line++;
|
|
92
|
+
if (state === "line")
|
|
93
|
+
state = "code";
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
switch (state) {
|
|
97
|
+
case "code":
|
|
98
|
+
if (c === "/" && c2 === "/") {
|
|
99
|
+
state = "line";
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
else if (c === "/" && c2 === "*") {
|
|
103
|
+
state = "block";
|
|
104
|
+
inBlock.add(line);
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
else if (c === "'")
|
|
108
|
+
state = "sq";
|
|
109
|
+
else if (c === '"')
|
|
110
|
+
state = "dq";
|
|
111
|
+
else if (c === "`")
|
|
112
|
+
state = "tpl";
|
|
113
|
+
break;
|
|
114
|
+
case "block":
|
|
115
|
+
inBlock.add(line);
|
|
116
|
+
if (c === "*" && c2 === "/") {
|
|
117
|
+
state = "code";
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
case "sq":
|
|
122
|
+
if (c === "\\")
|
|
123
|
+
i++;
|
|
124
|
+
else if (c === "'")
|
|
125
|
+
state = "code";
|
|
126
|
+
break;
|
|
127
|
+
case "dq":
|
|
128
|
+
if (c === "\\")
|
|
129
|
+
i++;
|
|
130
|
+
else if (c === '"')
|
|
131
|
+
state = "code";
|
|
132
|
+
break;
|
|
133
|
+
case "tpl":
|
|
134
|
+
if (c === "\\")
|
|
135
|
+
i++;
|
|
136
|
+
else if (c === "`")
|
|
137
|
+
state = "code";
|
|
138
|
+
break;
|
|
139
|
+
// "line" state is exited at the newline handler above
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return inBlock;
|
|
143
|
+
}
|
|
73
144
|
/**
|
|
74
145
|
* Check if a match is inside a multi-line string literal (template literal,
|
|
75
146
|
* fixCode/description property, or string concatenation).
|
|
@@ -187,6 +258,7 @@ function hasAuthGuardPattern(code) {
|
|
|
187
258
|
}
|
|
188
259
|
// Pattern 3: function called with await that contains auth-like keywords in name
|
|
189
260
|
// Broad catch: any function name containing auth/session/permission/guard/verify/protect
|
|
261
|
+
// guardvibe-ignore VG153 — dotted-identifier path matcher; each `\w+\.` segment is dot-anchored, so backtracking is linear, not catastrophic
|
|
190
262
|
if (/await\s+(?:\w+\.)*\w*(?:auth|Auth|session|Session|permission|Permission|guard|Guard|verify|Verify|protect|Protect|check|Check|ensure|Ensure|require|Require|assert|Assert|authorize|Authorize)\w*\s*\(/i.test(code)) {
|
|
191
263
|
return true;
|
|
192
264
|
}
|
|
@@ -221,6 +293,7 @@ function hasRoleCheckPattern(code) {
|
|
|
221
293
|
/(?:requireAdmin|requireRole|checkAdmin|isAdmin|verifyAdmin|assertAdmin)\s*\(/i.test(code))
|
|
222
294
|
return true;
|
|
223
295
|
// await requireAdmin() with error check pattern (naming-agnostic admin guard)
|
|
296
|
+
// guardvibe-ignore VG153 — dotted-identifier path matcher; dot-anchored segments make backtracking linear
|
|
224
297
|
if (/await\s+(?:\w+\.)*\w*(?:Admin|admin)\w*\s*\([^)]*\)\s*;?\s*\n\s*if\s*\(/i.test(code))
|
|
225
298
|
return true;
|
|
226
299
|
return false;
|
|
@@ -346,6 +419,14 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
346
419
|
if (customPattern.test(code))
|
|
347
420
|
codeHasAuthGuard = true;
|
|
348
421
|
}
|
|
422
|
+
// Line numbers inside multi-line /* */ block comments — computed once per file
|
|
423
|
+
// (string-aware) so the per-match comment skip can drop matches on commented-out
|
|
424
|
+
// code whose own line doesn't start with a comment marker. Gated to languages that
|
|
425
|
+
// actually use C-style /* */ comments — YAML/Python/shell/Dockerfile/TOML use #, so
|
|
426
|
+
// a `/*` there (e.g. a `# .../health/*` path glob in a k8s manifest) is NOT a comment
|
|
427
|
+
// opener and must not suppress real findings.
|
|
428
|
+
const usesCStyleBlockComments = language === "javascript" || language === "typescript" || language === "go";
|
|
429
|
+
const blockCommentLines = usesCStyleBlockComments && code.includes("/*") ? computeBlockCommentLines(code) : null;
|
|
349
430
|
const effectiveRules = rules ?? owaspRules;
|
|
350
431
|
for (const rule of effectiveRules) {
|
|
351
432
|
if (!rule.languages.includes(language))
|
|
@@ -370,7 +451,7 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
370
451
|
// agent.get('/?q=' + sqlPayload) which match the regex but aren't database calls
|
|
371
452
|
// - VG042/VG678: HTTP-response/security-header rules (tests don't serve to real users)
|
|
372
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);
|
|
373
|
-
if (isTestFile && ["VG001", "VG003", "VG062", "VG010", "VG011", "VG012", "VG013", "VG014", "VG042", "VG100", "VG130", "VG678", "VG955", "VG133", "VG1021", "VG409"].includes(rule.id))
|
|
454
|
+
if (isTestFile && ["VG001", "VG003", "VG062", "VG010", "VG011", "VG012", "VG013", "VG014", "VG042", "VG100", "VG130", "VG678", "VG955", "VG133", "VG1021", "VG409", "VG148", "VG424"].includes(rule.id))
|
|
374
455
|
continue;
|
|
375
456
|
// VG955 (Missing Pagination on List Endpoint): only fire on actual request-handling
|
|
376
457
|
// surfaces — API routes, App Router `route.{ts,tsx}`, pages/api, or Server Actions.
|
|
@@ -792,6 +873,10 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
792
873
|
const isMultiLineMatch = match[0].includes("\n");
|
|
793
874
|
if (!isMultiLineMatch && isInComment(lines, lineNumber))
|
|
794
875
|
continue;
|
|
876
|
+
// Single-line match sitting inside a /* ... */ block comment (its own line
|
|
877
|
+
// may not start with a comment marker) — commented-out dead code, skip.
|
|
878
|
+
if (!isMultiLineMatch && blockCommentLines?.has(lineNumber))
|
|
879
|
+
continue;
|
|
795
880
|
if (isInsideStringLiteral(lines, lineNumber, code, match.index))
|
|
796
881
|
continue;
|
|
797
882
|
}
|
|
@@ -853,6 +938,71 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
853
938
|
// e.g. `INVALID_PASSWORD = "5020"` — error code, not a credential.
|
|
854
939
|
if (/\b[A-Z][A-Z0-9_]*\s*=\s*["']\d+["']/.test(matchedLine))
|
|
855
940
|
continue;
|
|
941
|
+
// Skip UI/error message string variables: `invalidPasswordErrorMessage = "Invalid password"`.
|
|
942
|
+
// The identifier signals a user-facing message/label/error and the value is a prose phrase
|
|
943
|
+
// (letters + at least one space), not a credential. isHumanReadableString needs 4+ words;
|
|
944
|
+
// this catches shorter 2-3 word phrases when the name is clearly a message.
|
|
945
|
+
const msgPair = matchedLine.match(/\b([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*["']([^"']{3,})["']/);
|
|
946
|
+
if (msgPair
|
|
947
|
+
&& /(?:message|msg|error|\berr\b|label|title|hint|text|placeholder|description|tooltip|notice|warning|caption|heading|prompt|copy)/i.test(msgPair[1])
|
|
948
|
+
&& /^[A-Za-z][A-Za-z .,!?'’()-]*\s[A-Za-z .,!?'’()-]+$/.test(msgPair[2]))
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
// VG138 (Plaintext Password Comparison): skip benign non-credential comparisons.
|
|
952
|
+
// (1) Confirm-password match: `req.body.password == req.body.cpassword` compares two
|
|
953
|
+
// user inputs from the same form, not a submission against a stored secret.
|
|
954
|
+
// (2) Emptiness/presence check: `password === ''` validates that a field was provided.
|
|
955
|
+
if (rule.id === "VG138") {
|
|
956
|
+
const matchedLine = lines[lineNumber - 1] ?? "";
|
|
957
|
+
if (/(?:cpassword|confirm[_]?password|password[_]?confirm(?:ation)?|password2|repeat[_]?password|retype[_]?password|verify[_]?password)/i.test(matchedLine))
|
|
958
|
+
continue;
|
|
959
|
+
if (/(?:password|passwd|pwd)\s*(?:===|!==|==|!=)\s*(['"])\1/i.test(matchedLine))
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
// VG1002 (MongoDB NoSQL Injection via Query Operators): a query operator only enables
|
|
963
|
+
// injection when its value is attacker-controlled. Skip ONLY when the operator's value is
|
|
964
|
+
// a pure literal (`{ $ne: true }`, `{ $gt: 5 }`, `{ $regex: "^a" }`) — a static internal
|
|
965
|
+
// filter. A value built from a variable, concatenation, or template interpolation
|
|
966
|
+
// (`$where: 'this.x == ' + id`, `$where: `...${id}``) is a real injection vector — keep it.
|
|
967
|
+
if (rule.id === "VG1002") {
|
|
968
|
+
const after = code.slice(match.index + match[0].length, match.index + match[0].length + 80);
|
|
969
|
+
const staticLiteral = /^\s*:\s*(?:true|false|null|-?\d+(?:\.\d+)?|'[^'`$+]*'|"[^"`$+]*")\s*[},\]]/.test(after);
|
|
970
|
+
if (staticLiteral)
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
// VG060 (Weak password hashing): MD5/SHA-1 have legitimate non-credential uses — file/
|
|
974
|
+
// build-artifact checksums, ETags, cache keys, content integrity. Skip when the context
|
|
975
|
+
// is clearly a checksum/digest-of-bytes (or a build-tool config) and not a password.
|
|
976
|
+
if (rule.id === "VG060") {
|
|
977
|
+
const isBuildConfig = filePath ? /(?:^|\/)(?:Gruntfile|gulpfile|webpack\.config|rollup\.config|vite\.config|esbuild|metro\.config)\.[cm]?[jt]s$/i.test(filePath) : false;
|
|
978
|
+
const start = Math.max(0, lineNumber - 5);
|
|
979
|
+
const window = lines.slice(start, lineNumber + 4).join("\n");
|
|
980
|
+
// NB: do NOT treat `.update(data)` / `.update(content)` as a checksum signal — `data`
|
|
981
|
+
// is too generic and `hash(data)` is exactly how weak password hashing looks. Require a
|
|
982
|
+
// file/byte-buffer or explicit checksum marker instead.
|
|
983
|
+
const looksLikeChecksum = /(?:readFileSync|createReadStream|\bBuffer\b|\.update\s*\(\s*(?:buffer|buf|fileBuffer)|fs\.read|\.md5\b|checksum|etag|integrity|cacheKey|cache[_-]?key|contentHash|fileHash|subresource)/i.test(window);
|
|
984
|
+
const looksLikePassword = /(?:password|passwd|\bpwd\b|credential|user\.pass|loginPass)/i.test(window);
|
|
985
|
+
if ((isBuildConfig || looksLikeChecksum) && !looksLikePassword)
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
// VG123 (SQL Injection via Template Literal) + VG010 (SQL injection): skip when the query
|
|
989
|
+
// is parameterized (sequelize bind/replacements or $1/:name placeholders) AND every ${...}
|
|
990
|
+
// interpolation is a safe transform (hash/encode/escape/number) — not raw user input. e.g.
|
|
991
|
+
// `query(`... email = $1 ... password = '${security.hash(req.body.password)}'`, { bind: [..] })`.
|
|
992
|
+
// VG010 is included because the VG010↔VG123 dedup makes VG010 take over the same line once
|
|
993
|
+
// VG123 is suppressed — without this the FP is just relabeled, not removed.
|
|
994
|
+
if (rule.id === "VG123" || rule.id === "VG010") {
|
|
995
|
+
const tplStart = code.indexOf("`", match.index);
|
|
996
|
+
if (tplStart !== -1) {
|
|
997
|
+
const tplEnd = code.indexOf("`", tplStart + 1);
|
|
998
|
+
const tpl = tplEnd !== -1 ? code.slice(tplStart + 1, tplEnd) : "";
|
|
999
|
+
const callCtx = code.slice(match.index, (tplEnd !== -1 ? tplEnd : match.index) + 200);
|
|
1000
|
+
const isParameterized = /\b(?:bind|replacements)\s*:/.test(callCtx) || /[=\s](?:\$\d+|:[a-zA-Z_]\w*)\b/.test(tpl);
|
|
1001
|
+
const interps = tpl.match(/\$\{[^}]*\}/g) || [];
|
|
1002
|
+
const allSafe = interps.length > 0 && 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));
|
|
1003
|
+
if (isParameterized && allSafe)
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
856
1006
|
}
|
|
857
1007
|
// VG106 (Timing-Unsafe Secret Comparison): skip when one operand is a React useRef
|
|
858
1008
|
// pattern (`*Ref.current`). Refs hold local component state, not user-provided input,
|
|
@@ -884,6 +1034,7 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
884
1034
|
// - `z.enum(filterConfig.field.operators)` (TS `as const` config object) — when
|
|
885
1035
|
// the file has any `as const` cast, treat nested property access as static
|
|
886
1036
|
const matchedLine = lines[lineNumber - 1] ?? "";
|
|
1037
|
+
// guardvibe-ignore VG153 — dotted-identifier path matcher; dot-anchored segments make backtracking linear
|
|
887
1038
|
if (/z\.enum\s*\(\s*[\w$]+(?:\.[\w$]+)+/.test(matchedLine)) {
|
|
888
1039
|
if (/\.enumValues\b/.test(matchedLine))
|
|
889
1040
|
continue;
|
|
@@ -59,6 +59,25 @@ const SANITIZERS = [
|
|
|
59
59
|
/sanitizeHtml\s*\(/,
|
|
60
60
|
/xss\s*\(/,
|
|
61
61
|
];
|
|
62
|
+
/**
|
|
63
|
+
* A SQL sink is NOT injectable when the query is parameterized (sequelize
|
|
64
|
+
* bind/replacements, or $1 / :name placeholders) AND every ${...} interpolation
|
|
65
|
+
* in the template is a safe transform (hash/encode/escape/number) rather than raw
|
|
66
|
+
* user input. e.g. sequelize.query(`... email = $1 ... password = '${security.hash(pw)}'`,
|
|
67
|
+
* { bind: [req.body.email] }) — the only interpolation is a fixed-charset hash, and
|
|
68
|
+
* the user value is bound. Without this, the inline-source loop reports req.body.*
|
|
69
|
+
* appearing inside the hash() call as a SQLi flow (false positive).
|
|
70
|
+
*/
|
|
71
|
+
function isSafeParameterizedSqlSink(lines, sinkIdx) {
|
|
72
|
+
const ctx = lines.slice(sinkIdx, sinkIdx + 4).join("\n");
|
|
73
|
+
const parameterized = /\b(?:bind|replacements)\s*:/.test(ctx) || /[=\s](?:\$\d+|:[a-zA-Z_]\w*)\b/.test(ctx);
|
|
74
|
+
if (!parameterized)
|
|
75
|
+
return false;
|
|
76
|
+
const sinkLine = lines[sinkIdx] ?? "";
|
|
77
|
+
const tpl = (sinkLine.match(/`[^`]*`/) || [""])[0];
|
|
78
|
+
const interps = tpl.match(/\$\{[^}]*\}/g) || [];
|
|
79
|
+
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
|
+
}
|
|
62
81
|
function extractAssignments(lines) {
|
|
63
82
|
const assignments = [];
|
|
64
83
|
const assignPattern = /(?:const|let|var)\s+([\w]+)\s*=\s*(.*)/;
|
|
@@ -131,6 +150,8 @@ export function analyzeTaint(code, language, filePath) {
|
|
|
131
150
|
sink.pattern.lastIndex = 0;
|
|
132
151
|
if (!sink.pattern.test(line))
|
|
133
152
|
continue;
|
|
153
|
+
if (sink.type === "sql-injection" && isSafeParameterizedSqlSink(lines, i))
|
|
154
|
+
continue;
|
|
134
155
|
for (const tVar of taintedVars) {
|
|
135
156
|
if (line.includes(tVar.name)) {
|
|
136
157
|
const chain = [];
|
|
@@ -160,6 +181,8 @@ export function analyzeTaint(code, language, filePath) {
|
|
|
160
181
|
sink.pattern.lastIndex = 0;
|
|
161
182
|
if (!sink.pattern.test(line))
|
|
162
183
|
continue;
|
|
184
|
+
if (sink.type === "sql-injection" && isSafeParameterizedSqlSink(lines, i))
|
|
185
|
+
continue;
|
|
163
186
|
for (const source of TAINT_SOURCES) {
|
|
164
187
|
source.pattern.lastIndex = 0;
|
|
165
188
|
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.34",
|
|
4
4
|
"mcpName": "io.github.goklab/guardvibe",
|
|
5
|
-
"description": "Security MCP for vibe coding.
|
|
5
|
+
"description": "Security MCP for vibe coding. 436 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",
|