guardvibe 3.3.0 → 3.5.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 +22 -0
- package/README.md +4 -2
- package/build/cli/args.js +1 -1
- package/build/cli/scan.js +10 -2
- package/build/cli.js +2 -1
- package/build/index.js +8 -2
- package/build/tools/agent-output.d.ts +44 -0
- package/build/tools/agent-output.js +45 -0
- package/build/tools/full-audit.js +14 -5
- package/build/tools/reachability.d.ts +19 -0
- package/build/tools/reachability.js +122 -0
- package/build/tools/scan-dependencies.d.ts +7 -1
- package/build/tools/scan-dependencies.js +30 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ All notable changes to GuardVibe are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.5.0] - 2026-06-07
|
|
9
|
+
|
|
10
|
+
### Added — agent-native structured output (`guardvibe.agent.v1`) (438 rules / 37 tools)
|
|
11
|
+
- **New `agent` output format** that returns one stable, documented contract per finding so a coding agent can act on data instead of parsing prose: `{ id, name, severity, owasp, file, line, confidence, autoFixable, exactEdit, manualFix, verify }`.
|
|
12
|
+
- **`exactEdit`** is the structured line edit when the finding is auto-applicable (from `fix_code`); **`confidence`** is surfaced per finding; **`verify`** is a deterministic, runnable step (`guardvibe check … --format json`, expect the rule absent) so the agent can *prove* the fix landed.
|
|
13
|
+
- Exposed via CLI `guardvibe check <file> --format agent` and the MCP `scan_file` tool (`format: "agent"`). New module `src/tools/agent-output.ts` (`buildAgentReport`); deterministic, no new dependency.
|
|
14
|
+
- This unifies what was scattered across `fix_code`, `scan_file` and per-finding confidence into a single agent contract; complements `secure_this` (auto-apply loop) with a structured manual-fix path.
|
|
15
|
+
- No rule or tool changes (438 / 37).
|
|
16
|
+
|
|
17
|
+
Gate green (build / lint / test / self-audit PASS / A / 0).
|
|
18
|
+
|
|
19
|
+
## [3.4.0] - 2026-06-07
|
|
20
|
+
|
|
21
|
+
### Added — dependency reachability: is the vulnerable package actually imported? (438 rules / 37 tools)
|
|
22
|
+
- **The dependency scan now annotates each vulnerable package with reachability** — whether it is actually imported/required anywhere in your source. A flagged dependency you never import is far lower priority than one your code calls into; this turns the daily-CVE freshness signal into a *prioritized*, actionable one.
|
|
23
|
+
- **Annotate, never suppress:** findings are labeled `reachable: true/false` (and `[imported in source]` / `[not directly imported — likely unreachable]` in the audit), but nothing is dropped — a package can still be reached transitively or via dynamic/framework loading, so there are no new false negatives.
|
|
24
|
+
- New module `src/tools/reachability.ts`: `packageRoot` (specifier → installable name, scoped-package aware), `extractImportedPackages` (import/require/dynamic-import/re-export forms), `collectImportedPackages` (source-tree walk, node_modules excluded), `analyzeReachability`. Import-level (package granularity).
|
|
25
|
+
- Surfaced in `scan_dependencies` (per-package `reachable` + `reachabilityStatus`, summary `reachableVulnerable`) and the `audit` dependency section (`N of M directly imported in source`).
|
|
26
|
+
- No rule or tool changes (438 / 37).
|
|
27
|
+
|
|
28
|
+
Gate green (build / lint / test / self-audit PASS / A / 0).
|
|
29
|
+
|
|
8
30
|
## [3.3.0] - 2026-06-07
|
|
9
31
|
|
|
10
32
|
### Added — diff-aware scanning: block what you just wrote, not the backlog (438 rules / 37 tools)
|
package/README.md
CHANGED
|
@@ -219,7 +219,7 @@ Malicious postinstall scripts, unpinned GitHub Actions, CI `npm` provenance / `-
|
|
|
219
219
|
| `check_project` | Scan multiple files with security scoring (A-F) |
|
|
220
220
|
| `scan_directory` | Scan a project directory from disk |
|
|
221
221
|
| `scan_staged` | Pre-commit scan of git-staged files |
|
|
222
|
-
| `scan_dependencies` | Check all dependencies for known CVEs (OSV) |
|
|
222
|
+
| `scan_dependencies` | Check all dependencies for known CVEs (OSV) — annotates each vulnerable package with **reachability** (is it actually imported in your source?) |
|
|
223
223
|
| `scan_secrets` | Detect leaked secrets, API keys, tokens |
|
|
224
224
|
| `check_dependencies` | Check individual packages against OSV |
|
|
225
225
|
| `check_package_health` | Typosquat detection, maintenance status, adoption metrics |
|
|
@@ -330,7 +330,9 @@ npx guardvibe-scan # Scan staged files (for pre-commit)
|
|
|
330
330
|
npx guardvibe-scan --format sarif --output results.sarif # CI mode
|
|
331
331
|
|
|
332
332
|
# Options (all scan commands)
|
|
333
|
-
# --format markdown|json|sarif|buddy
|
|
333
|
+
# --format markdown|json|sarif|buddy|agent
|
|
334
|
+
# agent = guardvibe.agent.v1 — per finding: { id, severity, confidence, exactEdit, manualFix, verify }
|
|
335
|
+
# so an AI agent can apply the exact edit and run the verify step to prove the fix
|
|
334
336
|
# --output <file> Write results to file
|
|
335
337
|
# --fail-on <level> Exit 1 on findings: critical|high|medium|low|none
|
|
336
338
|
# --full Bypass response-size caps (50 JSON / 30 markdown / 200-file taint)
|
package/build/cli/args.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI argument parsing utilities
|
|
3
3
|
*/
|
|
4
|
-
const VALID_FORMATS = new Set(["markdown", "json", "sarif", "buddy"]);
|
|
4
|
+
const VALID_FORMATS = new Set(["markdown", "json", "sarif", "buddy", "agent"]);
|
|
5
5
|
export function getStringFlag(flags, key) {
|
|
6
6
|
const val = flags[key];
|
|
7
7
|
if (val === undefined || val === true)
|
package/build/cli/scan.js
CHANGED
|
@@ -206,9 +206,17 @@ export async function runFileCheck(filePath, flags) {
|
|
|
206
206
|
process.exit(1);
|
|
207
207
|
}
|
|
208
208
|
const format = validateFormat(flags);
|
|
209
|
-
const formatArg = format === "json" ? "json" : format === "buddy" ? "buddy" : "markdown";
|
|
210
209
|
const findings = analyzeFileSecurity(content, language, undefined, resolved, undefined);
|
|
211
|
-
|
|
210
|
+
let result;
|
|
211
|
+
if (format === "agent") {
|
|
212
|
+
// Agent-native contract: finding + exact-edit + confidence + verify step.
|
|
213
|
+
const { buildAgentReport } = await import("../tools/agent-output.js");
|
|
214
|
+
result = JSON.stringify(buildAgentReport(findings, content, language, resolved));
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const formatArg = format === "json" ? "json" : format === "buddy" ? "buddy" : "markdown";
|
|
218
|
+
result = renderFindings(findings, language, undefined, formatArg, resolved);
|
|
219
|
+
}
|
|
212
220
|
const outputFile = getOutputPath(flags);
|
|
213
221
|
if (outputFile) {
|
|
214
222
|
safeWriteOutput(outputFile, result);
|
package/build/cli.js
CHANGED
|
@@ -44,7 +44,8 @@ function printUsage() {
|
|
|
44
44
|
npx guardvibe-scan --format sarif --output results.sarif
|
|
45
45
|
|
|
46
46
|
Options:
|
|
47
|
-
--format <type> Output format: markdown (default), json, sarif, buddy
|
|
47
|
+
--format <type> Output format: markdown (default), json, sarif, buddy, agent
|
|
48
|
+
agent = guardvibe.agent.v1 (finding + exact edit + confidence + verify)
|
|
48
49
|
--output <file> Write results to file instead of stdout
|
|
49
50
|
--fail-on <level> Exit 1 when findings at this level or above exist
|
|
50
51
|
critical (default) | high | medium | low | none
|
package/build/index.js
CHANGED
|
@@ -42,6 +42,7 @@ import { formatHostFindings, redactSecrets } from "./server/types.js";
|
|
|
42
42
|
import { verifyFix } from "./tools/verify-fix.js";
|
|
43
43
|
import { fixCode as fixCodeTool } from "./tools/fix-code.js";
|
|
44
44
|
import { secureThis } from "./tools/secure-this.js";
|
|
45
|
+
import { buildAgentReport } from "./tools/agent-output.js";
|
|
45
46
|
import { analyzeAuthCoverage, formatAuthCoverage } from "./tools/auth-coverage.js";
|
|
46
47
|
import { buildDeepScanPrompt, parseDeepScanResult, formatDeepScanFindings, callLLM } from "./tools/deep-scan.js";
|
|
47
48
|
import { runFullAudit, formatAuditResult } from "./tools/full-audit.js";
|
|
@@ -506,9 +507,9 @@ server.tool("explain_remediation", "Pass a GuardVibe rule ID (e.g. VG154) to get
|
|
|
506
507
|
});
|
|
507
508
|
// Tool 23: Quick file scan — returns structured findings, not raw file contents.
|
|
508
509
|
// guardvibe-ignore VG880
|
|
509
|
-
server.tool("scan_file", "Scan a single file on disk by path for security vulnerabilities. Pass a file path — the tool reads the file itself. For inline code snippets, use check_code instead. Example: scan_file({file_path: 'src/api/route.ts'})", {
|
|
510
|
+
server.tool("scan_file", "Scan a single file on disk by path for security vulnerabilities. Pass a file path — the tool reads the file itself. For inline code snippets, use check_code instead. The 'agent' format returns the structured guardvibe.agent.v1 contract (finding + exact edit + confidence + verify step). Example: scan_file({file_path: 'src/api/route.ts', format: 'agent'})", {
|
|
510
511
|
file_path: z.string().describe("Absolute or relative path to the file to scan"),
|
|
511
|
-
format: z.enum(["markdown", "json"]).default("json").describe("Output format"),
|
|
512
|
+
format: z.enum(["markdown", "json", "agent"]).default("json").describe("Output format. 'agent' = machine-actionable guardvibe.agent.v1 (exact edits + confidence + verify)"),
|
|
512
513
|
}, async ({ file_path, format }) => {
|
|
513
514
|
const { readFileSync, existsSync } = await import("fs");
|
|
514
515
|
const { resolve, extname, basename, dirname } = await import("path");
|
|
@@ -529,6 +530,11 @@ server.tool("scan_file", "Scan a single file on disk by path for security vulner
|
|
|
529
530
|
}
|
|
530
531
|
const rules = getRules();
|
|
531
532
|
const findings = analyzeFileSecurity(content, language, undefined, resolved, dirname(resolved), rules);
|
|
533
|
+
if (format === "agent") {
|
|
534
|
+
const cwdA = dirname(resolved);
|
|
535
|
+
recordScan(cwdA, { toolName: "scan_file", filesScanned: 1, findings: findings.map(f => ({ severity: f.rule.severity, ruleId: f.rule.id })) });
|
|
536
|
+
return { content: [{ type: "text", text: JSON.stringify(buildAgentReport(findings, content, language, resolved, rules)) }] };
|
|
537
|
+
}
|
|
532
538
|
const result = renderFindings(findings, language, undefined, format, resolved);
|
|
533
539
|
const cwd = dirname(resolved);
|
|
534
540
|
recordScan(cwd, { toolName: "scan_file", filesScanned: 1, findings: findings.map(f => ({ severity: f.rule.severity, ruleId: f.rule.id })) });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-native structured output (FAZ 4).
|
|
3
|
+
*
|
|
4
|
+
* Coding agents act on data, not prose. This builds one stable, documented
|
|
5
|
+
* contract per finding so an agent can: read severity + confidence, apply the
|
|
6
|
+
* exact edit deterministically when one exists, and run a verify step to prove
|
|
7
|
+
* the issue is gone. It unifies what was scattered across fix_code (edits),
|
|
8
|
+
* scan_file (suggested_fixes) and the finding's own confidence into a single
|
|
9
|
+
* `guardvibe.agent.v1` shape.
|
|
10
|
+
*
|
|
11
|
+
* No new dependency, fully deterministic (same findings + code → same report).
|
|
12
|
+
*/
|
|
13
|
+
import type { Finding } from "./check-code.js";
|
|
14
|
+
import { type StructuredEdit } from "./fix-code.js";
|
|
15
|
+
import type { SecurityRule } from "../data/rules/types.js";
|
|
16
|
+
export interface AgentFinding {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
severity: string;
|
|
20
|
+
owasp?: string;
|
|
21
|
+
file: string;
|
|
22
|
+
line: number;
|
|
23
|
+
confidence: "high" | "medium" | "low";
|
|
24
|
+
autoFixable: boolean;
|
|
25
|
+
exactEdit: StructuredEdit | null;
|
|
26
|
+
manualFix: string;
|
|
27
|
+
/** A deterministic, runnable step that proves the finding is resolved. */
|
|
28
|
+
verify: {
|
|
29
|
+
command: string;
|
|
30
|
+
expect: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export interface AgentReport {
|
|
34
|
+
schema: "guardvibe.agent.v1";
|
|
35
|
+
file: string;
|
|
36
|
+
total: number;
|
|
37
|
+
autoFixable: number;
|
|
38
|
+
findings: AgentFinding[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a file's findings into the agent-native contract. `fixCode` is run
|
|
42
|
+
* once to attach exact edits to the findings that have one.
|
|
43
|
+
*/
|
|
44
|
+
export declare function buildAgentReport(findings: Finding[], code: string, language: string, filePath: string, rules?: SecurityRule[]): AgentReport;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { fixCode } from "./fix-code.js";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a file's findings into the agent-native contract. `fixCode` is run
|
|
4
|
+
* once to attach exact edits to the findings that have one.
|
|
5
|
+
*/
|
|
6
|
+
export function buildAgentReport(findings, code, language, filePath, rules) {
|
|
7
|
+
// Map ruleId+line → structured edit (only auto-applicable rules yield one).
|
|
8
|
+
const edits = new Map();
|
|
9
|
+
if (findings.length > 0) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(fixCode(code, language, undefined, filePath, "json", rules));
|
|
12
|
+
for (const fx of parsed.fixes ?? []) {
|
|
13
|
+
if (fx.edit)
|
|
14
|
+
edits.set(`${fx.ruleId}:${fx.line}`, fx.edit);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch { /* fixCode is best-effort; findings still report without edits */ }
|
|
18
|
+
}
|
|
19
|
+
const agentFindings = findings.map(f => {
|
|
20
|
+
const exactEdit = edits.get(`${f.rule.id}:${f.line}`) ?? null;
|
|
21
|
+
return {
|
|
22
|
+
id: f.rule.id,
|
|
23
|
+
name: f.rule.name,
|
|
24
|
+
severity: f.rule.severity,
|
|
25
|
+
owasp: f.rule.owasp,
|
|
26
|
+
file: filePath,
|
|
27
|
+
line: f.line,
|
|
28
|
+
confidence: f.confidence,
|
|
29
|
+
autoFixable: !!exactEdit,
|
|
30
|
+
exactEdit,
|
|
31
|
+
manualFix: f.rule.fix,
|
|
32
|
+
verify: {
|
|
33
|
+
command: `npx guardvibe check ${filePath} --format json`,
|
|
34
|
+
expect: `${f.rule.id} no longer reported at ${filePath}:${f.line}`,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
schema: "guardvibe.agent.v1",
|
|
40
|
+
file: filePath,
|
|
41
|
+
total: agentFindings.length,
|
|
42
|
+
autoFixable: agentFindings.filter(f => f.autoFixable).length,
|
|
43
|
+
findings: agentFindings,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -248,7 +248,7 @@ export async function runFullAudit(path, options) {
|
|
|
248
248
|
}
|
|
249
249
|
if (existsSync(manifestPath)) {
|
|
250
250
|
try {
|
|
251
|
-
const depsJson = await scanDependencies(manifestPath, "json");
|
|
251
|
+
const depsJson = await scanDependencies(manifestPath, "json", { root: projectRoot });
|
|
252
252
|
const parsed = safeJsonParse(depsJson);
|
|
253
253
|
if (!parsed) {
|
|
254
254
|
// scanDependencies returned a markdown error (OSV API unreachable, parse failed, etc).
|
|
@@ -259,9 +259,15 @@ export async function runFullAudit(path, options) {
|
|
|
259
259
|
}
|
|
260
260
|
if (parsed) {
|
|
261
261
|
const vulnPackages = parsed.summary?.vulnerable ?? 0;
|
|
262
|
+
const reachableVulnerable = parsed.summary?.reachableVulnerable;
|
|
262
263
|
const depFindings = [];
|
|
263
264
|
let depCritical = 0, depHigh = 0, depMedium = 0;
|
|
264
265
|
for (const pkg of parsed.packages ?? []) {
|
|
266
|
+
const pkgRec = pkg;
|
|
267
|
+
const reachable = pkgRec.reachable;
|
|
268
|
+
const reachNote = reachable === false
|
|
269
|
+
? " [not directly imported — likely unreachable]"
|
|
270
|
+
: reachable === true ? " [imported in source]" : "";
|
|
265
271
|
for (const v of pkg.vulnerabilities ?? []) {
|
|
266
272
|
const vuln2 = v;
|
|
267
273
|
const sev = (vuln2.severity ?? "high");
|
|
@@ -276,17 +282,20 @@ export async function runFullAudit(path, options) {
|
|
|
276
282
|
severity: sev,
|
|
277
283
|
file: "package.json",
|
|
278
284
|
line: 0,
|
|
279
|
-
name: `${
|
|
280
|
-
description: (vuln2.summary ?? vuln2.details ?? "")
|
|
281
|
-
fix: `Run: npm update ${
|
|
285
|
+
name: `${pkgRec.name ?? "unknown"}: ${(vuln2.id ?? "CVE")}`,
|
|
286
|
+
description: `${(vuln2.summary ?? vuln2.details ?? "")}${reachNote}`,
|
|
287
|
+
fix: `Run: npm update ${pkgRec.name ?? ""}`,
|
|
282
288
|
});
|
|
283
289
|
allFindings.push({ ruleId: `DEP:${vuln2.id ?? "CVE"}`, severity: sev, file: "package.json", line: 0 });
|
|
284
290
|
}
|
|
285
291
|
}
|
|
286
292
|
const counts = { findings: depFindings.length, critical: depCritical, high: depHigh, medium: depMedium };
|
|
293
|
+
const reachText = typeof reachableVulnerable === "number" && vulnPackages > 0
|
|
294
|
+
? ` (${reachableVulnerable} of ${vulnPackages} directly imported in source)`
|
|
295
|
+
: "";
|
|
287
296
|
const detailText = depFindings.length === 0
|
|
288
297
|
? "No known CVEs"
|
|
289
|
-
: `${depFindings.length} CVE(s) across ${vulnPackages} vulnerable package(s)`;
|
|
298
|
+
: `${depFindings.length} CVE(s) across ${vulnPackages} vulnerable package(s)${reachText}`;
|
|
290
299
|
sections.push({ name: "dependencies", status: "ok", ...counts, details: detailText, sectionFindings: depFindings });
|
|
291
300
|
}
|
|
292
301
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The installable package name behind a module specifier, or null if not a bare package. */
|
|
2
|
+
export declare function packageRoot(specifier: string): string | null;
|
|
3
|
+
/** All bare package roots imported/required/re-exported in a file's source. */
|
|
4
|
+
export declare function extractImportedPackages(code: string): Set<string>;
|
|
5
|
+
/** Walk a source tree and collect every imported package root (node_modules excluded). */
|
|
6
|
+
export declare function collectImportedPackages(root: string, opts?: {
|
|
7
|
+
maxFiles?: number;
|
|
8
|
+
}): Set<string>;
|
|
9
|
+
export type ReachabilityStatus = "imported" | "not_imported";
|
|
10
|
+
export interface ReachabilityResult {
|
|
11
|
+
reachable: boolean;
|
|
12
|
+
status: ReachabilityStatus;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Annotate each package name with whether it is imported anywhere under `root`.
|
|
16
|
+
* Pass `importedOverride` (a precomputed set) to avoid a filesystem walk (used in tests
|
|
17
|
+
* and to share one walk across many packages).
|
|
18
|
+
*/
|
|
19
|
+
export declare function analyzeReachability(packageNames: string[], root: string, importedOverride?: Set<string>): Map<string, ReachabilityResult>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// guardvibe-ignore — defines import-detection regexes; the `require(...)`/`import(...)`
|
|
2
|
+
// string literals here are detector patterns, not vulnerable code.
|
|
3
|
+
/**
|
|
4
|
+
* Dependency reachability — is a vulnerable package actually used by YOUR code?
|
|
5
|
+
*
|
|
6
|
+
* A vulnerable version in package.json is only exploitable from your app if the
|
|
7
|
+
* package is actually imported/required somewhere in source. Flagging every
|
|
8
|
+
* advisory regardless drowns the real ones in transitive noise. Reachability
|
|
9
|
+
* answers "do you import this?" so a flagged-but-unimported dependency can be
|
|
10
|
+
* deprioritized.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT — annotate, never suppress: a package can still be reached
|
|
13
|
+
* transitively or via dynamic/framework loading, so we LABEL findings
|
|
14
|
+
* (reachable: true/false) and never drop them. This keeps the freshness moat
|
|
15
|
+
* honest (no false negatives) while cutting the noise.
|
|
16
|
+
*
|
|
17
|
+
* This is import-level (package granularity), not call-graph/function level.
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync, statSync, readFileSync } from "fs";
|
|
20
|
+
import { join, extname } from "path";
|
|
21
|
+
const CODE_EXT = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
|
|
22
|
+
const SKIP_DIR = new Set([
|
|
23
|
+
"node_modules", ".git", ".next", "dist", "build", "out", "coverage",
|
|
24
|
+
".turbo", "vendor", ".vercel", ".cache", ".svelte-kit",
|
|
25
|
+
]);
|
|
26
|
+
/** The installable package name behind a module specifier, or null if not a bare package. */
|
|
27
|
+
export function packageRoot(specifier) {
|
|
28
|
+
if (!specifier)
|
|
29
|
+
return null;
|
|
30
|
+
if (specifier.startsWith(".") || specifier.startsWith("/"))
|
|
31
|
+
return null; // relative / absolute
|
|
32
|
+
if (specifier.startsWith("node:"))
|
|
33
|
+
return null; // node builtin
|
|
34
|
+
const parts = specifier.split("/");
|
|
35
|
+
if (specifier.startsWith("@")) {
|
|
36
|
+
if (parts.length < 2 || !parts[1])
|
|
37
|
+
return null; // incomplete scoped specifier
|
|
38
|
+
return `${parts[0]}/${parts[1]}`;
|
|
39
|
+
}
|
|
40
|
+
return parts[0];
|
|
41
|
+
}
|
|
42
|
+
const IMPORT_FROM = /(?:import|export)\b[^'"]*?\bfrom\s+['"]([^'"]+)['"]/g;
|
|
43
|
+
const BARE_IMPORT = /\bimport\s+['"]([^'"]+)['"]/g;
|
|
44
|
+
const REQUIRE_CALL = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
45
|
+
const DYNAMIC_IMPORT = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
46
|
+
/** All bare package roots imported/required/re-exported in a file's source. */
|
|
47
|
+
export function extractImportedPackages(code) {
|
|
48
|
+
const out = new Set();
|
|
49
|
+
const add = (spec) => {
|
|
50
|
+
const root = packageRoot(spec);
|
|
51
|
+
if (root)
|
|
52
|
+
out.add(root);
|
|
53
|
+
};
|
|
54
|
+
for (const re of [IMPORT_FROM, BARE_IMPORT, REQUIRE_CALL, DYNAMIC_IMPORT]) {
|
|
55
|
+
re.lastIndex = 0;
|
|
56
|
+
for (const m of code.matchAll(re))
|
|
57
|
+
add(m[1]);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** Walk a source tree and collect every imported package root (node_modules excluded). */
|
|
62
|
+
export function collectImportedPackages(root, opts = {}) {
|
|
63
|
+
const found = new Set();
|
|
64
|
+
const maxFiles = opts.maxFiles ?? 20_000;
|
|
65
|
+
let count = 0;
|
|
66
|
+
const walk = (dir) => {
|
|
67
|
+
if (count >= maxFiles)
|
|
68
|
+
return;
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(dir);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
if (count >= maxFiles)
|
|
78
|
+
return;
|
|
79
|
+
if (SKIP_DIR.has(e))
|
|
80
|
+
continue;
|
|
81
|
+
const p = join(dir, e);
|
|
82
|
+
let st;
|
|
83
|
+
try {
|
|
84
|
+
st = statSync(p);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (st.isDirectory()) {
|
|
90
|
+
walk(p);
|
|
91
|
+
}
|
|
92
|
+
else if (CODE_EXT.has(extname(e).toLowerCase()) && st.size < 1_000_000) {
|
|
93
|
+
count++;
|
|
94
|
+
let code;
|
|
95
|
+
try {
|
|
96
|
+
code = readFileSync(p, "utf-8");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const pkg of extractImportedPackages(code))
|
|
102
|
+
found.add(pkg);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
walk(root);
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Annotate each package name with whether it is imported anywhere under `root`.
|
|
111
|
+
* Pass `importedOverride` (a precomputed set) to avoid a filesystem walk (used in tests
|
|
112
|
+
* and to share one walk across many packages).
|
|
113
|
+
*/
|
|
114
|
+
export function analyzeReachability(packageNames, root, importedOverride) {
|
|
115
|
+
const imported = importedOverride ?? collectImportedPackages(root);
|
|
116
|
+
const out = new Map();
|
|
117
|
+
for (const name of packageNames) {
|
|
118
|
+
const reachable = imported.has(name);
|
|
119
|
+
out.set(name, { reachable, status: reachable ? "imported" : "not_imported" });
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
@@ -1 +1,7 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface ScanDependenciesOptions {
|
|
2
|
+
/** Source root to scan for imports (defaults to the manifest's directory). */
|
|
3
|
+
root?: string;
|
|
4
|
+
/** Annotate each vulnerable package with whether it is imported in source. Default true. */
|
|
5
|
+
reachability?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function scanDependencies(manifestPath: string, format?: "markdown" | "json", opts?: ScanDependenciesOptions): Promise<string>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
-
import { basename } from "path";
|
|
2
|
+
import { basename, dirname } from "path";
|
|
3
3
|
import { parseManifest } from "../utils/manifest-parser.js";
|
|
4
4
|
import { queryOsvBatch, formatVulnerability, normalizeSeverity } from "../utils/osv-client.js";
|
|
5
|
-
|
|
5
|
+
import { analyzeReachability } from "./reachability.js";
|
|
6
|
+
export async function scanDependencies(manifestPath, format = "markdown", opts = {}) {
|
|
6
7
|
let content;
|
|
7
8
|
try {
|
|
8
9
|
content = readFileSync(manifestPath, "utf-8");
|
|
@@ -42,6 +43,20 @@ export async function scanDependencies(manifestPath, format = "markdown") {
|
|
|
42
43
|
}
|
|
43
44
|
let totalVulns = 0;
|
|
44
45
|
const criticalPackages = [];
|
|
46
|
+
// --- Reachability: is each vulnerable package actually imported in YOUR source? ---
|
|
47
|
+
// Annotate only (never suppress) — a package may still be reached transitively.
|
|
48
|
+
const vulnerableNames = packages
|
|
49
|
+
.filter(p => (vulnResults.get(`${p.name}@${p.version}`) || []).length > 0)
|
|
50
|
+
.map(p => p.name);
|
|
51
|
+
let reach = null;
|
|
52
|
+
if (opts.reachability !== false && vulnerableNames.length > 0) {
|
|
53
|
+
try {
|
|
54
|
+
reach = analyzeReachability(vulnerableNames, opts.root ?? dirname(manifestPath));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
reach = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
45
60
|
// Build per-package vulnerability data
|
|
46
61
|
const pkgResults = [];
|
|
47
62
|
for (const pkg of packages) {
|
|
@@ -51,8 +66,11 @@ export async function scanDependencies(manifestPath, format = "markdown") {
|
|
|
51
66
|
continue;
|
|
52
67
|
totalVulns += vulns.length;
|
|
53
68
|
criticalPackages.push(key);
|
|
69
|
+
const r = reach?.get(pkg.name);
|
|
54
70
|
pkgResults.push({
|
|
55
71
|
name: pkg.name, version: pkg.version, ecosystem: pkg.ecosystem,
|
|
72
|
+
reachable: r?.reachable,
|
|
73
|
+
reachabilityStatus: r?.status,
|
|
56
74
|
vulnerabilities: vulns.map(v => ({
|
|
57
75
|
id: v.id, severity: normalizeSeverity(v), summary: v.summary,
|
|
58
76
|
fixedIn: (v.affected ?? []).flatMap((a) => (a.ranges ?? []).flatMap((r) => r.events.filter((e) => e.fixed).map((e) => e.fixed))).join(", ") || undefined,
|
|
@@ -60,6 +78,11 @@ export async function scanDependencies(manifestPath, format = "markdown") {
|
|
|
60
78
|
})),
|
|
61
79
|
});
|
|
62
80
|
lines.push(`## ${key} (${pkg.ecosystem}) — ${vulns.length} vulnerabilities`, ``);
|
|
81
|
+
if (r) {
|
|
82
|
+
lines.push(r.reachable
|
|
83
|
+
? `_Reachability: imported in your source._`
|
|
84
|
+
: `_Reachability: not directly imported — likely unreachable (may still be used transitively)._`, ``);
|
|
85
|
+
}
|
|
63
86
|
for (const vuln of vulns) {
|
|
64
87
|
lines.push(formatVulnerability(vuln), ``);
|
|
65
88
|
}
|
|
@@ -78,6 +101,7 @@ export async function scanDependencies(manifestPath, format = "markdown") {
|
|
|
78
101
|
vulnerable: criticalPackages.length,
|
|
79
102
|
vulnerablePackages: criticalPackages.length,
|
|
80
103
|
totalAdvisories: totalVulns,
|
|
104
|
+
...(reach ? { reachableVulnerable: [...reach.values()].filter(x => x.reachable).length } : {}),
|
|
81
105
|
...sevCounts,
|
|
82
106
|
},
|
|
83
107
|
packages: pkgResults,
|
|
@@ -91,6 +115,10 @@ export async function scanDependencies(manifestPath, format = "markdown") {
|
|
|
91
115
|
lines.push(`**${totalVulns} vulnerabilities** found in ${criticalPackages.length} packages:`, ``);
|
|
92
116
|
for (const pkg of criticalPackages)
|
|
93
117
|
lines.push(`- ${pkg}`);
|
|
118
|
+
if (reach) {
|
|
119
|
+
const reachableCount = [...reach.values()].filter(x => x.reachable).length;
|
|
120
|
+
lines.push(``, `**Reachability:** ${reachableCount} of ${reach.size} vulnerable package(s) are directly imported in your source — prioritize those.`);
|
|
121
|
+
}
|
|
94
122
|
lines.push(``, `**Action:** Update affected packages to their fixed versions.`);
|
|
95
123
|
}
|
|
96
124
|
return lines.join("\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardvibe",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.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. 438 rules, 37 tools, CLI + doctor. Host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. 67 CVE rules refreshed daily from GHSA/OSV/CISA KEV — Miasma @redhat-cloud-services compromise, Next.js May 2026 13-advisory cluster, Drizzle/MikroORM/Kysely SQL injection, Axios proxy-auth redirect leak, Hono setCookie attribute injection, Clerk SSRF, tRPC prototype pollution, @tanstack supply-chain, node-ipc protestware, OpenClaude sandbox bypass, plus the full AI-generated stack (Supabase, Stripe, Prisma, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK). 68 AI-native rules including OWASP MCP Top 10 tool-description prompt injection (VG1068), model-controlled sandbox-disable flag detection (VG1063), Session messenger exfil endpoint IOC (VG1075), and CI/CD supply-chain hardening (VG1070 npm --expect-provenance / --ignore-scripts enforcement).",
|
|
6
6
|
"type": "module",
|