guardvibe 3.24.0 → 3.26.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,24 @@ 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.26.0] - 2026-06-25
9
+
10
+ ### Improved — AST engine: inter-procedural & nested ownership for BOLA/IDOR (no rule/tool count change: 450 rules / 39 tools)
11
+ - **VG950 (find-by-id BOLA) precision via the AST engine.** The ownership guard now also recognizes two real-world authorization shapes the same-function analysis structurally could not see: (1) an ownership field nested inside a relation filter (`members: { some: { userId } }`, `teams.some.team.members.some.userId`), and (2) an **inter-procedural** check — an authorization helper the function calls *before* the query, passing both a session value and the same id (`isAdminForUser(ctx.user.id, targetId)` → throw, then `findUnique({ where: { id: targetId } })`). The same inter-procedural guard now also applies to VG951 (delete/update BOLA).
12
+ - **Soundness preserved:** only a session/auth-derived ownership value counts — a request-controlled value (`req.body.UserId`) is attacker-chosen and keeps firing. Deterministic (bundled TypeScript parser, no resolution of the scanned project's copy).
13
+ - Corpus delta: 3 confirmed false positives removed, zero true positives lost, zero drift on other rules. 8 new tests.
14
+
15
+ Gate green (build / lint / test / self-audit PASS / A / 0).
16
+
17
+ ## [3.25.0] - 2026-06-24
18
+
19
+ ### Fixed — QA hardening pass (no rule/tool count change: 450 rules / 39 tools)
20
+ - **Pre-commit gate now actually blocks.** `scan --staged` (the command the installed pre-commit hook runs) was falling through to a whole-directory scan that always exited 0, so the hook never blocked an insecure commit. It now runs a staged scan and defaults to `--fail-on critical`. The slopsquat/typosquat detector no longer false-flags declared, popular packages (e.g. `cors`, `chai`, `sinon`) or first-party source dirs as hallucinated, and `--format` now errors on an unsupported (command, format) combo instead of silently emitting markdown (so `check --format sarif` produces real SARIF).
21
+ - **Robustness & accuracy:** `diff` / changed-files scans auto-detect the base branch (origin/HEAD → main → master → HEAD~1 → HEAD) instead of assuming `main`, with a clear "not a git repository" vs "ref not found" distinction; `check_dependencies` gained `format: json`; `secure_this` returns clean rule IDs; the edit hook no longer depends on `jq`; `guardvibe-scan --help`/`--version` and a non-zero exit on an unknown `explain <rule>` now work.
22
+ - **Docs:** corrected the CVE-rule count, the per-category rule table (now sums to the real total), and the dependency description; added consistency guards so those counts cannot silently drift again. +20 regression tests.
23
+
24
+ Gate green (build / lint / test / self-audit PASS / A / 0).
25
+
8
26
  ## [3.24.0] - 2026-06-23
9
27
 
10
28
  ### Added — 1 rule from daily intel: Clerk 4.x auth() IDOR version-pin (449 → 450 rules)
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  > **Security infrastructure your AI can't be.**
10
10
  > No matter how good your coding agent gets, it can't know the CVE published after its training cutoff, it can't deterministically guarantee the same check every run, it can't hold your whole repo in context, and it can't objectively review its own code. GuardVibe does all four — the deterministic, post-cutoff-current, whole-repo, author-independent verification layer for AI-written code.
11
11
 
12
- - **🗓️ Knows what your AI doesn't.** CVE rules refreshed **daily** from GHSA / OSV.dev / CISA KEV — GuardVibe flags vulnerable dependencies published *after* your model's training cutoff. (71 CVE rules, `npm run intel` daily triage.)
12
+ - **🗓️ Knows what your AI doesn't.** CVE rules refreshed **daily** from GHSA / OSV.dev / CISA KEV — GuardVibe flags vulnerable dependencies published *after* your model's training cutoff. (77 CVE rules, `npm run intel` daily triage.)
13
13
  - **🎯 Deterministic, not probabilistic.** Same code = same result, every run (content-hashed). Your AI guesses; GuardVibe doesn't.
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.
@@ -31,7 +31,7 @@ Most security tools are built for enterprise security teams. GuardVibe is built
31
31
  - **Zero setup friction** — `npx guardvibe` and you're scanning
32
32
  - **No account required** — runs 100% locally, no API keys, no cloud
33
33
  - **Understands your stack** — not generic SAST, but rules that know Next.js, Supabase, Stripe, Clerk, and the tools you actually use
34
- - **CVE version intelligence** — detects 71 known vulnerable package versions in package.json, refreshed every day from GHSA / OSV.dev / CISA KEV
34
+ - **CVE version intelligence** — detects 77 known vulnerable package versions in package.json, refreshed every day from GHSA / OSV.dev / CISA KEV
35
35
  - **AI agent & MCP security** — detects MCP server vulnerabilities, tool-description prompt injection (OWASP MCP Top 10), model-controlled sandbox-disable flags, excessive AI permissions, indirect prompt injection
36
36
  - **Auto-fix suggestions** — `fix_code` tool returns concrete patches and structured edits the AI agent can apply mechanically. Coverage: hardcoded credentials → env-var migration; public-prefix LLM keys (`NEXT_PUBLIC_/VITE_/EXPO_PUBLIC_/REACT_APP_`) → prefix removal; CORS wildcards → env allowlist; `dangerouslyAllowBrowser` flags → drop; sandbox bypass flags (`unsafe`/`noSandbox`/`allowEval`) → drop; agent loops → add `maxSteps`; raw-HTML React props → `<ReactMarkdown>`; missing auth checks → insert auth guard; SQL injection → parameterized queries; missing rate limiters / CSRF / security headers → snippet templates.
37
37
  - **Pre-commit hook** — block insecure code before it reaches your repo
@@ -62,7 +62,7 @@ GuardVibe is purpose-built for the AI coding workflow. Traditional tools are exc
62
62
  | AI/LLM security (prompt injection, MCP, tool abuse) | 68 rules | Experimental/None | None |
63
63
  | AI host security (CVE-2025-59536, CVE-2026-21852) | `guardvibe doctor` | Not supported | Not supported |
64
64
  | Auto-fix suggestions for AI agents | `fix_code` tool | CLI autofix | Not supported |
65
- | CVE version detection | 71 packages, refreshed daily | Extensive | Extensive |
65
+ | CVE version detection | 77 packages, refreshed daily | Extensive | Extensive |
66
66
  | Compliance mapping (SOC2, PCI-DSS, HIPAA) | Built-in | Paid tier | None |
67
67
  | SARIF CI/CD export | Yes | Yes | Limited |
68
68
  | Rule count | 450 (focused, 68 AI-native) | 5000+ (broad) | N/A |
@@ -190,7 +190,7 @@ React Native, Expo — AsyncStorage secrets, deep link token exposure, hardcoded
190
190
  ### Firebase
191
191
  Firestore security rules, Firebase Admin SDK exposure, storage rules, custom token validation
192
192
 
193
- ### CVE Version Intelligence (71 CVEs, refreshed daily)
193
+ ### CVE Version Intelligence (77 CVEs, refreshed daily)
194
194
  **Frameworks:** Next.js (CVE-2024-34351, CVE-2024-46982, CVE-2025-29927, CVE-2026-23869, CVE-2026-44573 / 44574 / 44575 / 44578 / 44579 / 45109 May 2026 cluster), React + react-server-dom-* (CVE-2025-55182, CVE-2026-23870), Express, Hono pre-4.12.18 cluster, @vitejs/plugin-rsc, Strapi content-type-builder (CVE-2026-22599)
195
195
  **Auth:** Clerk middleware bypass (GHSA-vqx2), Clerk `has()` org/billing/reverification bypass (GHSA-w24r), Clerk `clerkFrontendApiProxy` SSRF (CVE-2026-34076), NextAuth.js (2 CVEs), jsonwebtoken
196
196
  **ORMs / SQL:** Drizzle SQL identifier injection (CVE-2026-39356) + Drizzle `sql.raw` interpolation (VG1073), MikroORM SQL injection (CVE-2026-44680), Prisma raw-query call-form, Kysely JSON-path traversal (CVE-2026-44635)
@@ -306,30 +306,30 @@ The offline tier is also a `full_audit` section (online never runs inside the au
306
306
 
307
307
  | Category | Rules | Coverage |
308
308
  |----------|-------|----------|
309
- | Core OWASP | 38 | SQL injection, XSS, CSRF, command injection, CORS, SSRF, hardcoded secrets |
310
- | Next.js App Router | 17 | Server Actions, secret exposure, auth bypass, CSP, redirects |
311
- | Auth (Clerk / Auth.js / Supabase Auth) | 16 | Middleware, secret keys, session storage, role checks, SSR cookies |
312
- | Database (Supabase / Prisma / Drizzle) | 12 | Raw queries, client exposure, service role leaks, NoSQL injection, Drizzle identifier injection (CVE-2026-39356) |
313
- | OWASP API Security | 10 | BOLA/IDOR, mass assignment, pagination, rate limiting, error leaks |
314
- | Modern Stack | 40 | Zod, tRPC, Hono, GraphQL, Uploadthing, Turso, Convex, OAuth, CSP, webhooks, AI SDK, React Server Action validation (React2Shell) |
309
+ | Core OWASP | 39 | SQL injection, XSS, CSRF, command injection, CORS, SSRF, hardcoded secrets |
310
+ | Next.js App Router | 18 | Server Actions, secret exposure, auth bypass, CSP, redirects |
311
+ | Auth (Clerk / Auth.js / Supabase Auth) | 17 | Middleware, secret keys, session storage, role checks, SSR cookies |
312
+ | Database (Supabase / Prisma / Drizzle) | 13 | Raw queries, client exposure, service role leaks, NoSQL injection, Drizzle identifier injection (CVE-2026-39356) |
313
+ | OWASP API Security | 11 | BOLA/IDOR, mass assignment, pagination, rate limiting, error leaks |
314
+ | Modern Stack | 47 | Zod, tRPC, Hono, GraphQL, Uploadthing, Turso, Convex, OAuth, CSP, webhooks, AI SDK, React Server Action validation (React2Shell) |
315
315
  | Deployment Config | 21 | Vercel, Next.js config, Docker Compose, Fly, Render, Netlify, Cloudflare, K8s secrets |
316
316
  | Payments (Stripe / Polar / Lemon) | 9 | Webhook signatures, key exposure, price manipulation |
317
317
  | Services (Resend / Upstash / Pinecone / PostHog) | 11 | API key leaks, PII tracking, email injection |
318
- | Web Security | 15 | Webhooks, CSP, .env safety, AI key exposure, cookie handling |
318
+ | Web Security | 20 | Webhooks, CSP, .env safety, AI key exposure, cookie handling |
319
319
  | React Native / Expo | 10 | AsyncStorage secrets, deep links, ATS, hardcoded URLs |
320
320
  | Firebase | 7 | Firestore rules, admin SDK, storage, custom tokens |
321
- | AI / LLM Security | 16 | Prompt injection, MCP SSRF, excessive agency, indirect injection |
322
- | **AI Host Security** | **10** | **CVE-2025-59536 hook injection, CVE-2026-21852 base URL hijack, MCP config audit** |
323
- | **AI Tool Runtime** | **4** | **MCP tool output sanitization, obfuscated descriptions, safety bypass** |
324
- | CVE Version Intelligence | 31 | Known vulnerable versions in package.json — incl. Vite dev-server cmd injection (CVE-2024-52011), React Router 7 cluster (CVE-2026-33245/42211/42342), DOMPurify XSS (CVE-2026-47423), Better Auth bypass (CVE-2026-45337), Axios supply-chain backdoor |
321
+ | AI / LLM Security | 33 | Prompt injection, MCP SSRF, excessive agency, indirect injection |
322
+ | **AI Host Security** | **14** | **CVE-2025-59536 hook injection, CVE-2026-21852 base URL hijack, MCP config audit** |
323
+ | **AI Tool Runtime** | **14** | **MCP tool output sanitization, obfuscated descriptions, safety bypass** |
324
+ | CVE Version Intelligence | 75 | Known vulnerable versions in package.json — incl. Vite dev-server cmd injection (CVE-2024-52011), React Router 7 cluster (CVE-2026-33245/42211/42342), DOMPurify XSS (CVE-2026-47423), Better Auth bypass (CVE-2026-45337), Axios supply-chain backdoor |
325
325
  | Shell / Bash | 5 | Pipe to bash, chmod 777, rm -rf, sudo password |
326
326
  | SQL | 4 | DROP/DELETE without WHERE, stacked queries, GRANT ALL |
327
- | Supply Chain | 16 | Malicious install scripts, lockfile integrity, dependency confusion, typosquat detection |
327
+ | Supply Chain | 19 | Malicious install scripts, lockfile integrity, dependency confusion, typosquat detection |
328
328
  | Go | 6 | SQL injection, command injection, template escaping |
329
329
  | Dockerfile | 7 | Root user, secrets in ENV, untagged images, non-root user |
330
- | CI/CD (GitHub Actions) | 7 | Secrets interpolation, unpinned actions, write-all permissions |
330
+ | CI/CD (GitHub Actions) | 8 | Secrets interpolation, unpinned actions, write-all permissions |
331
331
  | Terraform | 6 | Public S3, open security groups, IAM wildcards |
332
- | Advanced Security | 21 | ReDoS, CRLF injection, race conditions, XXE, brute force, audit logging |
332
+ | Advanced Security | 31 | ReDoS, CRLF injection, race conditions, XXE, brute force, audit logging |
333
333
  | Other Services | 5 | AWS, GCP, MongoDB, Convex, Sentry, Twilio |
334
334
 
335
335
  ## CLI Commands
@@ -376,12 +376,16 @@ npx guardvibe ci github # Generate GitHub Actions workflow
376
376
  npx guardvibe-scan # Scan staged files (for pre-commit)
377
377
  npx guardvibe-scan --format sarif --output results.sarif # CI mode
378
378
 
379
- # Options (all scan commands)
380
- # --format markdown|json|sarif|buddy|agent
379
+ # Options (scan commands)
380
+ # --format <type> scan / diff: markdown|json|sarif
381
+ # check: markdown|json|sarif|buddy|agent
381
382
  # agent = guardvibe.agent.v1 — per finding: { id, severity, confidence, exactEdit, manualFix, verify }
382
383
  # so an AI agent can apply the exact edit and run the verify step to prove the fix
384
+ # (an unsupported format errors rather than silently falling back to markdown)
383
385
  # --output <file> Write results to file
384
- # --fail-on <level> Exit 1 on findings: critical|high|medium|low|none
386
+ # --fail-on <level> critical|high|medium|low|none — exit 1 when a finding at/above this level exists
387
+ # check, audit, and the pre-commit gate (guardvibe-scan / scan --staged) gate on
388
+ # critical by DEFAULT; scan and diff are reports (exit 0) unless --fail-on is passed
385
389
  # --full Bypass response-size caps (50 JSON / 30 markdown / 200-file taint)
386
390
  ```
387
391
 
@@ -630,7 +634,7 @@ GuardVibe takes supply chain security seriously:
630
634
  - **Branch protection** — force push disabled on main, admin enforcement enabled
631
635
  - **Tag protection** — version tags (`v*`) cannot be deleted or force-pushed
632
636
  - **Minimal CI permissions** — GitHub Actions workflows use `permissions: contents: read` only
633
- - **Minimal, fully-audited runtime dependencies** — only the MCP SDK, Zod, and the TypeScript compiler (used for AST-based dataflow analysis). All three are widely-audited, zero-sub-dependency packages — no native bindings, no obscure transitive deps
637
+ - **Minimal, fully-audited runtime dependencies** — only three direct dependencies: the MCP SDK, Zod, and the TypeScript compiler (used for AST-based dataflow analysis). Zod and TypeScript are zero-sub-dependency, pure-JS packages. The MCP SDK pulls a small set of widely-used, audited transitive packages (e.g. `express`, `cors`, `ajv`) for its optional HTTP transport GuardVibe itself runs over stdio. No native bindings anywhere in the tree, and all code analysis runs 100% locally and offline
634
638
 
635
639
  To report a vulnerability, please email info@goklab.com or open a GitHub issue.
636
640
 
@@ -2,7 +2,7 @@
2
2
  * CLI argument parsing utilities
3
3
  */
4
4
  export declare function getStringFlag(flags: Record<string, string | true>, key: string): string | null;
5
- export declare function validateFormat(flags: Record<string, string | true>): string;
5
+ export declare function validateFormat(flags: Record<string, string | true>, supported?: string[]): string;
6
6
  export declare function getOutputPath(flags: Record<string, string | true>): string | null;
7
7
  export declare function parseArgs(args: string[]): {
8
8
  flags: Record<string, string | true>;
package/build/cli/args.js CHANGED
@@ -8,10 +8,17 @@ export function getStringFlag(flags, key) {
8
8
  return null;
9
9
  return val;
10
10
  }
11
- export function validateFormat(flags) {
11
+ export function validateFormat(flags, supported) {
12
12
  const format = getStringFlag(flags, "format") ?? "markdown";
13
13
  if (!VALID_FORMATS.has(format)) {
14
- console.error(` [ERR] Invalid format "${format}". Use: markdown, json, sarif, or buddy`);
14
+ console.error(` [ERR] Invalid format "${format}". Use: markdown, json, sarif, buddy, or agent`);
15
+ process.exit(1);
16
+ }
17
+ // Command-aware: a format may be globally valid but unsupported by THIS command.
18
+ // Error explicitly rather than silently degrading to markdown (which produced
19
+ // wrong output, e.g. `check --format sarif` writing markdown to a .sarif file).
20
+ if (supported && !supported.includes(format)) {
21
+ console.error(` [ERR] Format "${format}" is not supported by this command. Supported: ${supported.join(", ")}.`);
15
22
  process.exit(1);
16
23
  }
17
24
  return format;
@@ -14,4 +14,15 @@ export async function runExplain(args) {
14
14
  const format = (flags.format === "json" ? "json" : "markdown");
15
15
  const result = explainRemediation(ruleId, undefined, format);
16
16
  console.log(result);
17
+ // Unknown rule id is an error, not a successful lookup — exit 1 so scripts can tell.
18
+ const notFound = format === "json"
19
+ ? (() => { try {
20
+ return Boolean(JSON.parse(result).error);
21
+ }
22
+ catch {
23
+ return false;
24
+ } })()
25
+ : /^Rule .* not found\.?$/.test(result.trim());
26
+ if (notFound)
27
+ process.exit(1);
17
28
  }
package/build/cli/init.js CHANGED
@@ -99,7 +99,11 @@ function addToGitignore(entries) {
99
99
  const missing = entries.filter(e => !content.split("\n").some(line => line.trim() === e));
100
100
  if (missing.length === 0)
101
101
  return;
102
- const block = `\n# GuardVibe (auto-added by guardvibe init)\n${missing.join("\n")}\n`;
102
+ // Only write the header comment once `init all` runs this per platform, so a
103
+ // repeated header would otherwise stack up (3×) in the same .gitignore.
104
+ const header = "# GuardVibe (auto-added by guardvibe init)";
105
+ const hasHeader = content.split("\n").some(line => line.trim() === header);
106
+ const block = hasHeader ? `\n${missing.join("\n")}\n` : `\n${header}\n${missing.join("\n")}\n`;
103
107
  writeFileSync(gitignorePath, content.trimEnd() + block, "utf-8");
104
108
  console.log(` [OK] Added ${missing.join(", ")} to .gitignore`);
105
109
  }
@@ -111,7 +115,10 @@ function setupClaudeGuide() {
111
115
  const existingSettings = readJsonFile(claudeSettingsPath) || {};
112
116
  if (!existingSettings.hooks)
113
117
  existingSettings.hooks = {};
114
- const hookCommand = `jq -r '.tool_input.file_path' | xargs npx -y guardvibe@${pkg.version} check --format buddy 2>/dev/null || true`;
118
+ // Extract the edited file path with node (always present no `jq` dependency, which
119
+ // when absent made the hook a silent no-op) and pass it as an argv (no shell injection
120
+ // via filename). Errors are swallowed so a post-edit scan never blocks editing.
121
+ const hookCommand = `node -e "const fs=require('fs');let s='';try{s=fs.readFileSync(0,'utf8')}catch(e){}try{const p=(JSON.parse(s).tool_input||{}).file_path;if(p)require('child_process').execFileSync('npx',['-y','guardvibe@${pkg.version}','check',p,'--format','buddy'],{stdio:'inherit'})}catch(e){}" 2>/dev/null || true`;
115
122
  if (!existingSettings.hooks.PostToolUse) {
116
123
  existingSettings.hooks.PostToolUse = [
117
124
  {
@@ -126,7 +133,7 @@ function setupClaudeGuide() {
126
133
  // and in lock-step with the pinned MCP server.
127
134
  for (const entry of existingSettings.hooks.PostToolUse) {
128
135
  for (const h of entry?.hooks ?? []) {
129
- if (typeof h.command === "string" && /npx\s+-y\s+guardvibe@[^\s]+\s+check/.test(h.command)) {
136
+ if (typeof h.command === "string" && /guardvibe@[^\s'"]+/.test(h.command) && /\bcheck\b/.test(h.command)) {
130
137
  h.command = hookCommand;
131
138
  }
132
139
  }
@@ -2,9 +2,17 @@
2
2
  * CLI: guardvibe scan [path], guardvibe diff [base], guardvibe check <file>
3
3
  * Also: guardvibe-scan (pre-commit hook / CI entry point)
4
4
  */
5
+ /**
6
+ * Pre-commit / staged-files scan. Used by the `guardvibe-scan` bin AND by
7
+ * `guardvibe scan --staged` (the generated pre-commit hook calls the latter). This is a
8
+ * GATE: it defaults to `--fail-on critical` so a non-zero exit actually blocks the commit
9
+ * — previously `scan --staged` fell through to a whole-directory scan that exited 0,
10
+ * so the hook never blocked anything.
11
+ */
12
+ export declare function runStagedScan(flags: Record<string, string | true>): Promise<void>;
5
13
  export declare function runScan(): Promise<void>;
6
14
  export declare function runDirectoryScan(targetPath: string, flags: Record<string, string | true>): Promise<void>;
7
- export declare function runDiffScan(base: string, flags: Record<string, string | true>): Promise<void>;
15
+ export declare function runDiffScan(requestedBase: string | undefined, flags: Record<string, string | true>): Promise<void>;
8
16
  export declare function runFileCheck(filePath: string, flags: Record<string, string | true>): Promise<void>;
9
17
  export declare function handleScanCommand(args: string[]): Promise<void>;
10
18
  export declare function handleDiffCommand(args: string[]): Promise<void>;
package/build/cli/scan.js CHANGED
@@ -14,10 +14,15 @@ function safeWriteOutput(outputFile, result) {
14
14
  writeFileSync(outputFile, result, "utf-8");
15
15
  console.log(` [OK] Results written to ${outputFile}`);
16
16
  }
17
- export async function runScan() {
18
- const args = process.argv.slice(2);
19
- const { flags } = parseArgs(args);
20
- const format = validateFormat(flags);
17
+ /**
18
+ * Pre-commit / staged-files scan. Used by the `guardvibe-scan` bin AND by
19
+ * `guardvibe scan --staged` (the generated pre-commit hook calls the latter). This is a
20
+ * GATE: it defaults to `--fail-on critical` so a non-zero exit actually blocks the commit
21
+ * — previously `scan --staged` fell through to a whole-directory scan that exited 0,
22
+ * so the hook never blocked anything.
23
+ */
24
+ export async function runStagedScan(flags) {
25
+ const format = validateFormat(flags, ["markdown", "json", "sarif"]);
21
26
  const outputFile = getOutputPath(flags);
22
27
  let result;
23
28
  if (format === "sarif") {
@@ -34,15 +39,20 @@ export async function runScan() {
34
39
  else {
35
40
  console.log(result);
36
41
  }
37
- if (format !== "sarif" && flags["fail-on"]) {
42
+ // Default to gating on critical — this path is a commit/CI gate, not a report.
43
+ if (format !== "sarif") {
38
44
  const failOn = getStringFlag(flags, "fail-on") ?? "critical";
39
45
  if (shouldFail(result, failOn))
40
46
  process.exit(1);
41
47
  }
42
48
  }
49
+ export async function runScan() {
50
+ const { flags } = parseArgs(process.argv.slice(2));
51
+ await runStagedScan(flags);
52
+ }
43
53
  export async function runDirectoryScan(targetPath, flags) {
44
54
  const { scanDirectory } = await import("../tools/scan-directory.js");
45
- const format = validateFormat(flags);
55
+ const format = validateFormat(flags, ["markdown", "json", "sarif"]);
46
56
  const outputFile = getOutputPath(flags);
47
57
  const baselinePath = getStringFlag(flags, "baseline");
48
58
  const saveBaseline = flags["save-baseline"] === true || typeof flags["save-baseline"] === "string";
@@ -68,20 +78,30 @@ export async function runDirectoryScan(targetPath, flags) {
68
78
  : join(scanPath, ".guardvibe-baseline.json");
69
79
  safeWriteOutput(baselineFile, result);
70
80
  }
81
+ // `scan <dir>` is a report; it gates only when --fail-on is explicitly requested
82
+ // (the pre-commit gate is `scan --staged`, which gates by default).
71
83
  if (format !== "sarif" && flags["fail-on"]) {
72
84
  const failOn = getStringFlag(flags, "fail-on") ?? "critical";
73
85
  if (shouldFail(result, failOn))
74
86
  process.exit(1);
75
87
  }
76
88
  }
77
- export async function runDiffScan(base, flags) {
89
+ export async function runDiffScan(requestedBase, flags) {
78
90
  const { execFileSync } = await import("child_process");
79
91
  const { analyzeFileSecurity } = await import("../tools/file-security.js");
80
92
  const { EXTENSION_MAP, CONFIG_FILE_MAP } = await import("../utils/constants.js");
81
- const { getAddedLinesForDiff, filterToAddedLines } = await import("../tools/diff-aware.js");
82
- const format = validateFormat(flags);
93
+ const { getAddedLinesForDiff, filterToAddedLines, resolveGitBase } = await import("../tools/diff-aware.js");
94
+ const format = validateFormat(flags, ["markdown", "json"]);
83
95
  const outputFile = getOutputPath(flags);
84
96
  const root = resolve(".");
97
+ // Resolve a usable base: honor an explicit ref (error if it's a typo), otherwise
98
+ // auto-detect (origin/HEAD → main → master → HEAD~1 → HEAD) instead of assuming `main`.
99
+ const resolved = resolveGitBase(root, requestedBase, { strict: requestedBase !== undefined });
100
+ if (!resolved.ok) {
101
+ console.error(` [ERR] ${resolved.error}`);
102
+ process.exit(1);
103
+ }
104
+ const base = resolved.base;
85
105
  // Diff-aware by default: report only issues on newly-added lines. --all-lines
86
106
  // restores the old whole-changed-file behavior (surfaces pre-existing debt too).
87
107
  const allLines = flags["all-lines"] === true;
@@ -91,7 +111,7 @@ export async function runDiffScan(base, flags) {
91
111
  changedFiles = output.trim().split("\n").filter(Boolean);
92
112
  }
93
113
  catch {
94
- console.error(" [ERR] Failed to get git diff. Ensure you're in a git repository.");
114
+ console.error(` [ERR] git diff against "${base}" failed.`);
95
115
  process.exit(1);
96
116
  }
97
117
  if (changedFiles.length === 0) {
@@ -205,10 +225,16 @@ export async function runFileCheck(filePath, flags) {
205
225
  console.error(` [ERR] Unsupported file type: ${ext}`);
206
226
  process.exit(1);
207
227
  }
208
- const format = validateFormat(flags);
228
+ const format = validateFormat(flags, ["markdown", "json", "sarif", "buddy", "agent"]);
209
229
  const findings = analyzeFileSecurity(content, language, undefined, resolved, undefined);
210
230
  let result;
211
- if (format === "agent") {
231
+ if (format === "sarif") {
232
+ // SARIF for a single file (CI upload of one path) — was previously a silent
233
+ // markdown fallback, which corrupted `--output results.sarif`.
234
+ const { exportSarif } = await import("../tools/export-sarif.js");
235
+ result = exportSarif(resolved);
236
+ }
237
+ else if (format === "agent") {
212
238
  // Agent-native contract: finding + exact-edit + confidence + verify step.
213
239
  const { buildAgentReport } = await import("../tools/agent-output.js");
214
240
  result = JSON.stringify(buildAgentReport(findings, content, language, resolved));
@@ -231,6 +257,12 @@ export async function runFileCheck(filePath, flags) {
231
257
  }
232
258
  export async function handleScanCommand(args) {
233
259
  const { flags, positional } = parseArgs(args);
260
+ // `scan --staged` is the pre-commit gate (the installed hook calls it). It must run
261
+ // the staged-files scan, not silently fall through to a whole-directory scan.
262
+ if (flags["staged"]) {
263
+ await runStagedScan(flags);
264
+ return;
265
+ }
234
266
  const targetPath = positional[0] ?? ".";
235
267
  if (targetPath !== "." && existsSync(targetPath) && !statSync(targetPath).isDirectory()) {
236
268
  console.log(` [INFO] "${targetPath}" is a file. Running: guardvibe check ${targetPath}\n`);
@@ -242,8 +274,7 @@ export async function handleScanCommand(args) {
242
274
  }
243
275
  export async function handleDiffCommand(args) {
244
276
  const { flags, positional } = parseArgs(args);
245
- const base = positional[0] ?? "main";
246
- await runDiffScan(base, flags);
277
+ await runDiffScan(positional[0], flags);
247
278
  }
248
279
  export async function handleCheckCommand(args) {
249
280
  const { flags, positional } = parseArgs(args);
package/build/cli.js CHANGED
@@ -10,8 +10,17 @@ checkForUpdate(pkg.version);
10
10
  const SCAN_SCRIPT_DETECTED = process.argv[1]?.endsWith("guardvibe-scan") ||
11
11
  process.argv[1]?.endsWith("guardvibe-scan.js");
12
12
  if (SCAN_SCRIPT_DETECTED) {
13
- const { runScan } = await import("./cli/scan.js");
14
- await runScan();
13
+ const scanArgs = process.argv.slice(2);
14
+ if (scanArgs.includes("--version") || scanArgs.includes("-V")) {
15
+ console.log(pkg.version);
16
+ }
17
+ else if (scanArgs.includes("--help") || scanArgs.includes("-h")) {
18
+ printUsage();
19
+ }
20
+ else {
21
+ const { runScan } = await import("./cli/scan.js");
22
+ await runScan();
23
+ }
15
24
  }
16
25
  else {
17
26
  await main();
package/build/index.js CHANGED
@@ -154,9 +154,10 @@ server.tool("check_dependencies", "Check npm, PyPI, or Go packages for known sec
154
154
  }
155
155
  return val;
156
156
  }, z.array(packageSchema)).describe("List of packages to check: [{name, version, ecosystem}]"),
157
- }, async ({ packages }) => {
157
+ format: z.enum(["markdown", "json"]).default("markdown").describe("Output format: markdown (human) or json (machine-readable for agents)"),
158
+ }, async ({ packages, format }) => {
158
159
  try {
159
- const results = await checkDependencies(packages);
160
+ const results = await checkDependencies(packages, format);
160
161
  return { content: [{ type: "text", text: results }] };
161
162
  }
162
163
  catch (err) {
@@ -592,15 +593,22 @@ server.tool("scan_changed_files", "Scan only files that have changed since a giv
592
593
  const { readFileSync, existsSync } = await import("fs");
593
594
  const { resolve, extname, basename } = await import("path");
594
595
  const { EXTENSION_MAP, CONFIG_FILE_MAP } = await import("./utils/constants.js");
595
- const { getAddedLinesForDiff, filterToAddedLines } = await import("./tools/diff-aware.js");
596
+ const { getAddedLinesForDiff, filterToAddedLines, resolveGitBase } = await import("./tools/diff-aware.js");
596
597
  const root = resolve(repoPath);
598
+ // Resolve the base: fall back from the default ref (origin/HEAD → main → master →
599
+ // HEAD~1 → HEAD) so single-commit / master-named repos work; precise error otherwise.
600
+ const resolution = resolveGitBase(root, base, { strict: false });
601
+ if (!resolution.ok) {
602
+ return { content: [{ type: "text", text: format === "json" ? JSON.stringify({ error: resolution.error }) : `Error: ${resolution.error}` }] };
603
+ }
604
+ const effectiveBase = resolution.base;
597
605
  let changedFiles;
598
606
  try {
599
- const output = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", base], { cwd: root, encoding: "utf-8" });
607
+ const output = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", effectiveBase], { cwd: root, encoding: "utf-8" });
600
608
  changedFiles = output.trim().split("\n").filter(Boolean);
601
609
  }
602
610
  catch {
603
- return { content: [{ type: "text", text: format === "json" ? JSON.stringify({ error: "Failed to get git diff" }) : "Error: Failed to get git diff. Ensure you're in a git repository." }] };
611
+ return { content: [{ type: "text", text: format === "json" ? JSON.stringify({ error: `git diff against ${effectiveBase} failed` }) : `Error: git diff against ${effectiveBase} failed.` }] };
604
612
  }
605
613
  if (changedFiles.length === 0) {
606
614
  const empty = format === "json"
@@ -627,7 +635,7 @@ server.tool("scan_changed_files", "Scan only files that have changed since a giv
627
635
  const content = readFileSync(fullPath, "utf-8");
628
636
  let findings = analyzeFileSecurity(content, language, undefined, fullPath, root, rules);
629
637
  if (diff_aware) {
630
- const added = getAddedLinesForDiff(base, relPath, root);
638
+ const added = getAddedLinesForDiff(effectiveBase, relPath, root);
631
639
  const kept = filterToAddedLines(findings, added);
632
640
  preExistingHidden += findings.length - kept.length;
633
641
  findings = kept;
@@ -150,6 +150,96 @@ const OWNERSHIP_FIELDS = new Set([
150
150
  // A comparison of a fetched resource's ownership field, on a line that also references a session/user.
151
151
  const OWNERSHIP_COMPARE = /\.\s*(?:userId|ownerId|authorId|createdById|teamId|workspaceId|orgId|organizationId|tenantId|memberId|accountId|projectId)\b\s*(?:===|!==|==|!=)/i;
152
152
  const SESSION_REF = /\b(?:session|ctx|auth|currentUser|viewer|member|account|workspace|team|org|self|me|user)\b/i;
153
+ // A value text that is directly request/route-controlled is attacker-chosen, so an
154
+ // ownership field scoped to it (`UserId: req.body.UserId`, `workspaceId: params.x`)
155
+ // is NOT a real guard — the request can name any owner. Only session/auth-derived
156
+ // values count. (Mirrors the existing top-level `params|searchParams` exclusion,
157
+ // extended to req/request so juice-shop's `req.body.UserId` scoping keeps firing.)
158
+ const REQUEST_CONTROLLED = /\b(?:req|request|params|searchParams)\b/;
159
+ /**
160
+ * Recursively scan a `where` object literal for an ownership field (at any nesting
161
+ * depth, e.g. `members: { some: { userId: ... } }`) whose value is session-derived
162
+ * (not request-controlled). The line/regex engine and the prior top-level-only scan
163
+ * miss ownership nested inside relation filters.
164
+ */
165
+ function whereHasNestedOwnership(ts, sf, obj) {
166
+ for (const prop of obj.properties) {
167
+ const nm = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
168
+ if (ts.isPropertyAssignment(prop)) {
169
+ if (nm && OWNERSHIP_FIELDS.has(nm)) {
170
+ const valText = prop.initializer.getText(sf);
171
+ if (!REQUEST_CONTROLLED.test(valText))
172
+ return true;
173
+ }
174
+ if (ts.isObjectLiteralExpression(prop.initializer) && whereHasNestedOwnership(ts, sf, prop.initializer))
175
+ return true;
176
+ }
177
+ else if (ts.isShorthandPropertyAssignment(prop) && nm && OWNERSHIP_FIELDS.has(nm)) {
178
+ // `where: { userId }` — the bound variable carries the ownership scope.
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+ // An authz/ownership-check helper: an action verb + an authz noun (isAdminForUser,
185
+ // assertOwnership, checkAccess, requirePermission, ensureMemberRole…) or a bare
186
+ // authorize/authorise. Names like formatId/getUserById deliberately do NOT match.
187
+ const AUTHZ_HELPER = /^(?:authoris|authoriz)e|^(?:is|assert|ensure|require|check|verify|can|has|validate|guard|protect|enforce)[A-Za-z]*(?:owner|admin|member|access|permission|auth|allowed|belongs|role)/i;
188
+ /** The text of the `where.id` value (or the call's first-arg id) the call is keyed by. */
189
+ function findKeyedIdText(ts, sf, call) {
190
+ const arg0 = call.arguments[0];
191
+ if (arg0 && ts.isObjectLiteralExpression(arg0)) {
192
+ const whereProp = arg0.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "where");
193
+ const whereObj = whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)
194
+ ? whereProp.initializer : arg0;
195
+ const idProp = whereObj.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "id");
196
+ if (idProp && ts.isPropertyAssignment(idProp))
197
+ return idProp.initializer.getText(sf);
198
+ }
199
+ return undefined;
200
+ }
201
+ /**
202
+ * Inter-procedural ownership guard: the enclosing function calls an authz-named
203
+ * helper BEFORE the find/mutation, passing both a session value and the same id the
204
+ * query is keyed by (`isAdminForUser(ctx.user.id, input.forUserId)` → throw, then
205
+ * `findUnique({ where: { id: input.forUserId } })`). This is the case VG950/VG951's
206
+ * same-function analysis structurally can't see. Conservative on every axis (authz
207
+ * name + session ref + exact id-sharing + textually-before) so an unrelated guard
208
+ * can't hide a real BOLA.
209
+ */
210
+ function hasInterProceduralOwnershipGuard(ts, sf, target) {
211
+ const idText = findKeyedIdText(ts, sf, target);
212
+ // Require a specific id expression (a member access or a sufficiently long name);
213
+ // a bare `id` is too generic to match a helper argument soundly.
214
+ if (!idText || (!idText.includes(".") && idText.length < 5))
215
+ return false;
216
+ let fn = target;
217
+ while (fn && !(ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isArrowFunction(fn) || ts.isMethodDeclaration(fn))) {
218
+ fn = fn.parent;
219
+ }
220
+ if (!fn)
221
+ return false;
222
+ const targetStart = target.getStart(sf);
223
+ let guarded = false;
224
+ const visit = (node) => {
225
+ if (guarded)
226
+ return;
227
+ if (ts.isCallExpression(node) && node !== target && node.getStart(sf) < targetStart) {
228
+ const callee = node.expression;
229
+ const method = ts.isPropertyAccessExpression(callee) ? callee.name.text
230
+ : ts.isIdentifier(callee) ? callee.text : undefined;
231
+ if (method && AUTHZ_HELPER.test(method)) {
232
+ const argsText = node.arguments.map(a => a.getText(sf)).join(", ");
233
+ if (SESSION_REF.test(argsText) && argsText.includes(idText))
234
+ guarded = true;
235
+ }
236
+ }
237
+ if (!guarded)
238
+ ts.forEachChild(node, visit);
239
+ };
240
+ visit(fn);
241
+ return guarded;
242
+ }
153
243
  /** The first CallExpression near `line` whose last-identifier method is in `methods`. */
154
244
  function callNearLine(ts, sf, line, methods) {
155
245
  let target;
@@ -212,23 +302,22 @@ export function bolaOwnershipGuarded(code, filePath, line) {
212
302
  const target = callNearLine(ts, sf, line, FIND_METHODS);
213
303
  if (!target)
214
304
  return false;
215
- // (1) ownership field in the WHERE clause with a non-param value.
305
+ // (1) ownership field in the WHERE clause with a non-param value — now scanned
306
+ // recursively so ownership nested inside a relation filter (`members.some.userId`)
307
+ // counts too, with a session-derived (not request-controlled) value.
216
308
  const arg0 = target.arguments[0];
217
309
  if (arg0 && ts.isObjectLiteralExpression(arg0)) {
218
310
  const whereProp = arg0.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "where");
219
- if (whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)) {
220
- for (const prop of whereProp.initializer.properties) {
221
- const nm = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
222
- if (nm && OWNERSHIP_FIELDS.has(nm)) {
223
- const valText = ts.isPropertyAssignment(prop) ? prop.initializer.getText(sf) : nm;
224
- if (!/\b(?:params|searchParams)\b/.test(valText))
225
- return true;
226
- }
227
- }
311
+ if (whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)
312
+ && whereHasNestedOwnership(ts, sf, whereProp.initializer)) {
313
+ return true;
228
314
  }
229
315
  }
230
316
  // (2) post-fetch ownership comparison against a session/user value, in the same function.
231
- return hasPostFetchOwnershipGuard(ts, sf, target);
317
+ if (hasPostFetchOwnershipGuard(ts, sf, target))
318
+ return true;
319
+ // (3) inter-procedural: an authz helper checks session + this id before the find.
320
+ return hasInterProceduralOwnershipGuard(ts, sf, target);
232
321
  }
233
322
  /**
234
323
  * BOLA ownership-guard detection for VG951 (delete/update). The rule's regex
@@ -253,7 +342,11 @@ export function bolaMutationGuarded(code, filePath, line) {
253
342
  const target = callNearLine(ts, sf, line, MUTATION_METHODS);
254
343
  if (!target)
255
344
  return false;
256
- return hasPostFetchOwnershipGuard(ts, sf, target);
345
+ // Same-function post-fetch comparison, OR an inter-procedural authz helper that
346
+ // checked session + this id before the mutation (the helper-guard blind spot).
347
+ if (hasPostFetchOwnershipGuard(ts, sf, target))
348
+ return true;
349
+ return hasInterProceduralOwnershipGuard(ts, sf, target);
257
350
  }
258
351
  const ITER_METHODS = new Set(["map", "forEach", "some", "every", "filter", "find", "findIndex", "reduce", "flatMap"]);
259
352
  /** First `const NAME = <initializer>` for NAME anywhere in the file (file-scope-ish). */
@@ -1535,9 +1535,12 @@ function isDuplicatePair(a, b) {
1535
1535
  const bIsAuth = authPatterns.some(p => b.rule.name.includes(p));
1536
1536
  if (aIsAuth && bIsAuth)
1537
1537
  return true;
1538
- // Both are CORS wildcard rules — VG040+VG403+VG973 duplicate case
1539
- const aIsCors = a.rule.name.includes("CORS") && a.rule.name.includes("ildcard");
1540
- const bIsCors = b.rule.name.includes("CORS") && b.rule.name.includes("ildcard");
1538
+ // Both are CORS misconfiguration rules on the SAME line same CORS construct, so the
1539
+ // generic VG040 ("CORS wildcard") is redundant next to a more specific one like VG1094
1540
+ // ("CORS Origin Reflection With Credentials", CVE-2026-54290) or VG973 (Hono). Keep the
1541
+ // most specific (isMoreSpecific); a line with only one CORS finding is untouched (0-FN).
1542
+ const aIsCors = a.rule.name.includes("CORS");
1543
+ const bIsCors = b.rule.name.includes("CORS");
1541
1544
  if (aIsCors && bIsCors)
1542
1545
  return true;
1543
1546
  // Both are admin role check rules — VG426+VG957 duplicate case
@@ -3,5 +3,5 @@ interface PackageInput {
3
3
  version: string;
4
4
  ecosystem: string;
5
5
  }
6
- export declare function checkDependencies(packages: PackageInput[]): Promise<string>;
6
+ export declare function checkDependencies(packages: PackageInput[], format?: "markdown" | "json"): Promise<string>;
7
7
  export {};
@@ -1,5 +1,28 @@
1
1
  import { queryOsv, formatVulnerability } from "../utils/osv-client.js";
2
- export async function checkDependencies(packages) {
2
+ export async function checkDependencies(packages, format = "markdown") {
3
+ // JSON output for agents (parity with scan_dependencies, which already supports it).
4
+ if (format === "json") {
5
+ const pkgResults = [];
6
+ let total = 0;
7
+ for (const pkg of packages) {
8
+ try {
9
+ const vulns = await queryOsv(pkg.name, pkg.version, pkg.ecosystem);
10
+ total += vulns.length;
11
+ pkgResults.push({ name: pkg.name, version: pkg.version, ecosystem: pkg.ecosystem, vulnerabilities: vulns });
12
+ }
13
+ catch (error) {
14
+ pkgResults.push({ name: pkg.name, version: pkg.version, ecosystem: pkg.ecosystem, vulnerabilities: [], error: error instanceof Error ? error.message : "Unknown error" });
15
+ }
16
+ }
17
+ return JSON.stringify({
18
+ schema: "guardvibe.check-dependencies.v1",
19
+ database: "OSV",
20
+ packagesChecked: packages.length,
21
+ totalVulnerabilities: total,
22
+ vulnerablePackages: pkgResults.filter(p => p.vulnerabilities.length > 0).length,
23
+ packages: pkgResults,
24
+ });
25
+ }
3
26
  const results = [
4
27
  `# GuardVibe Dependency Security Report`,
5
28
  ``,
@@ -11,3 +11,21 @@ export declare function filterToAddedLines<T extends {
11
11
  export declare function getAddedLinesForDiff(base: string, relPath: string, cwd: string): Set<number>;
12
12
  /** Lines added in `relPath` in the staged (index) changes — for pre-commit gating. */
13
13
  export declare function getAddedLinesStaged(relPath: string, cwd: string): Set<number>;
14
+ export interface BaseResolution {
15
+ ok: boolean;
16
+ base?: string;
17
+ error?: string;
18
+ }
19
+ /**
20
+ * Resolve a usable diff base. Distinguishes "not a git repository" from "that ref does
21
+ * not exist here" (the old code conflated both as "Ensure you're in a git repository"),
22
+ * and — when no base is explicitly requested — auto-detects instead of hard-coding `main`
23
+ * (which fails on `master`-named or freshly-initialized repos). Fallback order:
24
+ * origin/HEAD → main → master → HEAD~1 → HEAD (uncommitted changes).
25
+ *
26
+ * @param strict when a base IS requested but missing, error instead of falling back
27
+ * (used by the CLI so a typo'd ref is reported, not silently swapped).
28
+ */
29
+ export declare function resolveGitBase(cwd: string, requested?: string, opts?: {
30
+ strict?: boolean;
31
+ }): BaseResolution;
@@ -76,3 +76,39 @@ export function getAddedLinesForDiff(base, relPath, cwd) {
76
76
  export function getAddedLinesStaged(relPath, cwd) {
77
77
  return gitDiffAddedLines(["--cached"], relPath, cwd);
78
78
  }
79
+ function gitTry(cwd, args) {
80
+ try {
81
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ /**
88
+ * Resolve a usable diff base. Distinguishes "not a git repository" from "that ref does
89
+ * not exist here" (the old code conflated both as "Ensure you're in a git repository"),
90
+ * and — when no base is explicitly requested — auto-detects instead of hard-coding `main`
91
+ * (which fails on `master`-named or freshly-initialized repos). Fallback order:
92
+ * origin/HEAD → main → master → HEAD~1 → HEAD (uncommitted changes).
93
+ *
94
+ * @param strict when a base IS requested but missing, error instead of falling back
95
+ * (used by the CLI so a typo'd ref is reported, not silently swapped).
96
+ */
97
+ export function resolveGitBase(cwd, requested, opts = {}) {
98
+ if (gitTry(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
99
+ return { ok: false, error: "Not a git repository. Run `git init`, or pass a path inside a repo." };
100
+ }
101
+ const refExists = (ref) => gitTry(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]) !== null;
102
+ if (requested) {
103
+ if (refExists(requested))
104
+ return { ok: true, base: requested };
105
+ if (opts.strict)
106
+ return { ok: false, error: `Base ref "${requested}" not found in this repository.` };
107
+ }
108
+ const originHead = gitTry(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
109
+ for (const cand of [originHead, "main", "master", "HEAD~1", "HEAD"]) {
110
+ if (cand && refExists(cand))
111
+ return { ok: true, base: cand };
112
+ }
113
+ return { ok: false, error: "No base ref available — repository has no commits yet." };
114
+ }
@@ -20,7 +20,16 @@ export function exportSarif(path, rules) {
20
20
  const config = loadConfig(scanRoot);
21
21
  const excludes = new Set([...DEFAULT_EXCLUDES, ...config.scan.exclude]);
22
22
  const filePaths = [];
23
- walkDirectory(scanRoot, true, excludes, filePaths);
23
+ // Accept a single file (for `check <file> --format sarif`) or a directory.
24
+ let isFile = false;
25
+ try {
26
+ isFile = statSync(scanRoot).isFile();
27
+ }
28
+ catch { /* treat as dir */ }
29
+ if (isFile)
30
+ filePaths.push(scanRoot);
31
+ else
32
+ walkDirectory(scanRoot, true, excludes, filePaths);
24
33
  const allResults = [];
25
34
  for (const filePath of filePaths) {
26
35
  try {
@@ -26,6 +26,14 @@ export interface HallucinationResult {
26
26
  * are removed so they are never counted as real dependencies.
27
27
  */
28
28
  export declare function stripCommentsAndTemplates(src: string): string;
29
+ /**
30
+ * Names that resolve to LOCAL first-party modules via tsconfig/jsconfig `baseUrl` or
31
+ * `paths` — e.g. `import x from "models/user"` under `baseUrl: "."`. These are source
32
+ * directories, NOT npm packages, so they must never be flagged phantom/typosquat
33
+ * (juice-shop's `models`/`data`/`lib` are the canonical false-positive case).
34
+ * Deterministic: reads the config + lists the baseUrl dir under root only; no network.
35
+ */
36
+ export declare function baseUrlLocalNames(root: string): Set<string>;
29
37
  /** Real npm package roots imported via import/require STATEMENTS in a file's source. */
30
38
  export declare function extractStatementImports(code: string): Set<string>;
31
39
  /** Walk a source tree and collect every package root imported via a real statement. */
@@ -148,6 +148,102 @@ const STMT_REQUIRE = /^\s*(?:(?:const|let|var)\s+[^=\n]+=\s*)?require\s*\(\s*['"
148
148
  function isPathAlias(spec) {
149
149
  return spec.startsWith("@/") || spec.startsWith("~");
150
150
  }
151
+ /** Strip // and block comments + trailing commas so a JSONC tsconfig can be JSON.parsed. */
152
+ function parseJsonc(text) {
153
+ const noBlock = text.replace(/\/\*[\s\S]*?\*\//g, "");
154
+ const noLine = noBlock.replace(/(^|[^:"'])\/\/[^\n]*/g, "$1");
155
+ const noTrailingCommas = noLine.replace(/,(\s*[}\]])/g, "$1");
156
+ return JSON.parse(noTrailingCommas);
157
+ }
158
+ /**
159
+ * Names that resolve to LOCAL first-party modules via tsconfig/jsconfig `baseUrl` or
160
+ * `paths` — e.g. `import x from "models/user"` under `baseUrl: "."`. These are source
161
+ * directories, NOT npm packages, so they must never be flagged phantom/typosquat
162
+ * (juice-shop's `models`/`data`/`lib` are the canonical false-positive case).
163
+ * Deterministic: reads the config + lists the baseUrl dir under root only; no network.
164
+ */
165
+ export function baseUrlLocalNames(root) {
166
+ const names = new Set();
167
+ const skip = new Set(SKIP_DIR);
168
+ // List a directory's child dirs + bare source-file names as resolvable local roots.
169
+ const addLocalFrom = (baseDir) => {
170
+ let entries;
171
+ try {
172
+ entries = readdirSync(baseDir);
173
+ }
174
+ catch {
175
+ return;
176
+ }
177
+ for (const e of entries) {
178
+ if (skip.has(e))
179
+ continue;
180
+ let st;
181
+ try {
182
+ st = statSync(join(baseDir, e));
183
+ }
184
+ catch {
185
+ continue;
186
+ }
187
+ if (st.isDirectory())
188
+ names.add(e);
189
+ else {
190
+ const ext = extname(e).toLowerCase();
191
+ if (CODE_EXT.has(ext))
192
+ names.add(e.slice(0, -ext.length));
193
+ }
194
+ }
195
+ };
196
+ // Walk the tree; every dir containing a tsconfig/jsconfig is a project root whose
197
+ // source dirs are addressable as project/baseUrl-relative bare imports (e.g. Angular's
198
+ // `import ... from 'src/app/...'`, or `models/user` under `baseUrl: "."`). This handles
199
+ // monorepos (a nested frontend/ with its own config). Deterministic + offline.
200
+ const walk = (dir) => {
201
+ let entries;
202
+ try {
203
+ entries = readdirSync(dir);
204
+ }
205
+ catch {
206
+ return;
207
+ }
208
+ const hasConfig = entries.includes("tsconfig.json") || entries.includes("jsconfig.json");
209
+ if (hasConfig) {
210
+ for (const cfgFile of ["tsconfig.json", "jsconfig.json"]) {
211
+ if (!entries.includes(cfgFile))
212
+ continue;
213
+ let cfg;
214
+ try {
215
+ cfg = parseJsonc(readFileSync(join(dir, cfgFile), "utf-8"));
216
+ }
217
+ catch {
218
+ cfg = null;
219
+ }
220
+ const co = cfg?.compilerOptions ?? {};
221
+ for (const key of Object.keys(co.paths ?? {})) {
222
+ const seg = key.replace(/\/\*?$/, "").split("/")[0];
223
+ if (seg)
224
+ names.add(seg);
225
+ }
226
+ addLocalFrom(typeof co.baseUrl === "string" ? resolve(dir, co.baseUrl) : dir);
227
+ }
228
+ }
229
+ for (const e of entries) {
230
+ if (skip.has(e))
231
+ continue;
232
+ const p = join(dir, e);
233
+ let st;
234
+ try {
235
+ st = statSync(p);
236
+ }
237
+ catch {
238
+ continue;
239
+ }
240
+ if (st.isDirectory())
241
+ walk(p);
242
+ }
243
+ };
244
+ walk(root);
245
+ return names;
246
+ }
151
247
  /** Real npm package roots imported via import/require STATEMENTS in a file's source. */
152
248
  export function extractStatementImports(code) {
153
249
  const stripped = stripCommentsAndTemplates(code);
@@ -170,6 +266,8 @@ export function collectStatementImports(root, opts = {}) {
170
266
  const found = new Set();
171
267
  const skip = new Set([...SKIP_DIR, ...(opts.exclude ?? [])]);
172
268
  const maxFiles = opts.maxFiles ?? 20_000;
269
+ // Local first-party module names (tsconfig baseUrl/paths) — never treated as packages.
270
+ const localModules = baseUrlLocalNames(root);
173
271
  let count = 0;
174
272
  const walk = (dir) => {
175
273
  if (count >= maxFiles)
@@ -208,7 +306,8 @@ export function collectStatementImports(root, opts = {}) {
208
306
  continue;
209
307
  }
210
308
  for (const pkg of extractStatementImports(code))
211
- found.add(pkg);
309
+ if (!localModules.has(pkg))
310
+ found.add(pkg);
212
311
  }
213
312
  }
214
313
  };
@@ -299,6 +398,22 @@ function mergeFinding(map, name, signal, severity, tier, similarTo) {
299
398
  function sortFindings(findings) {
300
399
  return [...findings].sort((a, b) => (SEV_RANK[a.severity] - SEV_RANK[b.severity]) || a.name.localeCompare(b.name));
301
400
  }
401
+ /** Bare (scope-stripped) package name, for length-based precision gating. */
402
+ function bareName(name) {
403
+ return name.startsWith("@") ? name.split("/").pop() ?? name : name;
404
+ }
405
+ /**
406
+ * High-precision gate for the deterministic typosquat tier. Structural squats
407
+ * (deceptive prefix/suffix/separator) are kept at any length; a bare Levenshtein match
408
+ * is kept only when the name is long enough (≥5) that the collision is unlikely to be
409
+ * coincidental — short names like `jws`/`cdk` sit within edit-distance of `jest`/`sdk`
410
+ * by chance, which is the residual false-positive source after the declared-name gate.
411
+ */
412
+ function isPreciseTyposquat(name, typo) {
413
+ if (typo.method === "levenshtein")
414
+ return bareName(name).length >= 5;
415
+ return true;
416
+ }
302
417
  /**
303
418
  * OFFLINE / deterministic core. Pure — no network, no filesystem. Unit-testable with
304
419
  * injected sets. Flags phantom imports (imported ∉ declared) and typosquats.
@@ -313,16 +428,22 @@ export function detectOffline(imported, declared, opts = {}) {
313
428
  for (const name of imported) {
314
429
  if (allow.has(name) || self.has(name))
315
430
  continue;
431
+ // The deterministic tier is gated on UNDECLARED (phantom) names only. A declared
432
+ // dependency is an intentional install — its existence/legitimacy is the ONLINE
433
+ // tier's job (404 / brand-new). Flagging a declared package as a typosquat offline,
434
+ // purely on edit-distance, is the dominant false-positive source: real, popular
435
+ // packages like `cors`/`chai`/`sinon`/`pug` sit within Levenshtein distance of
436
+ // unrelated popular names. (0-FP discipline > catching a declared typo offline.)
437
+ const isPhantom = !declared.has(name);
438
+ if (!isPhantom)
439
+ continue;
316
440
  const typo = detectTyposquat(name);
317
- if (typo) {
318
- const signal = typo.confidence >= 0.9 && /^(?:plain-|real-|original-|safe-|secure-|true-|actual-|verified-|legit-|official-|clean-|pure-|native-|simple-|fast-|super-|ultra-|better-|enhanced-|improved-|modern-|updated-|new-|my-|the-|a-|node-|js-|ts-)/.test(name.toLowerCase())
319
- ? "deceptive_prefix" : "typosquat";
441
+ if (typo && isPreciseTyposquat(name, typo)) {
442
+ const signal = typo.method === "prefix" || typo.method === "suffix" ? "deceptive_prefix" : "typosquat";
320
443
  mergeFinding(map, name, signal, "critical", "offline", typo.similarTo);
321
444
  }
322
445
  // phantom: imported in source, but not declared anywhere (and not a self/workspace pkg)
323
- if (imported.has(name) && !declared.has(name)) {
324
- mergeFinding(map, name, "phantom_import", "high", "offline");
325
- }
446
+ mergeFinding(map, name, "phantom_import", "high", "offline");
326
447
  }
327
448
  return sortFindings([...map.values()]);
328
449
  }
Binary file
@@ -3,6 +3,8 @@ export declare function levenshtein(a: string, b: string): number;
3
3
  interface TyposquatResult {
4
4
  similarTo: string;
5
5
  confidence: number;
6
+ /** How the match was made — lets callers apply method-specific precision gates. */
7
+ method?: "prefix" | "suffix" | "separator" | "levenshtein";
6
8
  }
7
9
  export declare function detectTyposquat(name: string): TyposquatResult | null;
8
10
  export {};
@@ -95,13 +95,13 @@ function detectPrefixSuffixSquat(name) {
95
95
  if (lower.startsWith(prefix)) {
96
96
  const stripped = lower.slice(prefix.length);
97
97
  if (POPULAR_PACKAGES.includes(stripped)) {
98
- return { similarTo: stripped, confidence: 0.95 };
98
+ return { similarTo: stripped, confidence: 0.95, method: "prefix" };
99
99
  }
100
100
  // Also check Levenshtein against stripped name
101
101
  for (const popular of POPULAR_PACKAGES) {
102
102
  const popularBare = popular.startsWith("@") ? popular.split("/").pop() ?? popular : popular;
103
103
  if (Math.abs(stripped.length - popularBare.length) <= 1 && levenshtein(stripped, popularBare) === 1) {
104
- return { similarTo: popular, confidence: 0.85 };
104
+ return { similarTo: popular, confidence: 0.85, method: "prefix" };
105
105
  }
106
106
  }
107
107
  }
@@ -110,7 +110,7 @@ function detectPrefixSuffixSquat(name) {
110
110
  if (lower.endsWith(suffix)) {
111
111
  const stripped = lower.slice(0, -suffix.length);
112
112
  if (POPULAR_PACKAGES.includes(stripped)) {
113
- return { similarTo: stripped, confidence: 0.9 };
113
+ return { similarTo: stripped, confidence: 0.9, method: "suffix" };
114
114
  }
115
115
  }
116
116
  }
@@ -125,12 +125,12 @@ function detectSeparatorSquat(name) {
125
125
  // Replace underscores and dots with hyphens, then check
126
126
  const normalized = lower.replace(/[_.]/g, "-");
127
127
  if (normalized !== lower && POPULAR_PACKAGES.includes(normalized)) {
128
- return { similarTo: normalized, confidence: 0.9 };
128
+ return { similarTo: normalized, confidence: 0.9, method: "separator" };
129
129
  }
130
130
  // Reverse: replace hyphens with underscores/dots
131
131
  const withUnderscore = lower.replace(/-/g, "_");
132
132
  if (withUnderscore !== lower && POPULAR_PACKAGES.includes(withUnderscore)) {
133
- return { similarTo: withUnderscore, confidence: 0.9 };
133
+ return { similarTo: withUnderscore, confidence: 0.9, method: "separator" };
134
134
  }
135
135
  return null;
136
136
  }
@@ -167,5 +167,5 @@ export function detectTyposquat(name) {
167
167
  return null;
168
168
  // Confidence: distance 1 = 0.9, distance 2 = 0.7
169
169
  const confidence = bestDistance === 1 ? 0.9 : 0.7;
170
- return { similarTo: bestMatch, confidence };
170
+ return { similarTo: bestMatch, confidence, method: "levenshtein" };
171
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.24.0",
3
+ "version": "3.26.0",
4
4
  "mcpName": "io.github.goklab/guardvibe",
5
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. 450 rules, 39 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. 77 CVE rules refreshed daily from GHSA/OSV/CISA KEV — js-cookie cookie-attribute injection, PostCSS </style> stringify XSS, Axios proxy prototype-pollution gadget, 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",