guardvibe 3.21.0 → 3.23.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,27 @@ 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.23.0] - 2026-06-19
9
+
10
+ ### Added — MCP/agent unauth endpoint rule + full CORS-credentials coverage from daily intel (448 → 449 rules)
11
+ - **VG1095 — MCP / agent tool-call endpoint without authentication (high).** Flags an HTTP route (`app|router|server|fastify`.`post|all|put|use`) that exposes an MCP `tools/call`, `/mcp`, or agent `run`/`invoke`/`execute` endpoint with no auth token within ~200 chars of the registration. Targets the June-2026 advisory wave: praisonai (unauthenticated HTTP tools/call + AgentOS agent listing/calling), network-ai (empty default secret authorizing every request, CVE-2026-48814/46701), AgenticMail (unauthenticated inbound mail driving a privileged agent session). Skips routes guarded by auth middleware or an in-handler session/token check.
12
+ - **VG1094 extended to full CVE-2026-54290 behavioral coverage.** Now also flags `cors({ origin: '*', credentials: true })` and `cors({ credentials: true })` with no origin key (middleware default reflects), in addition to the existing `origin:true` / reflecting-arrow-function cases. VG973 (wildcard without credentials) narrowed with a negative lookahead so the two are mutually exclusive — no double-firing. Explicit allowlists with credentials are still not flagged.
13
+ - **Already covered (verified against this brief, no action):** axios CVE-2026-44489/44490/44496 (all fixed in 1.16.0 → VG1042∪VG1091 `<1.16.0`), next RSC cluster CVE-2026-44576/44582/44577 (fixed 16.2.5/16.2.6 → VG1047), Hono CVE-2026-54290 version-pin (VG1092), Clerk/Drizzle/js-cookie/postcss/Anthropic-SDK/Vercel-AI-SDK. The brief's execSync command-injection suggestion is already covered by the MCP-handler rule (VG857) + the general command-injection rule.
14
+
15
+ Gate green (build / lint / test / self-audit PASS / A / 0).
16
+
17
+ ## [3.22.0] - 2026-06-18
18
+
19
+ ### Added — `slopscan`: AI-hallucinated / slopsquat package detector (38 → 39 tools)
20
+ - **New MCP tool `scan_hallucinated_packages` + CLI `slopscan [path]`** — detects the supply-chain seam commodity SCA misses: package names AI assistants invent (~20% of AI-generated code references non-existent packages) and the slopsquats attackers register for them. Commodity SCA scans known/published packages against vuln DBs; this catches names that don't exist yet, were never installed, or were published yesterday — at code-gen/PR time (shift-left).
21
+ - **Offline tier (deterministic, no network, air-gapped):** `phantom_import` (imported in source but absent from every package.json — a classic LLM tell) + typosquat/deceptive-prefix of popular packages. Import extraction is statement-anchored and strips comments + template-literal bodies, so example imports embedded in docs/codegen strings are never miscounted (verified 0 false positives on GuardVibe's own example-heavy source).
22
+ - **Online tier (opt-in, default on, graceful degrade):** npm-registry truth — `nonexistent` (404 = definitive hallucination), brand-new + low-download (easy-day-js/Mastra slopsquat pattern), deprecated/unmaintained/low-adoption. A total registry outage degrades to the deterministic offline result instead of misreporting every package as nonexistent.
23
+ - **`full_audit` integration:** the offline tier runs as a new `hallucinated-packages` section; the online tier never runs inside the audit, so the deterministic result hash is preserved.
24
+ - **Config:** `.guardviberc` `slopscan: { online?, allow? }` to allowlist intentional unpublished/workspace imports.
25
+ - **Reuse, no new dependency:** built on existing `detectTyposquat`, `packageRoot`, `assessPackageRisk`, and a new discriminated `fetchRegistryStatus` (distinguishes 404 from network failure). 13 new tests (offline phantom/typosquat/determinism, statement-anchored extraction, online-mock 404 + graceful degradation, CLI). Counts bumped 38 → 39 tools across all surfaces.
26
+
27
+ Gate green (build / lint / test / self-audit PASS / A / 0).
28
+
8
29
  ## [3.21.0] - 2026-06-18
9
30
 
10
31
  ### Added — 3 rules from daily threat intel: Hono CORS reflection + @hono/node-server bypass (445 → 448 rules / 38 tools)
package/README.md CHANGED
@@ -15,7 +15,7 @@
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
  - **⬅️ NEW: Starts before the first line of code.** Every scanner on earth — including your agent reviewing itself — acts *after* the code exists. [`secure_prompt`](#prompt-level-security-shift-left) acts *before*: it analyzes the coding prompt itself, detects the stack and attack surfaces it implies, and embeds severity-ranked GuardVibe requirements into the prompt your AI executes. The vulnerability is prevented, not caught. Deterministic, zero LLM calls — and if the prompt is already secure, it passes through untouched.
17
17
 
18
- **The security MCP built for vibe coding.** 448 security rules, 38 tools covering the entire AI-generated code journey — from the prompt itself to production deployment.
18
+ **The security MCP built for vibe coding.** 449 security rules, 39 tools covering the entire AI-generated code journey — from the prompt itself to production deployment.
19
19
 
20
20
  Works with **Claude Code, Cursor, Gemini CLI, Codex, VS Code (Copilot), Windsurf**, and any MCP-compatible coding agent.
21
21
 
@@ -27,7 +27,7 @@ Works with **Claude Code, Cursor, Gemini CLI, Codex, VS Code (Copilot), Windsurf
27
27
 
28
28
  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.
29
29
 
30
- - **448 security rules, 38 tools** purpose-built for the stacks AI agents generate
30
+ - **449 security rules, 39 tools** purpose-built for the stacks AI agents generate
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
@@ -65,7 +65,7 @@ GuardVibe is purpose-built for the AI coding workflow. Traditional tools are exc
65
65
  | CVE version detection | 71 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
- | Rule count | 448 (focused, 68 AI-native) | 5000+ (broad) | N/A |
68
+ | Rule count | 449 (focused, 68 AI-native) | 5000+ (broad) | N/A |
69
69
 
70
70
  **When to use GuardVibe:** You're building with AI agents and want security scanning integrated into your coding workflow — no dashboard, no account, no CI setup.
71
71
 
@@ -243,7 +243,7 @@ provider should be used (e.g. Clerk, Auth.js/NextAuth, Supabase Auth, custom JWT
243
243
 
244
244
  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.
245
245
 
246
- ## Tools (38 MCP tools)
246
+ ## Tools (39 MCP tools)
247
247
 
248
248
  | Tool | What it does |
249
249
  |------|-------------|
@@ -285,10 +285,24 @@ Same user intent — but the model now generates auth code with the guardrails s
285
285
  | `remediation_plan` | **Remediation plan** — generates section-by-section fix checklist after audit |
286
286
  | `verify_remediation` | **Remediation verification** — compares before/after audit, flags skipped sections |
287
287
  | `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 |
288
+ | `scan_hallucinated_packages` | **Slopsquat / AI-hallucination detector** — flags phantom imports (imported but in no manifest) and typosquats fully offline + deterministic; opt-in online tier adds npm-registry truth (404 = nonexistent, brand-new low-download = slopsquat pattern). CLI: `npx guardvibe slopscan [path] --offline` |
288
289
 
289
290
  All scanning tools support `format: "json"` for machine-readable output.
290
291
 
291
- ## Security Rules (448 rules across 25 modules)
292
+ ### Slopsquat / hallucinated-package detection
293
+
294
+ AI assistants invent package names — ~20% of AI-generated code references packages that don't exist, and attackers register those hallucinated names ("slopsquatting"). Commodity SCA scans *known, published* packages against vuln databases; it can't see a name that doesn't exist yet, was never installed, or was published yesterday. `scan_hallucinated_packages` / `slopscan` targets exactly that seam, at code-gen/PR time:
295
+
296
+ - **Offline (deterministic, air-gapped):** `phantom_import` (a package imported in source but absent from every `package.json` — a classic LLM tell) and typosquats of popular packages. Statement-anchored + comment/template-aware, so example imports in docs/strings are never miscounted.
297
+ - **Online (opt-in, graceful degrade):** npm-registry truth — `nonexistent` (404), brand-new + low-download (slopsquat-registration pattern), deprecated/unmaintained.
298
+
299
+ The offline tier is also a `full_audit` section (online never runs inside the audit, keeping the result hash deterministic). Allowlist intentional unpublished/workspace names via `.guardviberc`:
300
+
301
+ ```json
302
+ { "slopscan": { "online": true, "allow": ["@myorg/internal-pkg"] } }
303
+ ```
304
+
305
+ ## Security Rules (449 rules across 25 modules)
292
306
 
293
307
  | Category | Rules | Coverage |
294
308
  |----------|-------|----------|
@@ -0,0 +1 @@
1
+ export declare function runSlopscan(args: string[]): Promise<void>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * CLI: guardvibe slopscan [path]
3
+ * Detect AI-hallucinated / slopsquatted packages (phantom imports + typosquats + registry truth).
4
+ */
5
+ import { resolve } from "node:path";
6
+ import { statSync } from "node:fs";
7
+ import { parseArgs } from "./args.js";
8
+ import { scanHallucinatedPackages } from "../tools/scan-hallucinated.js";
9
+ export async function runSlopscan(args) {
10
+ const { flags, positional } = parseArgs(args);
11
+ const path = resolve(positional[0] ?? ".");
12
+ try {
13
+ const stat = statSync(path);
14
+ if (!stat.isDirectory()) {
15
+ console.error(` [ERR] Not a directory: ${path}`);
16
+ process.exit(1);
17
+ }
18
+ }
19
+ catch {
20
+ console.error(` [ERR] Path not found: ${path}`);
21
+ process.exit(1);
22
+ }
23
+ const format = (flags.format === "json" ? "json" : "markdown");
24
+ const online = !flags.offline;
25
+ const output = await scanHallucinatedPackages(path, format, { online });
26
+ console.log(output);
27
+ // Exit 1 when suspicious packages are found, so it can gate pre-commit / CI.
28
+ if (format === "json") {
29
+ try {
30
+ const parsed = JSON.parse(output);
31
+ if ((parsed.findings ?? []).length > 0)
32
+ process.exit(1);
33
+ }
34
+ catch { /* ignore */ }
35
+ }
36
+ else if (/^\*\*\d+ suspicious package/m.test(output)) {
37
+ process.exit(1);
38
+ }
39
+ }
package/build/cli.js CHANGED
@@ -34,6 +34,7 @@ function printUsage() {
34
34
  npx guardvibe auth-coverage [path] Auth coverage analysis (Next.js routes)
35
35
  npx guardvibe compliance [path] Compliance report (--framework SOC2|GDPR|...)
36
36
  npx guardvibe deep-scan <file> LLM-powered deep scan (IDOR, business logic, race conditions)
37
+ npx guardvibe slopscan [path] Detect AI-hallucinated / slopsquatted packages (--offline = deterministic-only)
37
38
  npx guardvibe init <platform> Setup MCP server configuration
38
39
  npx guardvibe hook install Install pre-commit security hook
39
40
  npx guardvibe hook uninstall Remove pre-commit security hook
@@ -168,6 +169,10 @@ async function main() {
168
169
  const { runDeepScan } = await import("./cli/deep-scan.js");
169
170
  await runDeepScan(subArgs);
170
171
  }
172
+ else if (command === "slopscan") {
173
+ const { runSlopscan } = await import("./cli/slopscan.js");
174
+ await runSlopscan(subArgs);
175
+ }
171
176
  else {
172
177
  console.error(` Unknown command: ${command}`);
173
178
  printUsage();
@@ -97,6 +97,18 @@ export const aiSecurityRules = [
97
97
  fixCode: '// Use spawn with argument array (no shell interpretation)\nimport { spawn } from "child_process";\nconst allowed = /^[a-zA-Z0-9._-]+$/;\nif (!allowed.test(args.filename)) throw new Error("Invalid filename");\nconst child = spawn("cat", [args.filename], { shell: false });',
98
98
  compliance: ["SOC2:CC7.1", "PCI-DSS:Req6.5.1", "EUAIACT:Art15"],
99
99
  },
100
+ {
101
+ id: "VG1095",
102
+ name: "MCP / Agent Tool-Call Endpoint Without Authentication",
103
+ severity: "high",
104
+ owasp: "A01:2025 Broken Access Control",
105
+ description: "An HTTP route exposes an MCP tools/call endpoint, an /mcp endpoint, or an agent run/invoke/execute endpoint with no authentication guard near the route registration. Exposing tool execution or agent invocation over HTTP without auth lets any caller run server-side tools/agents — the pattern behind the June-2026 advisory wave for praisonai (unauthenticated HTTP tools/call + AgentOS agent listing/calling), network-ai (empty default secret authorizing every request), and AgenticMail (unauthenticated inbound mail driving a privileged agent session). Heuristic: flags `(app|router|server|fastify).(post|all|put|use)` on a tool-call/mcp/agent-exec path when no auth token (auth/verify/session/getAuth/bearer/apiKey/token/middleware/guard/protect) appears within the next ~200 characters. Add an auth check, or — for the MCP SDK — authenticate at the transport layer before registering tools.",
106
+ pattern: /\b(?:app|router|server|fastify)\.(?:post|all|put|use)\s*\(\s*[`'"][^`'"]*(?:tools\/call|tool[-_]call|\/mcp\b|agents?\/[\w:./*-]*(?:run|invoke|execute|call)|(?:run|invoke|execute)[-_]?(?:tool|agent))[^`'"]*[`'"](?![\s\S]{0,200}?\b(?:auth|requireAuth|verify|authenticate|middleware|getAuth|getSession|session|currentUser|requireUser|isAuthenticated|bearer|apiKey|token|protect|guard)\b)/gi,
107
+ languages: ["javascript", "typescript"],
108
+ fix: "Require authentication before exposing tool-call or agent-invocation endpoints. Gate the route with auth middleware or an in-handler session/token check; for MCP over HTTP, authenticate the transport (bearer/API key) before dispatching tools/call.",
109
+ fixCode: '// Gate the MCP tools/call endpoint with auth middleware\nimport { requireAuth } from "./auth";\n\napp.post("/mcp/tools/call", requireAuth, async (req, res) => {\n const session = await getSession(req);\n if (!session) return res.status(401).json({ error: "Unauthorized" });\n // ... dispatch tool call\n});',
110
+ compliance: ["SOC2:CC6.1", "PCI-DSS:Req6.5.10", "EUAIACT:Art15"],
111
+ },
100
112
  // ── Katman 2: Excessive Agency Detection ───────────────────────────
101
113
  {
102
114
  id: "VG858",
@@ -214,8 +214,8 @@ export const modernStackRules = [
214
214
  name: "Hono CORS Wildcard",
215
215
  severity: "high",
216
216
  owasp: "A05:2025 Security Misconfiguration",
217
- description: "Hono app uses cors() with wildcard origin, allowing any website to make requests to your API.",
218
- pattern: /cors\s*\(\s*\{[\s\S]{0,200}?origin\s*:\s*['"]\*['"]/g,
217
+ description: "Hono app uses cors() with wildcard origin, allowing any website to make requests to your API. (When combined with credentials:true this is the account-takeover-grade CVE-2026-54290 case — flagged separately by VG1094.)",
218
+ pattern: /cors\s*\(\s*\{(?![\s\S]{0,400}?credentials\s*:\s*true)[\s\S]{0,200}?origin\s*:\s*['"]\*['"]/g,
219
219
  languages: ["javascript", "typescript"],
220
220
  fix: "Set specific allowed origins in Hono CORS configuration.",
221
221
  fixCode: 'import { cors } from "hono/cors";\n\napp.use("/*", cors({\n origin: ["https://myapp.com", "https://staging.myapp.com"],\n}));',
@@ -226,10 +226,10 @@ export const modernStackRules = [
226
226
  name: "CORS Origin Reflection With Credentials (CVE-2026-54290)",
227
227
  severity: "high",
228
228
  owasp: "A05:2025 Security Misconfiguration",
229
- description: "cors() is configured with credentials:true AND an origin that reflects the callereither origin:true or an arrow function that returns its origin argument unchanged (origin: (o) => o). This combination echoes any request's Origin back together with Access-Control-Allow-Credentials:true, so any website can make authenticated cross-origin requests on the victim's behalf (account-takeover-grade CSRF). This is the exact misconfiguration that made Hono CVE-2026-54290 exploitable, and it is dangerous on any CORS middleware (Hono, Express). The wildcard literal origin:'*' form is covered separately by VG973; this rule targets the reflected-origin forms that VG973 cannot see.",
230
- pattern: /cors\s*\(\s*\{(?=[\s\S]{0,400}?credentials\s*:\s*true)[\s\S]{0,400}?origin\s*:\s*(?:true\b|\(\s*(\w+)\s*\)\s*=>\s*\1\b)/g,
229
+ description: "cors() is configured with credentials:true together with a reflected or wildcard originorigin:'*', origin:true, an arrow function that returns its origin argument unchanged (origin: (o) => o), OR no origin key at all (the middleware default reflects/wildcards). Any of these echoes an arbitrary request Origin back with Access-Control-Allow-Credentials:true, so any website can make authenticated cross-origin requests on the victim's behalf (account-takeover-grade CSRF). This is exactly the misconfiguration that made Hono CVE-2026-54290 exploitable, and it is dangerous on any CORS middleware (Hono, Express). An explicit origin allowlist (origin: ['https://app.example.com']) with credentials:true is NOT flagged. VG973 covers the wildcard-without-credentials case.",
230
+ pattern: /cors\s*\(\s*\{(?=[\s\S]{0,400}?credentials\s*:\s*true)(?:[\s\S]{0,400}?origin\s*:\s*(?:['"]\*['"]|true\b|\(\s*(\w+)\s*\)\s*=>\s*\1\b)|(?![\s\S]{0,400}?\borigin\s*:)[\s\S]{0,200}?credentials\s*:\s*true)/g,
231
231
  languages: ["javascript", "typescript"],
232
- fix: "Never combine credentials:true with a reflected origin. Pass an explicit allowlist of trusted origins, or validate the incoming origin against an allowlist before returning it.",
232
+ fix: "Never combine credentials:true with a reflected/wildcard origin or an omitted origin. Pass an explicit allowlist of trusted origins, or validate the incoming origin against an allowlist before returning it.",
233
233
  fixCode: 'import { cors } from "hono/cors";\n\nconst ALLOWED = new Set(["https://myapp.com", "https://app.myapp.com"]);\napp.use("/api/*", cors({\n origin: (origin) => (ALLOWED.has(origin) ? origin : null),\n credentials: true,\n}));',
234
234
  compliance: ["SOC2:CC6.1", "SOC2:CC6.6", "PCI-DSS:Req6.2"],
235
235
  },
package/build/index.js CHANGED
@@ -12,6 +12,7 @@ import { getSecurityDocs } from "./tools/get-security-docs.js";
12
12
  import { checkDependencies } from "./tools/check-deps.js";
13
13
  import { scanDirectory } from "./tools/scan-directory.js";
14
14
  import { scanDependencies } from "./tools/scan-dependencies.js";
15
+ import { scanHallucinatedPackages } from "./tools/scan-hallucinated.js";
15
16
  import { scanSecrets } from "./tools/scan-secrets.js";
16
17
  import { scanStaged } from "./tools/scan-staged.js";
17
18
  import { complianceReport } from "./tools/compliance-report.js";
@@ -64,7 +65,7 @@ function mergeStatsIntoOutput(results, summary, format) {
64
65
  const server = new McpServer({
65
66
  name: "guardvibe",
66
67
  version: pkg.version,
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.`,
68
+ description: `Security MCP for vibe coding — single source of truth for AI assistants. ${builtinRules.length} security rules and 39 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.`,
68
69
  });
69
70
  // Tool 1: Analyze code for security vulnerabilities
70
71
  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'})", {
@@ -215,6 +216,29 @@ server.tool("scan_dependencies", "Parse a lockfile or manifest (package.json, pa
215
216
  }
216
217
  return { content: [{ type: "text", text: results }] };
217
218
  });
