guardvibe 3.17.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,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.19.0] - 2026-06-10
9
+
10
+ ### Added — secure_prompt: prompt-level security, shift left (442 rules / 37 → 38 tools)
11
+ - **New MCP tool `secure_prompt`** — analyzes a raw coding prompt BEFORE any code is written and returns a structured enhancement directive (`guardvibe.secure_prompt.v1`) the host LLM uses to rewrite the prompt with GuardVibe security requirements embedded. Fully deterministic: no LLM calls, no network, no API keys — same prompt = same directive.
12
+ - **Triage-first, "do no harm":** verdict `NO_MOD` (prompt already specific and security-aware, or touches no security surface → host proceeds with the ORIGINAL prompt unchanged), `LIGHT_MOD` (clear intent, missing security constraints → inject requirements only), `HEAVY_MOD` (vague AND security-relevant → requirements + up to 3 clarifying questions, never invented answers). Scoring heuristics (concrete nouns, security vocabulary, length/imperative specificity, sensitive surfaces) with thresholds in an exported `TRIAGE_CONFIG` constant.
13
+ - **Stack + attack-surface detection** from keyword/alias maps (Next.js, Supabase, Clerk, Stripe, Prisma, Express, Hono, Drizzle, Firebase, MongoDB, tRPC, FastAPI, Django...; auth, payments, file upload, user input, database/SQL, secrets, external APIs, deserialization, redirects), including surfaces implied by detected technologies. Optional `context` input merges client-known stack info. Token matching is boundary-checked `indexOf` — no dynamic RegExp (keeps the self-audit and ReDoS meta-test clean).
14
+ - **Rule matching over the existing 442-rule set** by name/description keywords for the detected stack + surfaces, severity-ranked (critical → info), near-duplicate guidance deduped, capped at the top 8; each requirement carries `[rule-id]`, title, severity, and the rule's fix phrased as an instruction. CVE version-pin rules excluded (they gate package pins, not prompts).
15
+ - Directive output: verdict + one-line reason, intent summary stated as a HARD CONSTRAINT, numbered security requirements, ambiguities (HEAVY_MOD only), explicit rewrite directive ("Do NOT add features the user did not request. Do NOT change the user's intent."), and the original prompt echoed verbatim (fence-safe even when the prompt contains code blocks).
16
+ - New module `src/tools/secure-prompt.ts`; 24 tests in `tests/tools/secure-prompt.test.ts` (NO_MOD short-circuit, LIGHT vs HEAVY classification, 7-framework stack detection, rule cap + severity ordering, empty/garbage input, determinism). README gains a "Prompt-Level Security (Shift Left)" section with a before/after example. Zero new runtime dependencies.
17
+
18
+ Gate green (build / lint / test / self-audit PASS / A / 0).
19
+
20
+ ## [3.18.0] - 2026-06-09
21
+
22
+ ### Added — FAZ 3 part c: AST BOLA mutation-guard detection for VG951 (442 rules / 37 tools)
23
+ - **VG951 (BOLA — delete/update without ownership) is now AST-aware for the `find → compare → mutate` pattern.** The rule's regex already excludes an ownership field inside the mutation's WHERE clause; its only blind spot was a bare-id mutation preceded by a separate ownership check. `bolaMutationGuarded` (AST) suppresses VG951 when the enclosing function performs a **post-fetch ownership comparison** of the fetched resource against the session (e.g. `const s = await prisma.schedule.findUnique({ where: { id } }); if (s?.userId !== user.id) throw; await prisma.schedule.delete({ where: { id } })`). Anything without that comparison keeps firing.
24
+ - Reuses the part-1/2 AST engine — factored a shared `callNearLine` anchor finder and `hasPostFetchOwnershipGuard` (the case-2 ownership-comparison check) out of `bolaOwnershipGuarded`, then anchored it on the mutation call instead of the find call.
25
+ - **Validated (clean stash diff): VG951 11 → 9; both removed are genuinely ownership-guarded** — cal `delete.handler` (`findUnique select userId → if (scheduleToDelete?.userId !== user.id) throw UNAUTHORIZED → delete`) and cal `ScheduleService.update` (`findUnique → if (userSchedule?.userId !== user.id) require team edit-permission else throw → update`). **0 true positives lost, 0 false positives added, 0 other-rule drift.** 5 new mutation-guard tests (10 BOLA tests total).
26
+ - No rule or tool changes (442 / 37). FAZ 3 part c.
27
+
28
+ Gate green (build / lint / test / self-audit PASS / A / 0).
29
+
8
30
  ## [3.17.0] - 2026-06-09
9
31
 
10
32
  ### Added — FAZ 3 part 3: AST constant-origin detection for VG126 (442 rules / 37 tools)
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  - **🗺️ Sees the whole repo.** Cross-file taint + auth-coverage across every route — catches the unprotected endpoint your agent's narrow context missed.
15
15
  - **🔍 An independent second pair of eyes.** The thing that wrote the code can't review itself. GuardVibe is the outside checker on AI-written code — in the loop *while* your AI codes (real-time edit hook), not after.
16
16
 
17
- **The security MCP built for vibe coding.** 442 security rules, 37 tools covering the entire AI-generated code journey — from first line to production deployment.
17
+ **The security MCP built for vibe coding.** 442 security rules, 38 tools covering the entire AI-generated code journey — from the prompt itself to production deployment.
18
18
 
19
19
  Works with **Claude Code, Cursor, Gemini CLI, Codex, VS Code (Copilot), Windsurf**, and any MCP-compatible coding agent.
20
20
 
@@ -26,7 +26,7 @@ Works with **Claude Code, Cursor, Gemini CLI, Codex, VS Code (Copilot), Windsurf
26
26
 
27
27
  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.
28
28
 
29
- - **442 security rules, 37 tools** purpose-built for the stacks AI agents generate
29
+ - **442 security rules, 38 tools** purpose-built for the stacks AI agents generate
30
30
  - **Zero setup friction** — `npx guardvibe` and you're scanning
31
31
  - **No account required** — runs 100% locally, no API keys, no cloud
32
32
  - **Understands your stack** — not generic SAST, but rules that know Next.js, Supabase, Stripe, Clerk, and the tools you actually use
