guardvibe 3.24.0 → 3.25.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 +9 -0
- package/README.md +26 -22
- package/build/cli/args.d.ts +1 -1
- package/build/cli/args.js +9 -2
- package/build/cli/explain.js +11 -0
- package/build/cli/init.js +10 -3
- package/build/cli/scan.d.ts +9 -1
- package/build/cli/scan.js +45 -14
- package/build/cli.js +11 -2
- package/build/index.js +14 -6
- package/build/tools/check-code.js +6 -3
- package/build/tools/check-deps.d.ts +1 -1
- package/build/tools/check-deps.js +24 -1
- package/build/tools/diff-aware.d.ts +18 -0
- package/build/tools/diff-aware.js +36 -0
- package/build/tools/export-sarif.js +10 -1
- package/build/tools/scan-hallucinated.d.ts +8 -0
- package/build/tools/scan-hallucinated.js +128 -7
- package/build/tools/secure-this.js +0 -0
- package/build/utils/typosquat.d.ts +2 -0
- package/build/utils/typosquat.js +6 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ 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.25.0] - 2026-06-24
|
|
9
|
+
|
|
10
|
+
### Fixed — QA hardening pass (no rule/tool count change: 450 rules / 39 tools)
|
|
11
|
+
- **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).
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
15
|
+
Gate green (build / lint / test / self-audit PASS / A / 0).
|
|
16
|
+
|
|
8
17
|
## [3.24.0] - 2026-06-23
|
|
9
18
|
|
|
10
19
|
### 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. (
|
|
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
|
|
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 |
|
|
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 (
|
|
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 |
|
|
310
|
-
| Next.js App Router |
|
|
311
|
-
| Auth (Clerk / Auth.js / Supabase Auth) |
|
|
312
|
-
| Database (Supabase / Prisma / Drizzle) |
|
|
313
|
-
| OWASP API Security |
|
|
314
|
-
| Modern Stack |
|
|
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 |
|
|
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 |
|
|
322
|
-
| **AI Host Security** | **
|
|
323
|
-
| **AI Tool Runtime** | **
|
|
324
|
-
| CVE Version Intelligence |
|
|
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 |
|
|
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) |
|
|
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 |
|
|
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 (
|
|
380
|
-
# --format markdown|json|sarif
|
|
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>
|
|
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).
|
|
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
|
|
package/build/cli/args.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/build/cli/explain.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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" && /
|
|
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
|
}
|
package/build/cli/scan.d.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 === "
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
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",
|
|
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:
|
|
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(
|
|
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;
|
|
@@ -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
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {};
|
package/build/utils/typosquat.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "3.25.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",
|