219
+ // Detect AI-hallucinated / slopsquatted packages (phantom imports + typosquats + registry truth)
220
+ server.tool("scan_hallucinated_packages", "Detect AI-hallucinated and slopsquatted packages in a repo — the supply-chain seam commodity SCA misses. OFFLINE (deterministic): flags phantom imports (a package imported in source but absent from every package.json — a classic LLM hallucination tell) and typosquats of popular packages. ONLINE (opt-in, default on; gracefully degrades offline): adds npm-registry truth — packages that return 404 (definitive hallucination) and brand-new low-download packages (slopsquat-registration pattern). Run on AI-generated code at PR time, before `npm install`. Pass online:false for a fully deterministic, air-gapped scan.", {
221
+ path: z.string().default(".").describe("Repository root to scan (default current directory)"),
222
+ online: z.boolean().default(true).describe("Query the npm registry for existence/age/downloads. false = deterministic offline-only (phantom imports + typosquats)."),
223
+ format: z.enum(["markdown", "json"]).default("markdown").describe("Output format: markdown (human) or json (guardvibe.slopscan.v1 for agents)"),
224
+ }, async ({ path, online, format }) => {
225
+ try {
226
+ const results = await scanHallucinatedPackages(path, format, { online });
227
+ const { resolve: resolvePath } = await import("path");
228
+ const root = resolvePath(path);
229
+ try {
230
+ const parsed = format === "json" ? JSON.parse(results) : null;
231
+ recordScan(root, { toolName: "scan_hallucinated_packages", filesScanned: 1, findings: (parsed?.findings ?? []).map((f) => ({ severity: f.severity, ruleId: f.ruleId })) });
232
+ }
233
+ catch {
234
+ recordScan(root, { toolName: "scan_hallucinated_packages", filesScanned: 1, findings: [] });
235
+ }
236
+ return { content: [{ type: "text", text: results }] };
237
+ }
238
+ catch (e) {
239
+ return { content: [{ type: "text", text: `# GuardVibe Slopsquat Report\n\nError scanning ${path}: ${e.message}\n\nThis may be a network issue reaching the npm registry. Retry with online:false for a deterministic offline scan.` }] };
240
+ }
241
+ });
218
242
  // Tool 7: Scan for leaked secrets, API keys, and credentials