@@ -212,7 +212,37 @@ Maps security findings to SOC2, PCI-DSS, HIPAA, GDPR, ISO27001, and EU AI Act (E
212
212
  ### Supply Chain
213
213
  Malicious postinstall scripts, unpinned GitHub Actions, CI `npm` provenance / `--ignore-scripts` hardening (VG1070), typosquat detection, `node-ipc` protestware versions (VG1069), Miasma `@redhat-cloud-services` namespace compromise IOC (VG1074, RHSB-2026-006), Session messenger exfil endpoint IOC (VG1075, `filev2.getsession.org`), `@tanstack/*` Mini Shai-Hulud mass-malware versions (May 2026), `@wdio/browserstack-service` command injection via git branch names (CVE-2026-25244), lockfile poisoning patterns
214
214
 
215
- ## Tools (37 MCP tools)
215
+ ## Prompt-Level Security (Shift Left)
216
+
217
+ Most vulnerabilities in AI-generated code are born in the prompt: "add login to my app" says nothing about password hashing, session handling, or rate limiting — so the model picks defaults, and the defaults are where the CVEs live. `secure_prompt` moves the security gate to **before code generation**: it analyzes the raw prompt, detects the stack and attack surfaces it implies, matches them against GuardVibe's rule set, and returns a directive the host LLM uses to rewrite the prompt with security requirements embedded.
218
+
219
+ This is not a prompt beautifier. It is deterministic (no LLM, no network), it never restructures intent, and its first job is **do no harm**: a prompt that is already specific and security-aware gets verdict `NO_MOD` and passes through untouched.
220
+
221
+ - **`NO_MOD`** — prompt is already specific and security-aware → proceed with the original prompt unchanged
222
+ - **`LIGHT_MOD`** — intent is clear but security constraints are missing → inject requirements only
223
+ - **`HEAVY_MOD`** — prompt is vague *and* security-relevant → inject requirements + surface clarifying questions (never invent the answers)
224
+
225
+ **Before** (what the user typed):
226
+
227
+ ```text
228
+ add login to my app
229
+ ```
230
+
231
+ **After** (what the host LLM executes, having applied the `secure_prompt` directive):
232
+
233
+ ```text
234
+ Add login to my app, with these security requirements:
235
+ - [VG001] Use environment variables or a secrets manager — never hardcode credentials.
236
+ - [VG1008] Always verify the caller has admin privileges before allowing role elevation.
237
+ - [VG105] Always specify allowed algorithms explicitly in jwt.verify().
238
+
239
+ Before implementing, confirm: which framework/stack is this for, and which auth
240
+ provider should be used (e.g. Clerk, Auth.js/NextAuth, Supabase Auth, custom JWT)?
241
+ ```
242
+
243
+ Same user intent — but the model now generates auth code with the guardrails stated up front, instead of GuardVibe catching the missing pieces after the fact.
244
+
245
+ ## Tools (38 MCP tools)
216
246
 
217
247
  | Tool | What it does |
218
248
  |------|-------------|
@@ -253,6 +283,7 @@ Malicious postinstall scripts, unpinned GitHub Actions, CI `npm` provenance / `-
253
283
  | `full_audit` | **Single source of truth** — runs ALL checks in one call, returns PASS/FAIL/WARN verdict + score + coverage % + deterministic result hash |
254
284
  | `remediation_plan` | **Remediation plan** — generates section-by-section fix checklist after audit |
255
285
  | `verify_remediation` | **Remediation verification** — compares before/after audit, flags skipped sections |
286
+ | `secure_prompt` | **Prompt-level security (shift left)** — analyze a coding prompt BEFORE code is written; deterministic triage (NO_MOD/LIGHT_MOD/HEAVY_MOD), stack + attack-surface detection, severity-ranked GuardVibe requirements embedded via a rewrite directive |
256
287
 
257
288
  All scanning tools support `format: "json"` for machine-readable output.
258
289
 
package/build/index.js CHANGED
@@ -42,6 +42,7 @@ import { formatHostFindings, redactSecrets } from "./server/types.js";
42
42
  import { verifyFix } from "./tools/verify-fix.js";
43
43
  import { fixCode as fixCodeTool } from "./tools/fix-code.js";
44
44
  import { secureThis } from "./tools/secure-this.js";
45
+ import { securePrompt } from "./tools/secure-prompt.js";
45
46
  import { buildAgentReport } from "./tools/agent-output.js";
46
47
  import { analyzeAuthCoverage, formatAuthCoverage } from "./tools/auth-coverage.js";
47
48
  import { buildDeepScanPrompt, parseDeepScanResult, formatDeepScanFindings, callLLM } from "./tools/deep-scan.js";
@@ -63,7 +64,7 @@ function mergeStatsIntoOutput(results, summary, format) {
63
64
  const server = new McpServer({
64
65
  name: "guardvibe",
65
66
  version: pkg.version,
66
- description: "Security MCP for vibe coding — single source of truth for AI assistants. 390 security rules and 36 tools. Use full_audit for a comprehensive PASS/FAIL/WARN verdict with deterministic result hash, coverage %, and unified report across code, secrets, dependencies, config, taint analysis, and auth coverage. IMPORTANT: When full_audit returns FAIL/WARN, call remediation_plan to get a mandatory section-by-section fix checklist covering ALL 6 sections (not just code). After fixing, call verify_remediation to confirm all sections were addressed. Same code = same hash = same results regardless of which AI assistant runs it. Covers OWASP, Next.js, Supabase, Stripe, Clerk, Prisma, Hono, AI SDK, MCP server security, host hardening. Maps to SOC2, PCI-DSS, HIPAA, GDPR, ISO27001, EU AI Act. Runs 100% locally with zero configuration.",
67
+ description: `Security MCP for vibe coding — single source of truth for AI assistants. ${builtinRules.length} security rules and 38 tools. Call secure_prompt with the user's coding prompt BEFORE generating code to embed security requirements up front (shift left). Use full_audit for a comprehensive PASS/FAIL/WARN verdict with deterministic result hash, coverage %, and unified report across code, secrets, dependencies, config, taint analysis, and auth coverage. IMPORTANT: When full_audit returns FAIL/WARN, call remediation_plan to get a mandatory section-by-section fix checklist covering ALL 6 sections (not just code). After fixing, call verify_remediation to confirm all sections were addressed. Same code = same hash = same results regardless of which AI assistant runs it. Covers OWASP, Next.js, Supabase, Stripe, Clerk, Prisma, Hono, AI SDK, MCP server security, host hardening. Maps to SOC2, PCI-DSS, HIPAA, GDPR, ISO27001, EU AI Act. Runs 100% locally with zero configuration.`,
67
68
  });
68
69
  // Tool 1: Analyze code for security vulnerabilities