219
243
  server.tool("scan_secrets", "Scan files and directories for leaked secrets, API keys, tokens, and credentials. Detects high-entropy strings, known API key patterns (AWS, Stripe, OpenAI, GitHub, Supabase), exposed .env files, and missing .gitignore coverage. Returns findings with exact line numbers and remediation steps.", {
220
244
  path: z.string().describe("File or directory path to scan"),
@@ -24,5 +24,20 @@ export interface PackageHealthResult {
24
24
  similarTo?: string;
25
25
  }
26
26
  export declare function assessPackageRisk(name: string, data: RegistryData): PackageHealthResult;
27
+ /**
28
+ * Discriminated registry fetch. Distinguishes "package truly does not exist (404)"
29
+ * from "could not reach/parse the registry (network/5xx)" — critical so a network
30
+ * outage is NOT misreported as every package being nonexistent.
31
+ * { ok: true, data } → resolved (data.exists is true for 200, false for 404)
32
+ * { ok: false } → transport/5xx error — existence is unknown
33
+ */
34
+ export type RegistryFetch = {
35
+ ok: true;
36
+ data: RegistryData;
37
+ } | {
38
+ ok: false;
39
+ };
40
+ export declare function fetchRegistryStatus(name: string): Promise<RegistryFetch>;
41
+ export declare function fetchRegistryData(name: string): Promise<RegistryData>;
27
42
  export declare function checkPackageHealth(packages: string[], format?: "markdown" | "json"): Promise<string>;
28
43
  export {};
@@ -68,17 +68,17 @@ export function assessPackageRisk(name, data) {
68
68
  similarTo,
69
69
  };
70
70
  }
71
- async function fetchRegistryData(name) {
71
+ export async function fetchRegistryStatus(name) {
72
72
  try {
73
73
  const [metaRes, downloadsRes] = await Promise.all([
74
74
  fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(5000) }),
75
75
  fetch(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(5000) }),
76
76
  ]);
77
77
  if (metaRes.status === 404) {
78
- return { exists: false, downloads: 0, lastPublish: "", maintainers: 0, deprecated: false };
78
+ return { ok: true, data: { exists: false, downloads: 0, lastPublish: "", maintainers: 0, deprecated: false } };
79
79
  }
80
80
  if (!metaRes.ok) {
81
- throw new Error(`npm registry error: ${metaRes.status}`);
81
+ return { ok: false }; // 5xx / rate-limit — cannot determine existence
82
82
  }
83
83
  const meta = await metaRes.json();
84
84
  const latestVersion = meta["dist-tags"]?.latest;
@@ -90,12 +90,16 @@ async function fetchRegistryData(name) {
90
90
  const dlData = await downloadsRes.json();
91
91
  downloads = dlData.downloads ?? 0;
92
92
  }
93
- return { exists: true, downloads, lastPublish, maintainers, deprecated };
93
+ return { ok: true, data: { exists: true, downloads, lastPublish, maintainers, deprecated } };
94
94
  }