69
70
  server.tool("check_code", "Analyze inline code for security vulnerabilities (OWASP Top 10, XSS, SQL injection, insecure patterns). Pass code as a string parameter. For scanning files on disk, use scan_file instead. Example: check_code({code: 'app.get(...)', language: 'javascript'})", {
@@ -1031,6 +1032,15 @@ server.tool("verify_remediation", "Compare before/after audit results to verify
1031
1032
  lines.push("", "---", `**${summary}**`);
1032
1033
  return { content: [{ type: "text", text: lines.join("\n") }] };
1033
1034
  });
1035
+ // Tool 38: secure_prompt — shift-left security at the prompt level (enhance BEFORE code is written)
1036
+ server.tool("secure_prompt", "Shift-left security at the prompt level: analyze a raw coding prompt BEFORE any code is written and return a structured enhancement directive that embeds GuardVibe security requirements (auth checks, input validation, webhook signature verification, SQL injection prevention, secrets handling) into the prompt you are about to execute. Deterministic — no LLM, no network: triage verdict NO_MOD (prompt already specific and security-aware → proceed with the ORIGINAL prompt unchanged), LIGHT_MOD (inject missing security constraints only), or HEAVY_MOD (also surface clarifying questions — never invent answers to them). Detects stack (Next.js, Supabase, Clerk, Stripe, Prisma, Express, Hono...) and attack surfaces (auth, payments, file upload, user input, SQL, secrets, redirects) from the prompt text, matches them against GuardVibe's rule set, and returns verdict + intent summary + numbered [rule-id] requirements + rewrite directive. Call this with the user's prompt before generating code; prevents vulnerabilities before code generation instead of scanning after. Example: secure_prompt({raw_prompt: 'add login to my app'})", {
1037
+ raw_prompt: z.string().describe("The user's original coding prompt, verbatim"),
1038
+ context: z.string().optional().describe("Known stack/framework context if the client has it (e.g. 'Next.js app router, Supabase, Stripe')"),
1039
+ }, async ({ raw_prompt, context }) => {
1040
+ const rules = getRules();
1041
+ const result = securePrompt(raw_prompt, { context, rules });
1042
+ return { content: [{ type: "text", text: result.markdown }] };
1043
+ });
1034
1044
  export async function startMcpServer() {
1035
1045
  return main();
1036
1046
  }
@@ -16,6 +16,16 @@ export declare function paramReachesSink(code: string, filePath?: string): boole
16
16
  * firing — for a BOLA rule we prefer a false positive over hiding a real one.
17
17
  */
18
18
  export declare function bolaOwnershipGuarded(code: string, filePath: string | undefined, line: number): boolean;
19
+ /**
20
+ * BOLA ownership-guard detection for VG951 (delete/update). The rule's regex
21
+ * already suppresses an ownership field inside the mutation's WHERE clause (via a
22
+ * negative lookahead), so the only blind spot is the find → compare → mutate
23
+ * pattern: the mutation's where-clause is a bare id, but the enclosing function
24
+ * fetched the resource and compared its ownership field against the session first.
25
+ * Returns true (→ suppress) only when that post-fetch comparison is present;
26
+ * false on uncertainty so a genuinely unguarded mutation keeps firing.
27
+ */
28
+ export declare function bolaMutationGuarded(code: string, filePath: string | undefined, line: number): boolean;
19
29
  /**
20
30
  * True when the argument to a `new RegExp(...)` at `line` is PROVABLY a constant
21
31
  * (a string literal, a variable assigned from a string literal, or the callback
@@ -137,6 +137,10 @@ export function paramReachesSink(code, filePath) {
137
137
  return sinkArgs.some(args => refsTaint(args, tainted));
138
138
  }
139
139
  const FIND_METHODS = new Set(["findUnique", "findFirst", "findById", "findOne", "getOne"]);
140
+ // Mutation sinks for VG951 (delete/update BOLA) — the last identifier of the callee.
141
+ const MUTATION_METHODS = new Set([
142
+ "delete", "update", "destroy", "remove", "deleteMany", "updateMany", "deleteOne", "updateOne",
143
+ ]);
140
144
  const OWNERSHIP_FIELDS = new Set([
141
145
  "userId", "user_id", "ownerId", "owner_id", "authorId", "author_id", "createdById", "createdBy", "created_by",
142
146
  "accountId", "account_id", "tenantId", "tenant_id", "orgId", "org_id", "organizationId",
@@ -146,6 +150,44 @@ const OWNERSHIP_FIELDS = new Set([
146
150
  // A comparison of a fetched resource's ownership field, on a line that also references a session/user.
147
151
  const OWNERSHIP_COMPARE = /\.\s*(?:userId|ownerId|authorId|createdById|teamId|workspaceId|orgId|organizationId|tenantId|memberId|accountId|projectId)\b\s*(?:===|!==|==|!=)/i;
148
152
  const SESSION_REF = /\b(?:session|ctx|auth|currentUser|viewer|member|account|workspace|team|org|self|me|user)\b/i;
153
+ /** The first CallExpression near `line` whose last-identifier method is in `methods`. */
154
+ function callNearLine(ts, sf, line, methods) {
155
+ let target;
156
+ const visit = (node) => {
157
+ if (!target && ts.isCallExpression(node)) {
158
+ const callee = node.expression;
159
+ const method = ts.isPropertyAccessExpression(callee) ? callee.name.text
160
+ : ts.isIdentifier(callee) ? callee.text : undefined;
161
+ if (method && methods.has(method)) {
162
+ const startLine = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
163
+ if (Math.abs(startLine - line) <= 1)
164
+ target = node;
165
+ }
166
+ }
167
+ if (!target)
168
+ ts.forEachChild(node, visit);
169
+ };
170
+ visit(sf);
171
+ return target;
172
+ }
173
+ /**
174
+ * True when the function enclosing `node` performs a post-fetch ownership
175
+ * comparison — an ownership field (`fetched.userId`) compared against a
176
+ * session/user value on the same line (`!== ctx.user.id`). Same-function only
177
+ * (no inter-procedural tracing), matching the line/regex engine's precision.
178
+ */
179
+ function hasPostFetchOwnershipGuard(ts, sf, node) {
180
+ let fn = node;
181
+ while (fn && !(ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isArrowFunction(fn) || ts.isMethodDeclaration(fn))) {
182
+ fn = fn.parent;
183
+ }
184
+ const body = fn ? fn.getText(sf) : sf.getText();
185
+ for (const ln of body.split("\n")) {
186
+ if (OWNERSHIP_COMPARE.test(ln) && SESSION_REF.test(ln))
187
+ return true;
188
+ }
189
+ return false;
190
+ }
149
191
  /**
150
192
  * BOLA ownership-guard detection for VG950 (find-by-user-id). Returns true (the
151
193
  * query is ownership-guarded → suppress the finding) when EITHER:
@@ -167,22 +209,7 @@ export function bolaOwnershipGuarded(code, filePath, line) {
167
209
  catch {
168
210
  return false;
169
211
  }
170
- let target;
171
- const visit = (node) => {
172
- if (!target && ts.isCallExpression(node)) {
173
- const callee = node.expression;
174
- const method = ts.isPropertyAccessExpression(callee) ? callee.name.text
175
- : ts.isIdentifier(callee) ? callee.text : undefined;
176
- if (method && FIND_METHODS.has(method)) {
177
- const startLine = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
178
- if (Math.abs(startLine - line) <= 1)
179
- target = node;
180
- }
181
- }
182
- if (!target)
183
- ts.forEachChild(node, visit);
184
- };
185
- visit(sf);
212
+ const target = callNearLine(ts, sf, line, FIND_METHODS);
186
213
  if (!target)
187
214
  return false;
188
215
  // (1) ownership field in the WHERE clause with a non-param value.
@@ -201,16 +228,32 @@ export function bolaOwnershipGuarded(code, filePath, line) {
201
228
  }
202
229
  }
203
230
  // (2) post-fetch ownership comparison against a session/user value, in the same function.
204
- let fn = target;
205
- while (fn && !(ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isArrowFunction(fn) || ts.isMethodDeclaration(fn))) {
206
- fn = fn.parent;
231
+ return hasPostFetchOwnershipGuard(ts, sf, target);
232
+ }
233
+ /**
234
+ * BOLA ownership-guard detection for VG951 (delete/update). The rule's regex
235
+ * already suppresses an ownership field inside the mutation's WHERE clause (via a
236
+ * negative lookahead), so the only blind spot is the find → compare → mutate
237
+ * pattern: the mutation's where-clause is a bare id, but the enclosing function
238
+ * fetched the resource and compared its ownership field against the session first.
239
+ * Returns true (→ suppress) only when that post-fetch comparison is present;
240
+ * false on uncertainty so a genuinely unguarded mutation keeps firing.
241
+ */
242
+ export function bolaMutationGuarded(code, filePath, line) {
243
+ const ts = getTs();
244
+ if (!ts)
245
+ return false;
246
+ let sf;
247
+ try {
248
+ sf = ts.createSourceFile(filePath ?? "file.ts", code, ts.ScriptTarget.Latest, true, scriptKindFor(ts, filePath));
207
249
  }
208
- const body = fn ? fn.getText(sf) : code;
209
- for (const ln of body.split("\n")) {
210
- if (OWNERSHIP_COMPARE.test(ln) && SESSION_REF.test(ln))
211
- return true;
250
+ catch {
251
+ return false;
212
252
  }
213
- return false;
253
+ const target = callNearLine(ts, sf, line, MUTATION_METHODS);
254
+ if (!target)
255
+ return false;
256
+ return hasPostFetchOwnershipGuard(ts, sf, target);
214
257
  }
215
258
  const ITER_METHODS = new Set(["map", "forEach", "some", "every", "filter", "find", "findIndex", "reduce", "flatMap"]);
216
259
  /** First `const NAME = <initializer>` for NAME anywhere in the file (file-scope-ish). */
@@ -4,7 +4,7 @@ import { loadConfig } from "../utils/config.js";
4
4
  import { loadIgnoreFile, isIgnored } from "../utils/ignore.js";
5
5
  import { securityBanner } from "../utils/banner.js";
6
6
  import { looksMinified } from "../utils/constants.js";
7
- import { paramReachesSink, bolaOwnershipGuarded, regexpArgIsConstant } from "./ast-engine.js";
7
+ import { paramReachesSink, bolaOwnershipGuarded, bolaMutationGuarded, regexpArgIsConstant } from "./ast-engine.js";
8
8
  /** CVE version-pin rule IDs are VG900-VG931 (and only these). Other VG9xx IDs
9
9
  * (VG983 Turso, VG990 SVG, VG998 OpenAI browser flag, etc.) are regular code-pattern
10
10
  * rules and should NOT be exempted from comment / string-literal skip logic. */
@@ -886,6 +886,12 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
886
886
  // `userId` that only appears in `select`, and sees a separate comparison statement.
887
887
  if (rule.id === "VG950" && filePath && bolaOwnershipGuarded(code, filePath, lineNumber))
888
888
  continue;
889
+ // VG951 (BOLA delete/update): the regex already suppresses an ownership field
890
+ // inside the mutation's WHERE clause, so its only blind spot is the
891
+ // find → compare → mutate pattern — a bare-id mutation preceded by a post-fetch
892
+ // ownership comparison against the session. Suppress only when AST proves it.
893
+ if (rule.id === "VG951" && filePath && bolaMutationGuarded(code, filePath, lineNumber))
894
+ continue;
889
895
  // VG126 (Dynamic RegExp from User Input): the regex matches `new RegExp(anyVar)`,
890
896
  // but the var is often a provable constant — a string literal or the callback
891
897
  // param iterating a const string array (the classic bot-UA / referrer-list pattern).
@@ -0,0 +1,65 @@
1
+ import type { SecurityRule } from "../data/rules/types.js";
2
+ export type SecurePromptVerdict = "NO_MOD" | "LIGHT_MOD" | "HEAVY_MOD";
3
+ export interface SecurePromptRequirement {
4
+ ruleId: string;
5
+ title: string;
6
+ requirement: string;
7
+ severity: SecurityRule["severity"];
8
+ }
9
+ export interface SecurePromptResult {
10
+ verdict: SecurePromptVerdict;
11
+ reason: string;
12
+ intentSummary: string;
13
+ detectedStack: string[];
14
+ detectedSurfaces: string[];
15
+ securityRequirements: SecurePromptRequirement[];
16
+ ambiguities: string[];
17
+ originalPrompt: string;
18
+ /** The single markdown directive block returned to the host LLM. */
19
+ markdown: string;
20
+ }
21
+ /** Triage thresholds — tune here, never inline. */
22
+ export declare const TRIAGE_CONFIG: {
23
+ /** Distinct security terms at/above this → prompt counts as security-aware. */
24
+ readonly securityAwareTerms: 3;
25
+ /** Specificity score at/above this → prompt counts as specific (not vague). */
26
+ readonly specificityThreshold: 4;
27
+ /** Prompts shorter than this many words are vague regardless of other signals. */
28
+ readonly minWords: 6;
29
+ /** Matched rules surfaced as requirements are capped at this many. */
30
+ readonly maxRequirements: 8;
31
+ /** Clarifying questions (HEAVY_MOD only) are capped at this many. */
32
+ readonly maxAmbiguities: 3;
33
+ };
34
+ /**
35
+ * A lowercased haystack searched in two forms: raw (so hyphenated tokens like
36
+ * "next-auth" match) and hyphen/underscore-normalized (so user phrasings like
37
+ * "sign-in"/"RLS-enabled"/"log_in" match space-joined tokens like "sign in").
38
+ */
39
+ interface Haystack {
40
+ raw: string;
41
+ norm: string;
42
+ }
43
+ /** True if the token appears (word-boundary) in either form of the haystack. */
44
+ export declare function includesToken(haystack: Haystack | string, token: string): boolean;
45
+ /** Detect technologies named in the prompt (and optional client-provided context). */
46
+ export declare function detectPromptStack(rawPrompt: string, context?: string): string[];
47
+ /**
48
+ * Detect security-sensitive attack surfaces implied by the prompt. Surfaces describe
49
+ * what the user is BUILDING, so they are derived from the prompt text only — the
50
+ * optional `context` (which names the stack, not the task) deliberately does not
51
+ * manufacture surfaces, preserving the NO_MOD "do no harm" path for non-security
52
+ * prompts even when a host always attaches project context.
53
+ */
54
+ export declare function detectPromptSurfaces(rawPrompt: string, context?: string): string[];
55
+ /** Rank rules against the detected stack + surfaces; severity first, cap at maxRequirements. */
56
+ export declare function matchRulesForPrompt(stack: string[], surfaces: string[], rules: SecurityRule[]): SecurePromptRequirement[];
57
+ /**
58
+ * Analyze a raw coding prompt BEFORE code generation and return a structured
59
+ * enhancement directive. Fully deterministic: same prompt = same directive.
60
+ */
61
+ export declare function securePrompt(rawPrompt: string, opts?: {
62
+ context?: string;
63
+ rules?: SecurityRule[];
64
+ }): SecurePromptResult;
65
+ export {};
@@ -0,0 +1,434 @@
1
+ // secure_prompt — shift-left security at the prompt level.
2
+ // Deterministic pipeline (no LLM calls, no network, no filesystem): triage the raw
3
+ // prompt ("do no harm" first), detect stack + attack surfaces from keyword/alias maps,
4
+ // match the existing GuardVibe rule set, and emit a markdown enhancement directive
5
+ // (guardvibe.secure_prompt.v1) that the HOST LLM uses to rewrite the prompt with
6
+ // security requirements embedded — BEFORE any code is written.
7
+ //
8
+ // Keyword matching deliberately avoids dynamic RegExp construction (boundary-checked
9
+ // indexOf instead) so the scanner's own dynamic-regex and ReDoS audits stay clean.
10
+ import { builtinRules } from "../data/rules/index.js";
11
+ /** Triage thresholds — tune here, never inline. */
12
+ export const TRIAGE_CONFIG = {
13
+ /** Distinct security terms at/above this → prompt counts as security-aware. */
14
+ securityAwareTerms: 3,
15
+ /** Specificity score at/above this → prompt counts as specific (not vague). */
16
+ specificityThreshold: 4,
17
+ /** Prompts shorter than this many words are vague regardless of other signals. */
18
+ minWords: 6,
19
+ /** Matched rules surfaced as requirements are capped at this many. */
20
+ maxRequirements: 8,
21
+ /** Clarifying questions (HEAVY_MOD only) are capped at this many. */
22
+ maxAmbiguities: 3,
23
+ };
24
+ const TECHS = [
25
+ { id: "nextjs", label: "Next.js", tokens: ["next.js", "nextjs", "next js", "app router", "server action", "server actions", "server component", "server components", "route handler"], ruleKeywords: ["next.js", "nextjs", "server action", "app router", "route handler", "next_public"], impliedSurfaces: [] },
26
+ { id: "react", label: "React", tokens: ["react", "jsx", "tsx"], ruleKeywords: ["react", "dangerouslysetinnerhtml"], impliedSurfaces: [] },
27
+ { id: "express", label: "Express", tokens: ["express", "expressjs"], ruleKeywords: ["express"], impliedSurfaces: [] },
28
+ { id: "hono", label: "Hono", tokens: ["hono"], ruleKeywords: ["hono"], impliedSurfaces: [] },
29
+ { id: "supabase", label: "Supabase", tokens: ["supabase", "row level security", "rls"], ruleKeywords: ["supabase", "row level security"], impliedSurfaces: ["database"] },
30
+ { id: "clerk", label: "Clerk", tokens: ["clerk"], ruleKeywords: ["clerk"], impliedSurfaces: ["auth"] },
31
+ { id: "nextauth", label: "Auth.js / NextAuth", tokens: ["next-auth", "nextauth", "auth.js", "authjs"], ruleKeywords: ["next-auth", "nextauth", "auth.js"], impliedSurfaces: ["auth"] },
32
+ { id: "stripe", label: "Stripe", tokens: ["stripe"], ruleKeywords: ["stripe"], impliedSurfaces: ["payments"] },
33
+ { id: "lemonsqueezy", label: "LemonSqueezy", tokens: ["lemonsqueezy", "lemon squeezy"], ruleKeywords: ["lemonsqueezy"], impliedSurfaces: ["payments"] },
34
+ { id: "prisma", label: "Prisma", tokens: ["prisma"], ruleKeywords: ["prisma"], impliedSurfaces: ["database"] },
35
+ { id: "drizzle", label: "Drizzle", tokens: ["drizzle"], ruleKeywords: ["drizzle"], impliedSurfaces: ["database"] },
36
+ { id: "mongodb", label: "MongoDB / Mongoose", tokens: ["mongodb", "mongoose", "mongo"], ruleKeywords: ["mongo", "nosql"], impliedSurfaces: ["database"] },
37
+ { id: "postgres", label: "PostgreSQL", tokens: ["postgres", "postgresql"], ruleKeywords: ["postgres", "sql"], impliedSurfaces: ["database"] },
38
+ { id: "firebase", label: "Firebase", tokens: ["firebase", "firestore"], ruleKeywords: ["firebase", "firestore"], impliedSurfaces: ["database"] },
39
+ { id: "trpc", label: "tRPC", tokens: ["trpc"], ruleKeywords: ["trpc", "procedure"], impliedSurfaces: [] },
40
+ { id: "fastapi", label: "FastAPI", tokens: ["fastapi"], ruleKeywords: ["fastapi"], impliedSurfaces: [] },
41
+ { id: "django", label: "Django", tokens: ["django"], ruleKeywords: ["django"], impliedSurfaces: [] },
42
+ ];
43
+ const SURFACES = [
44
+ {
45
+ id: "auth", label: "authentication / access control",
46
+ tokens: ["auth", "authentication", "authorization", "login", "log in", "signin", "sign in", "signup", "sign up", "logout", "password", "session", "sessions", "jwt", "oauth", "sso", "2fa", "mfa", "role", "roles", "permission", "permissions", "admin", "account", "user management"],
47
+ ruleKeywords: ["auth", "session", "login", "access control", "unauthorized", "credential", "jwt", "bola", "idor"],
48
+ question: "Which auth provider or mechanism should be used (e.g. Clerk, Auth.js/NextAuth, Supabase Auth, custom JWT sessions)?",
49
+ answeredByTechs: ["clerk", "nextauth", "supabase", "firebase"],
50
+ },
51
+ {
52
+ id: "payments", label: "payments / billing",
53
+ tokens: ["payment", "payments", "checkout", "billing", "subscription", "subscriptions", "invoice", "refund", "pricing", "pay"],
54
+ ruleKeywords: ["stripe", "payment", "webhook", "checkout", "billing", "price"],
55
+ question: "Which payment provider is used, and which webhook events must be handled?",
56
+ answeredByTechs: ["stripe", "lemonsqueezy"],
57
+ },
58
+ {
59
+ id: "file-upload", label: "file upload",
60
+ tokens: ["upload", "uploads", "file upload", "avatar", "attachment", "attachments", "multipart", "image upload"],
61
+ ruleKeywords: ["upload", "file type", "multipart", "path traversal", "content-type"],
62
+ question: "What file types and maximum size should uploads accept, and where are files stored?",
63
+ },
64
+ {
65
+ id: "user-input", label: "user input handling",
66
+ tokens: ["form", "forms", "input", "inputs", "comment", "comments", "search", "user input", "query param", "query params", "request body", "post endpoint", "api endpoint", "endpoint", "contact form", "profile"],
67
+ ruleKeywords: ["validation", "sanitiz", "xss", "injection", "innerhtml", "user input"],
68
+ },
69
+ {
70
+ id: "database", label: "database / SQL",
71
+ tokens: ["sql", "database", "db", "query", "queries", "mysql", "sqlite", "orm", "table", "schema", "migration"],
72
+ ruleKeywords: ["sql", "injection", "query", "orm", "database", "mass assignment"],
73
+ question: "Which database/ORM is used (e.g. Prisma, Drizzle, Supabase, raw Postgres)?",
74
+ answeredByTechs: ["prisma", "drizzle", "supabase", "postgres", "mongodb", "firebase"],
75
+ },
76
+ {
77
+ id: "secrets", label: "secrets / credentials",
78
+ tokens: ["secret", "secrets", "api key", "api keys", "apikey", "token", "tokens", "credential", "credentials", ".env", "env var", "env vars", "environment variable", "environment variables", "private key"],
79
+ ruleKeywords: ["secret", "credential", "api key", "hardcoded", "env"],
80
+ },
81
+ {
82
+ id: "external-api", label: "external API calls",
83
+ tokens: ["external api", "third-party", "third party", "fetch", "webhook", "webhooks", "http request", "api call", "api calls", "integration", "proxy", "scrape", "scraper"],
84
+ ruleKeywords: ["ssrf", "request forgery", "external", "url"],
85
+ },
86
+ {
87
+ id: "deserialization", label: "deserialization / dynamic evaluation",
88
+ tokens: ["deserialize", "deserialization", "unserialize", "pickle", "yaml.load", "eval", "serialize"],
89
+ ruleKeywords: ["deserial", "eval", "prototype pollution", "unserialize"],
90
+ },
91
+ {
92
+ id: "redirect", label: "redirects / callbacks",
93
+ tokens: ["redirect", "redirects", "callback url", "return url", "returnto", "return to", "callback"],
94
+ ruleKeywords: ["redirect", "callback"],
95
+ },
96
+ ];
97
+ // Explicit security-engineering vocabulary, grouped by CONCEPT. countSecurityTerms
98
+ // counts each group at most once, so synonyms/sub-phrases ("validation" +
99
+ // "input validation" + "schema validation") of a single concept never triple-count.
100
+ const SECURITY_TERM_GROUPS = [
101
+ ["auth", "authn", "authentication", "authorization", "access control", "ownership check"],
102
+ ["validate", "validates", "validation", "input validation", "schema validation", "zod"],
103
+ ["sanitize", "sanitizes", "sanitization", "escape", "escaping"],
104
+ ["rate limit", "rate-limit", "rate limiting", "rate-limiting", "throttle"],
105
+ ["csrf"],
106
+ ["xss"],
107
+ ["sql injection", "injection", "parameterized", "prepared statement"],
108
+ ["webhook signature", "signature verification", "verify the signature", "constructevent", "hmac", "timingsafeequal", "timing-safe"],
109
+ ["secret manager", "secrets manager", "env var", "environment variable"],
110
+ ["encrypt", "encryption", "hash", "hashed", "hashing", "bcrypt", "argon2", "scrypt"],
111
+ ["jwt verification", "verify jwt"],
112
+ ["rls", "row level security", "least privilege"],
113
+ ["csp", "hsts", "x-frame-options", "security header", "security headers", "helmet"],
114
+ ["cors"],
115
+ ["2fa", "mfa"],
116
+ ["owasp", "idor", "bola", "ssrf"],
117
+ ["allowlist", "whitelist", "denylist"],
118
+ ];
119
+ /** Markers of an underspecified ask — each hit lowers the specificity score. */
120
+ const VAGUE_MARKERS = [
121
+ "somehow", "something", "stuff", "make it work", "or whatever", "etc", "some kind of",
122
+ "quick and dirty", "simple app", "basic app", "a thing",
123
+ ];
124
+ const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
125
+ /** Languages whose rules express things a code-writing prompt can actually satisfy. */
126
+ const CODE_LANGUAGES = ["javascript", "typescript", "python", "go"];
127
+ function isWordChar(ch) {
128
+ if (ch === "")
129
+ return false;
130
+ return /[a-z0-9_-]/.test(ch);
131
+ }
132
+ /** Word-boundary token search without dynamic RegExp (token is matched case-insensitively). */
133
+ function includesTokenIn(haystackLower, token) {
134
+ const needle = token.toLowerCase();
135
+ let idx = haystackLower.indexOf(needle);
136
+ while (idx !== -1) {
137
+ const before = idx === 0 ? "" : haystackLower[idx - 1];
138
+ const afterIdx = idx + needle.length;
139
+ const after = afterIdx >= haystackLower.length ? "" : haystackLower[afterIdx];
140
+ if (!isWordChar(before) && !isWordChar(after))
141
+ return true;
142
+ idx = haystackLower.indexOf(needle, idx + 1);
143
+ }
144
+ return false;
145
+ }
146
+ function makeHaystack(text) {
147
+ const raw = text.toLowerCase();
148
+ return { raw, norm: raw.replace(/[-_]+/g, " ") };
149
+ }
150
+ /** True if the token appears (word-boundary) in either form of the haystack. */
151
+ export function includesToken(haystack, token) {
152
+ const h = typeof haystack === "string" ? makeHaystack(haystack) : haystack;
153
+ return includesTokenIn(h.raw, token) || includesTokenIn(h.norm, token);
154
+ }
155
+ /** Detect technologies named in the prompt (and optional client-provided context). */
156
+ export function detectPromptStack(rawPrompt, context) {
157
+ const h = makeHaystack(`${rawPrompt}\n${context ?? ""}`);
158
+ return TECHS.filter((t) => t.tokens.some((tok) => includesToken(h, tok))).map((t) => t.id);
159
+ }
160
+ /**
161
+ * Detect security-sensitive attack surfaces implied by the prompt. Surfaces describe
162
+ * what the user is BUILDING, so they are derived from the prompt text only — the
163
+ * optional `context` (which names the stack, not the task) deliberately does not
164
+ * manufacture surfaces, preserving the NO_MOD "do no harm" path for non-security
165
+ * prompts even when a host always attaches project context.
166
+ */
167
+ export function detectPromptSurfaces(rawPrompt, context) {
168
+ void context;
169
+ const h = makeHaystack(rawPrompt);
170
+ const direct = SURFACES.filter((s) => s.tokens.some((tok) => includesToken(h, tok))).map((s) => s.id);
171
+ const implied = TECHS.filter((t) => t.tokens.some((tok) => includesToken(h, tok))).flatMap((t) => t.impliedSurfaces);
172
+ return [...new Set([...direct, ...implied])];
173
+ }
174
+ /** Count DISTINCT security concepts present (each term group counts at most once). */
175
+ function countSecurityTerms(textLower) {
176
+ const h = makeHaystack(textLower);
177
+ let count = 0;
178
+ for (const group of SECURITY_TERM_GROUPS) {
179
+ if (group.some((term) => includesToken(h, term)))
180
+ count++;
181
+ }
182
+ return count;
183
+ }
184
+ function specificityScore(rawPrompt, stackCount) {
185
+ const collapsed = rawPrompt.replace(/\s+/g, " ").trim();
186
+ const words = collapsed.length === 0 ? [] : collapsed.split(" ");
187
+ const lower = collapsed.toLowerCase();
188
+ let score = Math.min(4, stackCount * 2);
189
+ // Concrete nouns: file paths / extensions and code identifiers.
190
+ let pathTokens = 0;
191
+ let codeTokens = 0;
192
+ for (const w of words) {
193
+ const cleaned = w.replace(/[,;:!?)]+$/, "");
194
+ if (cleaned.includes("/") && cleaned.length > 3)
195
+ pathTokens++;
196
+ else if (/\.[a-z]{2,4}$/i.test(cleaned))
197
+ pathTokens++;
198
+ else if (/[a-z][A-Z]/.test(cleaned) || cleaned.includes("(") || cleaned.includes("`"))
199
+ codeTokens++;
200
+ }
201
+ score += Math.min(2, pathTokens) + Math.min(2, codeTokens);
202
+ // Length tiers reward elaborated asks.
203
+ if (words.length >= 25)
204
+ score += 2;
205
+ else if (words.length >= 12)
206
+ score += 1;
207
+ // Vagueness markers subtract.
208
+ let vagueHits = 0;
209
+ for (const marker of VAGUE_MARKERS) {
210
+ if (includesToken(lower, marker))
211
+ vagueHits++;
212
+ }
213
+ score -= Math.min(2, vagueHits) * 2;
214
+ return score;
215
+ }
216
+ function triage(rawPrompt, stack, surfaces) {
217
+ const collapsed = rawPrompt.replace(/\s+/g, " ").trim();
218
+ if (collapsed.length === 0) {
219
+ return { verdict: "NO_MOD", reason: "Empty prompt — nothing to analyze; proceed as-is.", securityTermCount: 0 };
220
+ }
221
+ const lower = collapsed.toLowerCase();
222
+ const securityTermCount = countSecurityTerms(lower);
223
+ const securityRelevant = surfaces.length > 0 || securityTermCount > 0;
224
+ if (!securityRelevant) {
225
+ return { verdict: "NO_MOD", reason: "No security-sensitive surface detected — injecting security requirements would be noise; proceed as-is.", securityTermCount };
226
+ }
227
+ const wordCount = collapsed.split(" ").length;
228
+ const specific = wordCount >= TRIAGE_CONFIG.minWords
229
+ && specificityScore(rawPrompt, stack.length) >= TRIAGE_CONFIG.specificityThreshold;
230
+ const securityAware = securityTermCount >= TRIAGE_CONFIG.securityAwareTerms;
231
+ if (specific && securityAware) {
232
+ return {
233
+ verdict: "NO_MOD",
234
+ reason: `Prompt is already specific and security-aware (${securityTermCount} security terms, concrete stack/detail) — modification would risk altering intent.`,
235
+ securityTermCount,
236
+ };
237
+ }
238
+ if (specific) {
239
+ return {
240
+ verdict: "LIGHT_MOD",
241
+ reason: "Intent is clear and specific but explicit security constraints are missing — inject requirements only, do not restructure.",
242
+ securityTermCount,
243
+ };
244
+ }
245
+ return {
246
+ verdict: "HEAVY_MOD",
247
+ reason: "Prompt is vague/underspecified and touches security-sensitive surfaces — inject requirements and surface clarifying questions.",
248
+ securityTermCount,
249
+ };
250
+ }
251
+ /** Rank rules against the detected stack + surfaces; severity first, cap at maxRequirements. */
252
+ export function matchRulesForPrompt(stack, surfaces, rules) {
253
+ const techDefs = TECHS.filter((t) => stack.includes(t.id));
254
+ const surfaceDefs = SURFACES.filter((s) => surfaces.includes(s.id));
255
+ if (techDefs.length === 0 && surfaceDefs.length === 0)
256
+ return [];
257
+ const scored = [];
258
+ for (const rule of rules) {
259
+ // Only code-level rules become prompt-level requirements. This drops version-pin
260
+ // advisories and config/manifest rules (languages json/yaml only — you can't
261
+ // satisfy "upgrade package X" by writing code) while keeping behavioral js/ts/
262
+ // python/go rules even when they cite a CVE in their name (e.g. Drizzle sql.raw
263
+ // injection, Axios redirect leak, Hono SSE injection).
264
+ if (!rule.languages.some((l) => CODE_LANGUAGES.includes(l)))
265
+ continue;
266
+ const text = `${rule.name} ${rule.description}`.toLowerCase();
267
+ let score = 0;
268
+ for (const t of techDefs) {
269
+ if (t.ruleKeywords.some((k) => text.includes(k)))
270
+ score += 2;
271
+ }
272
+ for (const s of surfaceDefs) {
273
+ if (s.ruleKeywords.some((k) => text.includes(k)))
274
+ score += 1;
275
+ }
276
+ if (score > 0)
277
+ scored.push({ rule, score });
278
+ }
279
+ scored.sort((a, b) => (SEVERITY_ORDER[a.rule.severity] ?? 99) - (SEVERITY_ORDER[b.rule.severity] ?? 99)
280
+ || b.score - a.score
281
+ || a.rule.id.localeCompare(b.rule.id));
282
+ // Dedupe near-identical guidance (e.g. three "use parameterized queries" rules)
283
+ // so the capped list spends its slots on diverse requirements.
284
+ const seen = new Set();
285
+ const requirements = [];
286
+ for (const { rule } of scored) {
287
+ if (requirements.length >= TRIAGE_CONFIG.maxRequirements)
288
+ break;
289
+ const requirement = firstSentence(rule.fix);
290
+ // Key on the instruction itself, ignoring an attached code example (": db.query(...)").
291
+ const key = requirement.split(":")[0].toLowerCase().replace(/[^a-z0-9 ]/g, "").replace(/ +/g, " ").trim();
292
+ if (seen.has(key))
293
+ continue;
294
+ seen.add(key);
295
+ requirements.push({ ruleId: rule.id, title: rule.name, requirement, severity: rule.severity });
296
+ }
297
+ return requirements;
298
+ }
299
+ /** Common abbreviations whose trailing "." is not a sentence boundary. */
300
+ const ABBREVIATIONS = ["e.g", "i.e", "etc", "vs", "cf", "approx", "no", "fig", "al"];
301
+ function firstSentence(text) {
302
+ const collapsed = text.replace(/\s+/g, " ").trim();
303
+ // Find the first real sentence boundary, skipping ellipses ("...") and abbreviations
304
+ // ("e.g. ", "etc. ") so an example mid-fix doesn't truncate the actionable instruction.
305
+ let idx = collapsed.indexOf(". ");
306
+ while (idx > 0) {
307
+ const isEllipsis = collapsed[idx - 1] === ".";
308
+ const before = collapsed.slice(0, idx).toLowerCase();
309
+ const isAbbrev = ABBREVIATIONS.some((a) => before.endsWith(a) && !isWordChar(before[before.length - a.length - 1] ?? ""));
310
+ if (!isEllipsis && !isAbbrev)
311
+ break;
312
+ idx = collapsed.indexOf(". ", idx + 1);
313
+ }
314
+ if (idx === -1)
315
+ return collapsed;
316
+ const sentence = collapsed.slice(0, idx + 1);
317
+ // Never cut inside an unclosed inline code span.
318
+ const backticks = sentence.split("`").length - 1;
319
+ return backticks % 2 === 0 ? sentence : collapsed;
320
+ }
321
+ function buildAmbiguities(stack, surfaces) {
322
+ const questions = [];
323
+ if (stack.length === 0) {
324
+ questions.push("Which framework/stack is this for (e.g. Next.js, Express, Hono)? Only generic security rules could be matched without it.");
325
+ }
326
+ for (const surface of SURFACES) {
327
+ if (!surfaces.includes(surface.id) || !surface.question)
328
+ continue;
329
+ const answered = surface.answeredByTechs?.some((t) => stack.includes(t)) ?? false;
330
+ if (!answered)
331
+ questions.push(surface.question);
332
+ }
333
+ if (questions.length === 0) {
334
+ questions.push("The request is broad — which routes/files are in scope, and what does a successful result look like?");
335
+ }
336
+ return questions.slice(0, TRIAGE_CONFIG.maxAmbiguities);
337
+ }
338
+ function buildIntentSummary(rawPrompt) {
339
+ const collapsed = rawPrompt.replace(/\s+/g, " ").trim();
340
+ const clipped = collapsed.length > 220 ? `${collapsed.slice(0, 217)}...` : collapsed;
341
+ return `The user wants to: ${clipped}`;
342
+ }
343
+ /** Pick a code fence longer than any backtick run in the prompt so it embeds verbatim. */
344
+ function fenceFor(text) {
345
+ let longest = 0;
346
+ let run = 0;
347
+ for (const ch of text) {
348
+ run = ch === "`" ? run + 1 : 0;
349
+ if (run > longest)
350
+ longest = run;
351
+ }
352
+ return "`".repeat(Math.max(3, longest + 1));
353
+ }
354
+ const REWRITE_DIRECTIVE = "Rewrite the user's prompt incorporating the security requirements above. " +
355
+ "Do NOT add features the user did not request. Do NOT change the user's intent. " +
356
+ "If verdict is NO_MOD, use the original prompt as-is.";
357
+ const NO_MOD_DIRECTIVE = "Verdict is NO_MOD: use the ORIGINAL prompt below as-is. " +
358
+ "Do NOT rewrite, augment, or reinterpret it. " +
359
+ "Do NOT add features the user did not request. Do NOT change the user's intent.";
360
+ function surfaceLabel(id) {
361
+ return SURFACES.find((s) => s.id === id)?.label ?? id;
362
+ }
363
+ function techLabel(id) {
364
+ return TECHS.find((t) => t.id === id)?.label ?? id;
365
+ }
366
+ function buildMarkdown(result) {
367
+ const fence = fenceFor(result.originalPrompt);
368
+ const lines = [
369
+ "## GuardVibe secure_prompt directive (guardvibe.secure_prompt.v1)",
370
+ "",
371
+ `- **verdict:** ${result.verdict}`,
372
+ `- **reason:** ${result.reason}`,
373
+ ];
374
+ if (result.verdict === "NO_MOD") {
375
+ lines.push("", "### rewrite_directive", NO_MOD_DIRECTIVE, "", "### original_prompt", fence + "text", result.originalPrompt, fence);
376
+ return lines.join("\n");
377
+ }
378
+ lines.push("", "### intent_summary (HARD CONSTRAINT — preserve this intent exactly)", `${result.intentSummary}`, "The rewritten prompt MUST preserve this intent exactly: no added features, no scope changes.");
379
+ if (result.detectedStack.length > 0 || result.detectedSurfaces.length > 0) {
380
+ lines.push("", "### detected_context");
381
+ if (result.detectedStack.length > 0) {
382
+ lines.push(`- **stack:** ${result.detectedStack.map(techLabel).join(", ")}`);
383
+ }
384
+ if (result.detectedSurfaces.length > 0) {
385
+ lines.push(`- **attack surfaces:** ${result.detectedSurfaces.map(surfaceLabel).join(", ")}`);
386
+ }
387
+ }
388
+ lines.push("", "### security_requirements");
389
+ if (result.securityRequirements.length === 0) {
390
+ lines.push("_No specific GuardVibe rules matched the detected stack/surfaces — apply standard input validation and authentication practices._");
391
+ }
392
+ else {
393
+ result.securityRequirements.forEach((req, i) => {
394
+ lines.push(`${i + 1}. [${req.ruleId}] (${req.severity}) ${req.title} — ${req.requirement}`);
395
+ });
396
+ }
397
+ if (result.verdict === "HEAVY_MOD" && result.ambiguities.length > 0) {
398
+ lines.push("", "### ambiguities (ask the user — do NOT invent answers)");
399
+ result.ambiguities.forEach((q, i) => lines.push(`${i + 1}. ${q}`));
400
+ }
401
+ lines.push("", "### rewrite_directive", REWRITE_DIRECTIVE, "", "### original_prompt", fence + "text", result.originalPrompt, fence);
402
+ return lines.join("\n");
403
+ }
404
+ /**
405
+ * Analyze a raw coding prompt BEFORE code generation and return a structured
406
+ * enhancement directive. Fully deterministic: same prompt = same directive.
407
+ */
408
+ export function securePrompt(rawPrompt, opts) {
409
+ const effectiveRules = opts?.rules && opts.rules.length > 0 ? opts.rules : builtinRules;
410
+ // Full known stack (prompt + context) — informs display and answers "which provider"
411
+ // clarifying questions. promptStack (prompt only) drives triage and rule selection so
412
+ // that always-attached project context can never escalate a non-security prompt or
413
+ // manufacture off-topic requirements (the "do no harm" guarantee).
414
+ const detectedStack = detectPromptStack(rawPrompt, opts?.context);
415
+ const promptStack = detectPromptStack(rawPrompt);
416
+ const detectedSurfaces = detectPromptSurfaces(rawPrompt);
417
+ const { verdict, reason } = triage(rawPrompt, promptStack, detectedSurfaces);
418
+ // NO_MOD short-circuits: original prompt untouched, no requirements computed.
419
+ const securityRequirements = verdict === "NO_MOD"
420
+ ? []
421
+ : matchRulesForPrompt(promptStack, detectedSurfaces, effectiveRules);
422
+ const ambiguities = verdict === "HEAVY_MOD" ? buildAmbiguities(detectedStack, detectedSurfaces) : [];
423
+ const base = {
424
+ verdict,
425
+ reason,
426
+ intentSummary: buildIntentSummary(rawPrompt),
427
+ detectedStack,
428
+ detectedSurfaces,
429
+ securityRequirements,
430
+ ambiguities,
431
+ originalPrompt: rawPrompt,
432
+ };
433
+ return { ...base, markdown: buildMarkdown(base) };
434
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.17.0",
3
+ "version": "3.19.0",
4
4
  "mcpName": "io.github.goklab/guardvibe",
5
- "description": "Security infrastructure your AI can't be — deterministic, current past your model's training cutoff, whole-repo-aware, author-independent. Security MCP for vibe coding. 442 rules, 37 tools, CLI + doctor. Host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. 71 CVE rules refreshed daily from GHSA/OSV/CISA KEV — Vite dev-server RCE, React Router 7 cluster, DOMPurify XSS, Better Auth bypass, 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).",
5
+ "description": "Security infrastructure your AI can't be — deterministic, current past your model's training cutoff, whole-repo-aware, author-independent. Security MCP for vibe coding. 442 rules, 38 tools, CLI + doctor. Prompt-level shift-left security (secure_prompt — embed security requirements BEFORE code generation), host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. 71 CVE rules refreshed daily from GHSA/OSV/CISA KEV — Vite dev-server RCE, React Router 7 cluster, DOMPurify XSS, Better Auth bypass, 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",