95
95
  catch {
96
- return { exists: false, downloads: 0, lastPublish: "", maintainers: 0, deprecated: false };
96
+ return { ok: false }; // network error cannot determine existence
97
97
  }
98
98
  }
99
+ export async function fetchRegistryData(name) {
100
+ const r = await fetchRegistryStatus(name);
101
+ return r.ok ? r.data : { exists: false, downloads: 0, lastPublish: "", maintainers: 0, deprecated: false };
102
+ }
99
103
  export async function checkPackageHealth(packages, format = "markdown") {
100
104
  const results = [];
101
105
  for (const name of packages) {
@@ -16,6 +16,7 @@ import { scanDependencies } from "./scan-dependencies.js";
16
16
  import { auditConfig } from "./audit-config.js";
17
17
  import { analyzeCrossFileTaint } from "./cross-file-taint.js";
18
18
  import { analyzeAuthCoverage } from "./auth-coverage.js";
19
+ import { detectHallucinatedOffline } from "./scan-hallucinated.js";
19
20
  import { getRules } from "../utils/rule-registry.js";
20
21
  import { loadConfig } from "../utils/config.js";
21
22
  import { isExcludedFilename } from "../utils/constants.js";
@@ -417,6 +418,40 @@ export async function runFullAudit(path, options) {
417
418
  }
418
419
  }
419
420
  catch { /* auth coverage is optional */ }
421
+ // --- Section 7: Hallucinated / slopsquatted packages (OFFLINE only — keeps result hash deterministic) ---
422
+ try {
423
+ const halluc = detectHallucinatedOffline(projectRoot);
424
+ if (halluc.length > 0) {
425
+ let hCritical = 0, hHigh = 0, hMedium = 0;
426
+ const hFindings = halluc.map(f => {
427
+ if (f.severity === "critical")
428
+ hCritical++;
429
+ else if (f.severity === "high")
430
+ hHigh++;
431
+ else
432
+ hMedium++;
433
+ return {
434
+ ruleId: f.ruleId,
435
+ severity: f.severity,
436
+ file: "package.json",
437
+ line: 0,
438
+ name: `${f.name}: ${f.signals.join(", ")}${f.similarTo ? ` (did you mean ${f.similarTo}?)` : ""}`,
439
+ description: `Possible AI-hallucinated / slopsquatted package. Signals: ${f.signals.join(", ")}.`,
440
+ fix: f.fix,
441
+ };
442
+ });
443
+ sections.push({
444
+ name: "hallucinated-packages", status: "ok",
445
+ findings: hFindings.length, critical: hCritical, high: hHigh, medium: hMedium,
446
+ details: `${hFindings.length} suspicious package(s) (offline: phantom imports + typosquats)`,
447
+ sectionFindings: hFindings,
448
+ });
449
+ for (const f of hFindings) {
450
+ allFindings.push({ ruleId: f.ruleId, severity: f.severity, file: f.file, line: f.line });
451
+ }
452
+ }
453
+ }
454
+ catch { /* hallucination scan is optional */ }
420
455
  // --- Compute totals ---
421
456
  const totalCritical = sections.reduce((s, sec) => s + sec.critical, 0);
422
457
  const totalHigh = sections.reduce((s, sec) => s + sec.high, 0);
@@ -527,6 +562,15 @@ function buildInlineRemediationPlan(result) {
527
562
  "Verify: `npx guardvibe auth-coverage --format json` — unprotected must be 0",
528
563
  ],
529
564
  },
565
+ "hallucinated-packages": {
566
+ priority: 7,
567
+ tool: "scan_hallucinated_packages",
568
+ actions: [
569
+ "Look at sectionFindings above — each names a phantom-imported or typosquatted package",
570
+ "For phantom imports: add the real dependency, or remove the import if the AI invented it. For typosquats: switch to the official package name",
571
+ "Confirm existence/provenance: `npx guardvibe slopscan . --format json` (online), or add intentional names to .guardviberc slopscan.allow",
572
+ ],
573
+ },
530
574
  };
531
575
  const steps = [];
532
576
  for (const section of result.sections) {
@@ -0,0 +1,65 @@
1
+ export type HallucinationSignal = "phantom_import" | "typosquat" | "deceptive_prefix" | "nonexistent" | "new_package" | "low_adoption" | "single_maintainer" | "unmaintained" | "deprecated";
2
+ export type Severity = "critical" | "high" | "medium" | "low";
3
+ export interface HallucinationFinding {
4
+ name: string;
5
+ ecosystem: "npm";
6
+ signals: HallucinationSignal[];
7
+ severity: Severity;
8
+ similarTo?: string;
9
+ tier: "offline" | "online";
10
+ ruleId: string;
11
+ fix: string;
12
+ }
13
+ export interface HallucinationResult {
14
+ schema: "guardvibe.slopscan.v1";
15
+ root: string;
16
+ declaredCount: number;
17
+ importedCount: number;
18
+ deterministic: boolean;
19
+ networkStatus: "ok" | "unreachable" | "skipped";
20
+ findings: HallucinationFinding[];
21
+ }
22
+ /**
23
+ * Blank out comments and template-literal (backtick) bodies, preserving newlines and
24
+ * single/double-quoted strings. Real ES import specifiers are single/double-quoted in
25
+ * code context and survive; example imports living inside backtick templates or comments
26
+ * are removed so they are never counted as real dependencies.
27
+ */
28
+ export declare function stripCommentsAndTemplates(src: string): string;
29
+ /** Real npm package roots imported via import/require STATEMENTS in a file's source. */
30
+ export declare function extractStatementImports(code: string): Set<string>;
31
+ /** Walk a source tree and collect every package root imported via a real statement. */
32
+ export declare function collectStatementImports(root: string, opts?: {
33
+ exclude?: string[];
34
+ maxFiles?: number;
35
+ }): Set<string>;
36
+ export interface DeclaredInfo {
37
+ /** Every dependency name declared across all package.json files under root. */
38
+ declared: Set<string>;
39
+ /** `name` field of every package.json found (workspace-internal / self packages). */
40
+ selfNames: Set<string>;
41
+ }
42
+ /** Collect declared dependency names + workspace self-names from every package.json under root. */
43
+ export declare function collectDeclaredPackages(root: string, opts?: {
44
+ exclude?: string[];
45
+ }): DeclaredInfo;
46
+ /**
47
+ * OFFLINE / deterministic core. Pure — no network, no filesystem. Unit-testable with
48
+ * injected sets. Flags phantom imports (imported ∉ declared) and typosquats.
49
+ */
50
+ export declare function detectOffline(imported: Set<string>, declared: Set<string>, opts?: {
51
+ allow?: string[];
52
+ selfNames?: Set<string>;
53
+ }): HallucinationFinding[];
54
+ /**
55
+ * Repo-level orchestrator. Runs the offline tier, then (unless online === false)
56
+ * enriches with npm-registry truth, gracefully degrading to offline on any network error.
57
+ */
58
+ export declare function scanHallucinatedPackages(root: string, format?: "markdown" | "json", opts?: {
59
+ online?: boolean;
60
+ }): Promise<string>;
61
+ /**
62
+ * OFFLINE-only repo scan, for use inside full_audit (never makes a network call →
63
+ * keeps the audit result hash deterministic). Returns the deterministic finding list.
64
+ */
65
+ export declare function detectHallucinatedOffline(root: string): HallucinationFinding[];
@@ -0,0 +1,457 @@
1
+ // guardvibe-ignore — defines import-detection regexes; the `import`/`require`/`from`
2
+ // string literals here are detector patterns, not vulnerable code.
3
+ /**
4
+ * Slopsquat / AI-hallucinated package detector.
5
+ *
6
+ * AI coding assistants invent package names: ~20% of AI-generated code references
7
+ * packages that do not exist (USENIX 2025), and attackers register those hallucinated
8
+ * names ("slopsquatting"). Commodity SCA (Snyk/Socket/GHAS) scans KNOWN, PUBLISHED
9
+ * packages against vuln DBs — it is blind to a name that does not exist yet, was never
10
+ * installed, or was published yesterday to satisfy a hallucinated import. This tool
11
+ * targets exactly that seam, at code-generation / PR time (shift-left).
12
+ *
13
+ * Two tiers:
14
+ * OFFLINE (deterministic, no network) — always runs:
15
+ * - phantom_import: imported in source but absent from every manifest (classic LLM tell)
16
+ * - typosquat / deceptive_prefix: looks like a popular package (reuses detectTyposquat)
17
+ * ONLINE (opt-in, gracefully degrades) — adds npm-registry truth:
18
+ * - nonexistent: 404 on the registry (definitive hallucination)
19
+ * - new_package: published <30d ago with low downloads (easy-day-js/Mastra pattern)
20
+ * - deprecated / unmaintained / low_adoption / single_maintainer
21
+ *
22
+ * The offline tier is import-statement-anchored and strips comments + template-literal
23
+ * bodies, so example imports embedded in docs/codegen strings are NOT mistaken for real
24
+ * dependencies (verified 0 false positives on GuardVibe's own example-heavy source).
25
+ */
26
+ import { readdirSync, statSync, readFileSync } from "fs";
27
+ import { join, extname, resolve } from "path";
28
+ import { detectTyposquat } from "../utils/typosquat.js";
29
+ import { packageRoot } from "./reachability.js";
30
+ import { assessPackageRisk, fetchRegistryStatus } from "./check-package-health.js";
31
+ import { loadConfig } from "../utils/config.js";
32
+ const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
33
+ // Static list (not module.builtinModules) so results don't drift across Node versions.
34
+ const NODE_BUILTINS = new Set([
35
+ "assert", "async_hooks", "buffer", "child_process", "cluster", "console", "constants",
36
+ "crypto", "dgram", "diagnostics_channel", "dns", "domain", "events", "fs", "http",
37
+ "http2", "https", "inspector", "module", "net", "os", "path", "perf_hooks", "process",
38
+ "punycode", "querystring", "readline", "repl", "stream", "string_decoder", "sys",
39
+ "timers", "tls", "trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", "zlib",
40
+ ]);
41
+ const CODE_EXT = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
42
+ const SKIP_DIR = new Set([
43
+ "node_modules", ".git", ".next", "dist", "build", "out", "coverage",
44
+ ".turbo", "vendor", ".vercel", ".cache", ".svelte-kit", ".output", ".nuxt", ".astro",
45
+ ]);
46
+ /**
47
+ * Blank out comments and template-literal (backtick) bodies, preserving newlines and
48
+ * single/double-quoted strings. Real ES import specifiers are single/double-quoted in
49
+ * code context and survive; example imports living inside backtick templates or comments
50
+ * are removed so they are never counted as real dependencies.
51
+ */
52
+ export function stripCommentsAndTemplates(src) {
53
+ let out = "";
54
+ let i = 0;
55
+ const n = src.length;
56
+ // state: 0 code, 1 line comment, 2 block comment, 3 template literal
57
+ let state = 0;
58
+ while (i < n) {
59
+ const c = src[i];
60
+ const d = src[i + 1];
61
+ if (state === 0) {
62
+ if (c === "/" && d === "/") {
63
+ state = 1;
64
+ out += " ";
65
+ i += 2;
66
+ continue;
67
+ }
68
+ if (c === "/" && d === "*") {
69
+ state = 2;
70
+ out += " ";
71
+ i += 2;
72
+ continue;
73
+ }
74
+ if (c === "`") {
75
+ state = 3;
76
+ out += " ";
77
+ i++;
78
+ continue;
79
+ }
80
+ if (c === "'" || c === '"') {
81
+ const q = c;
82
+ out += c;
83
+ i++;
84
+ while (i < n) {
85
+ const e = src[i];
86
+ if (e === "\\") {
87
+ out += e + (src[i + 1] ?? "");
88
+ i += 2;
89
+ continue;
90
+ }
91
+ out += e;
92
+ i++;
93
+ if (e === q || e === "\n")
94
+ break;
95
+ }
96
+ continue;
97
+ }
98
+ out += c;
99
+ i++;
100
+ continue;
101
+ }
102
+ if (state === 1) {
103
+ if (c === "\n") {
104
+ state = 0;
105
+ out += c;
106
+ }
107
+ else
108
+ out += " ";
109
+ i++;
110
+ continue;
111
+ }
112
+ if (state === 2) {
113
+ if (c === "*" && d === "/") {
114
+ state = 0;
115
+ out += " ";
116
+ i += 2;
117
+ continue;
118
+ }
119
+ out += c === "\n" ? "\n" : " ";
120
+ i++;
121
+ continue;
122
+ }
123
+ // state === 3 (template literal)
124
+ if (c === "\\") {
125
+ out += " ";
126
+ i += 2;
127
+ continue;
128
+ }
129
+ if (c === "`") {
130
+ state = 0;
131
+ out += " ";
132
+ i++;
133
+ continue;
134
+ }
135
+ out += c === "\n" ? "\n" : " ";
136
+ i++;
137
+ continue;
138
+ }
139
+ return out;
140
+ }
141
+ // Statement-anchored (^\s*) import detectors — only real import statements, never
142
+ // substrings inside other expressions. Run on comment/template-stripped code.
143
+ const STMT_FROM = /^\s*(?:import|export)\b[^'";]*?\bfrom\s+['"]([^'"]+)['"]/gm;
144
+ const STMT_BARE = /^\s*import\s+['"]([^'"]+)['"]/gm;
145
+ const STMT_DYN = /^\s*(?:await\s+)?import\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
146
+ const STMT_REQUIRE = /^\s*(?:(?:const|let|var)\s+[^=\n]+=\s*)?require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
147
+ /** Is this specifier a path alias (tsconfig paths) rather than a real package? */
148
+ function isPathAlias(spec) {
149
+ return spec.startsWith("@/") || spec.startsWith("~");
150
+ }
151
+ /** Real npm package roots imported via import/require STATEMENTS in a file's source. */
152
+ export function extractStatementImports(code) {
153
+ const stripped = stripCommentsAndTemplates(code);
154
+ const out = new Set();
155
+ for (const re of [STMT_FROM, STMT_BARE, STMT_DYN, STMT_REQUIRE]) {
156
+ re.lastIndex = 0;
157
+ for (const m of stripped.matchAll(re)) {
158
+ const spec = m[1];
159
+ if (isPathAlias(spec))
160
+ continue;
161
+ const root = packageRoot(spec);
162
+ if (root && !NODE_BUILTINS.has(root))
163
+ out.add(root);
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+ /** Walk a source tree and collect every package root imported via a real statement. */
169
+ export function collectStatementImports(root, opts = {}) {
170
+ const found = new Set();
171
+ const skip = new Set([...SKIP_DIR, ...(opts.exclude ?? [])]);
172
+ const maxFiles = opts.maxFiles ?? 20_000;
173
+ let count = 0;
174
+ const walk = (dir) => {
175
+ if (count >= maxFiles)
176
+ return;
177
+ let entries;
178
+ try {
179
+ entries = readdirSync(dir);
180
+ }
181
+ catch {
182
+ return;
183
+ }
184
+ for (const e of entries) {
185
+ if (count >= maxFiles)
186
+ return;
187
+ if (skip.has(e))
188
+ continue;
189
+ const p = join(dir, e);
190
+ let st;
191
+ try {
192
+ st = statSync(p);
193
+ }
194
+ catch {
195
+ continue;
196
+ }
197
+ if (st.isDirectory()) {
198
+ walk(p);
199
+ continue;
200
+ }
201
+ if (CODE_EXT.has(extname(e).toLowerCase()) && st.size < 1_000_000) {
202
+ count++;
203
+ let code;
204
+ try {
205
+ code = readFileSync(p, "utf-8");
206
+ }
207
+ catch {
208
+ continue;
209
+ }
210
+ for (const pkg of extractStatementImports(code))
211
+ found.add(pkg);
212
+ }
213
+ }
214
+ };
215
+ walk(root);
216
+ return found;
217
+ }
218
+ /** Collect declared dependency names + workspace self-names from every package.json under root. */
219
+ export function collectDeclaredPackages(root, opts = {}) {
220
+ const declared = new Set();
221
+ const selfNames = new Set();
222
+ const skip = new Set([...SKIP_DIR, ...(opts.exclude ?? [])]);
223
+ const walk = (dir) => {
224
+ let entries;
225
+ try {
226
+ entries = readdirSync(dir);
227
+ }
228
+ catch {
229
+ return;
230
+ }
231
+ for (const e of entries) {
232
+ if (skip.has(e))
233
+ continue;
234
+ const p = join(dir, e);
235
+ let st;
236
+ try {
237
+ st = statSync(p);
238
+ }
239
+ catch {
240
+ continue;
241
+ }
242
+ if (st.isDirectory()) {
243
+ walk(p);
244
+ continue;
245
+ }
246
+ if (e === "package.json") {
247
+ let json;
248
+ try {
249
+ json = JSON.parse(readFileSync(p, "utf-8"));
250
+ }
251
+ catch {
252
+ continue;
253
+ }
254
+ for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies", "overrides"]) {
255
+ for (const name of Object.keys(json[section] ?? {}))
256
+ declared.add(name);
257
+ }
258
+ if (typeof json.name === "string" && json.name)
259
+ selfNames.add(json.name);
260
+ }
261
+ }
262
+ };
263
+ walk(root);
264
+ return { declared, selfNames };
265
+ }
266
+ const SIGNAL_FIX = {
267
+ phantom_import: "This package is imported in source but is not in any package.json. If the import is real, add the dependency; if the AI invented it, remove the import or replace it with the genuine package.",
268
+ typosquat: "This name closely resembles a popular package — verify it is the one you intend before installing. Use the official name.",
269
+ deceptive_prefix: "This name uses a deceptive prefix/suffix of a popular package (a known supply-chain attack shape). Use the official package name.",
270
+ nonexistent: "This package does NOT exist on the npm registry — almost certainly an AI hallucination. Remove the import or replace it with the real package.",
271
+ new_package: "This package was published very recently and has low adoption — a slopsquat-registration red flag. Verify the publisher and provenance before installing.",
272
+ low_adoption: "Very low weekly downloads — confirm this is a legitimate, intended dependency.",
273
+ single_maintainer: "Single maintainer with low adoption — elevated supply-chain risk; review before depending on it.",
274
+ unmaintained: "Not published in over 2 years — consider a maintained alternative.",
275
+ deprecated: "Marked deprecated on npm — migrate to the recommended replacement.",
276
+ };
277
+ function ruleIdFor(signals) {
278
+ if (signals.includes("deceptive_prefix"))
279
+ return "VG873";
280
+ return "VG-SLOP";
281
+ }
282
+ function mergeFinding(map, name, signal, severity, tier, similarTo) {
283
+ const existing = map.get(name);
284
+ if (existing) {
285
+ if (!existing.signals.includes(signal))
286
+ existing.signals.push(signal);
287
+ if (SEV_RANK[severity] < SEV_RANK[existing.severity])
288
+ existing.severity = severity;
289
+ if (tier === "online")
290
+ existing.tier = "online";
291
+ if (similarTo && !existing.similarTo)
292
+ existing.similarTo = similarTo;
293
+ existing.ruleId = ruleIdFor(existing.signals);
294
+ existing.fix = SIGNAL_FIX[existing.signals[0]];
295
+ return;
296
+ }
297
+ map.set(name, { name, ecosystem: "npm", signals: [signal], severity, similarTo, tier, ruleId: ruleIdFor([signal]), fix: SIGNAL_FIX[signal] });
298
+ }
299
+ function sortFindings(findings) {
300
+ return [...findings].sort((a, b) => (SEV_RANK[a.severity] - SEV_RANK[b.severity]) || a.name.localeCompare(b.name));
301
+ }
302
+ /**
303
+ * OFFLINE / deterministic core. Pure — no network, no filesystem. Unit-testable with
304
+ * injected sets. Flags phantom imports (imported ∉ declared) and typosquats.
305
+ */
306
+ export function detectOffline(imported, declared, opts = {}) {
307
+ const allow = new Set(opts.allow ?? []);
308
+ const self = opts.selfNames ?? new Set();
309
+ const map = new Map();
310
+ // Candidates are IMPORTED names only — what the code actually uses. Running typosquat
311
+ // over declared-but-unused devtools (e.g. "c8", "@types/node") produces Levenshtein
312
+ // false positives, so a declared package is only judged when source imports it.
313
+ for (const name of imported) {
314
+ if (allow.has(name) || self.has(name))
315
+ continue;
316
+ 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";
320
+ mergeFinding(map, name, signal, "critical", "offline", typo.similarTo);
321
+ }
322
+ // 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
+ }
326
+ }
327
+ return sortFindings([...map.values()]);
328
+ }
329
+ /** Map an assessPackageRisk flag type to a HallucinationSignal (online tier). */
330
+ const FLAG_TO_SIGNAL = {
331
+ new_package: "new_package",
332
+ low_adoption: "low_adoption",
333
+ single_maintainer: "single_maintainer",
334
+ unmaintained: "unmaintained",
335
+ deprecated: "deprecated",
336
+ };
337
+ const FLAG_SEVERITY = {
338
+ phantom_import: "high", typosquat: "critical", deceptive_prefix: "critical",
339
+ nonexistent: "critical", new_package: "medium", low_adoption: "medium",
340
+ single_maintainer: "high", unmaintained: "high", deprecated: "high",
341
+ };
342
+ /**
343
+ * Repo-level orchestrator. Runs the offline tier, then (unless online === false)
344
+ * enriches with npm-registry truth, gracefully degrading to offline on any network error.
345
+ */
346
+ export async function scanHallucinatedPackages(root, format = "markdown", opts = {}) {
347
+ const projectRoot = resolve(root);
348
+ const config = loadConfig(projectRoot);
349
+ const exclude = config.scan.exclude;
350
+ const allow = config.slopscan?.allow ?? [];
351
+ const online = opts.online ?? config.slopscan?.online ?? true;
352
+ const imported = collectStatementImports(projectRoot, { exclude });
353
+ const { declared, selfNames } = collectDeclaredPackages(projectRoot, { exclude });
354
+ const offline = detectOffline(imported, declared, { allow, selfNames });
355
+ let networkStatus = "skipped";
356
+ let findings = offline;
357
+ let deterministic = true;
358
+ if (online) {
359
+ const map = new Map();
360
+ for (const f of offline)
361
+ map.set(f.name, { ...f, signals: [...f.signals] });
362
+ // Query suspicious offline names + every declared/imported external package so we
363
+ // also catch declared-but-fake (404) and brand-new slopsquat deps (easy-day-js shape).
364
+ const allowSet = new Set(allow);
365
+ const queryNames = [...new Set([
366
+ ...offline.map(f => f.name),
367
+ ...declared,
368
+ ...imported,
369
+ ])].filter(n => !allowSet.has(n) && !selfNames.has(n)).sort();
370
+ let anyOk = false;
371
+ for (const name of queryNames) {
372
+ const r = await fetchRegistryStatus(name);
373
+ if (!r.ok)
374
+ continue; // transport error for THIS name — never false-positive as nonexistent
375
+ anyOk = true;
376
+ if (!r.data.exists) {
377
+ mergeFinding(map, name, "nonexistent", "critical", "online");
378
+ continue;
379
+ }
380
+ const risk = assessPackageRisk(name, r.data);
381
+ for (const flag of risk.flags) {
382
+ const signal = FLAG_TO_SIGNAL[flag.type];
383
+ if (!signal)
384
+ continue;
385
+ mergeFinding(map, name, signal, FLAG_SEVERITY[signal], "online");
386
+ }
387
+ }
388
+ if (!anyOk && queryNames.length > 0) {
389
+ // Total registry outage — could not determine anything online. Degrade to the
390
+ // deterministic offline result rather than guessing.
391
+ networkStatus = "unreachable";
392
+ findings = offline;
393
+ deterministic = true;
394
+ }
395
+ else {
396
+ // Composition: a phantom/typosquat name that is ALSO brand-new is critical.
397
+ for (const f of map.values()) {
398
+ if (f.signals.includes("new_package") && (f.signals.includes("phantom_import") || f.signals.includes("typosquat") || f.signals.includes("deceptive_prefix"))) {
399
+ f.severity = "critical";
400
+ }
401
+ }
402
+ networkStatus = "ok";
403
+ findings = sortFindings([...map.values()]);
404
+ deterministic = false;
405
+ }
406
+ }
407
+ const result = {
408
+ schema: "guardvibe.slopscan.v1",
409
+ root: projectRoot,
410
+ declaredCount: declared.size,
411
+ importedCount: imported.size,
412
+ deterministic,
413
+ networkStatus,
414
+ findings,
415
+ };
416
+ if (format === "json")
417
+ return JSON.stringify(result);
418
+ return renderMarkdown(result);
419
+ }
420
+ function renderMarkdown(r) {
421
+ const lines = [
422
+ "# GuardVibe Slopsquat / Hallucinated Package Report",
423
+ "",
424
+ `Root: ${r.root}`,
425
+ `Imported packages: ${r.importedCount} · Declared: ${r.declaredCount}`,
426
+ `Mode: ${r.networkStatus === "skipped" ? "offline (deterministic)" : r.networkStatus === "unreachable" ? "online requested but registry unreachable — offline results only" : "offline + online (npm registry)"}`,
427
+ "",
428
+ "---",
429
+ "",
430
+ ];
431
+ if (r.findings.length === 0) {
432
+ lines.push("No hallucinated, phantom, or slopsquatted packages detected.");
433
+ return lines.join("\n");
434
+ }
435
+ lines.push(`**${r.findings.length} suspicious package(s):**`, "");
436
+ for (const f of r.findings) {
437
+ lines.push(`## ${f.name} — ${f.severity.toUpperCase()} (${f.tier})`, "");
438
+ lines.push(`- Signals: ${f.signals.join(", ")}`);
439
+ if (f.similarTo)
440
+ lines.push(`- Did you mean **${f.similarTo}**?`);
441
+ lines.push(`- Fix: ${f.fix}`, "", "---", "");
442
+ }
443
+ return lines.join("\n");
444
+ }
445
+ /**
446
+ * OFFLINE-only repo scan, for use inside full_audit (never makes a network call →
447
+ * keeps the audit result hash deterministic). Returns the deterministic finding list.
448
+ */
449
+ export function detectHallucinatedOffline(root) {
450
+ const projectRoot = resolve(root);
451
+ const config = loadConfig(projectRoot);
452
+ const exclude = config.scan.exclude;
453
+ const allow = config.slopscan?.allow ?? [];
454
+ const imported = collectStatementImports(projectRoot, { exclude });
455
+ const { declared, selfNames } = collectDeclaredPackages(projectRoot, { exclude });
456
+ return detectOffline(imported, declared, { allow, selfNames });
457
+ }
@@ -41,6 +41,15 @@ export interface GuardVibeConfig {
41
41
  scoring?: {
42
42
  densityModel?: "linear" | "exponential";
43
43
  };
44
+ /** Slopsquat / hallucinated-package detector (`slopscan`) settings.
45
+ * - online: query the npm registry for existence/age/downloads (default true for the
46
+ * standalone tool; full_audit always runs offline-only regardless).
47
+ * - allow: package names to treat as intentional (e.g. private/unpublished or
48
+ * path-aliased imports) so they are never flagged as phantom imports. */
49
+ slopscan?: {
50
+ online?: boolean;
51
+ allow?: string[];
52
+ };
44
53
  }
45
54
  export declare function loadConfig(dir?: string): GuardVibeConfig;
46
55
  export declare function resetConfigCache(): void;
@@ -72,6 +72,10 @@ export function loadConfig(dir) {
72
72
  scoring: parsed.scoring && typeof parsed.scoring === "object" ? {
73
73
  densityModel: parsed.scoring.densityModel === "exponential" ? "exponential" : "linear",
74
74
  } : undefined,
75
+ slopscan: parsed.slopscan && typeof parsed.slopscan === "object" ? {
76
+ online: typeof parsed.slopscan.online === "boolean" ? parsed.slopscan.online : undefined,
77
+ allow: Array.isArray(parsed.slopscan.allow) ? parsed.slopscan.allow : undefined,
78
+ } : undefined,
75
79
  };
76
80
  }
77
81
  catch (err) {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.21.0",
3
+ "version": "3.23.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. 448 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. 76 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).",
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. 449 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. 76 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",
7
7
  "bin": {
8
8
  "guardvibe": "build/cli.js",