guardvibe 3.2.0 → 3.4.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.4.0] - 2026-06-07
9
+
10
+ ### Added — dependency reachability: is the vulnerable package actually imported? (438 rules / 37 tools)
11
+ - **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.
12
+ - **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.
13
+ - 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).
14
+ - Surfaced in `scan_dependencies` (per-package `reachable` + `reachabilityStatus`, summary `reachableVulnerable`) and the `audit` dependency section (`N of M directly imported in source`).
15
+ - No rule or tool changes (438 / 37).
16
+
17
+ Gate green (build / lint / test / self-audit PASS / A / 0).
18
+
19
+ ## [3.3.0] - 2026-06-07
20
+
21
+ ### Added — diff-aware scanning: block what you just wrote, not the backlog (438 rules / 37 tools)
22
+ - **`guardvibe diff [base]` is now diff-aware by default** — it reports only findings on lines the change actually **added**, instead of re-reporting pre-existing debt in every file you touched. This makes the gate actionable: it blocks the issues newly introduced vs the base, the ones an AI agent just wrote.
23
+ - **Transparent, never silent:** the report states the mode and how many pre-existing findings on unchanged lines were hidden (`preExistingHidden` in JSON; a note in markdown). `--all-lines` restores the whole-changed-file view.
24
+ - New module `src/tools/diff-aware.ts` — a pure, git-free unified-diff hunk parser (`addedLinesFromUnifiedDiff`) plus thin `git diff` wrappers (`getAddedLinesForDiff`, `getAddedLinesStaged`) and a `filterToAddedLines` helper. Unit-tested independent of git; works at any `--unified` context level; counts newly-added files as fully added; ignores deletions.
25
+ - No rule or tool changes (438 / 37).
26
+
27
+ Gate green (build / lint / test / self-audit PASS / A / 0).
28
+
8
29
  ## [3.2.0] - 2026-06-07
9
30
 
10
31
  ### Added — `secure_this`: close the loop from "warns" to "guarantees the fix landed" (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 |
@@ -292,7 +292,8 @@ All scanning tools support `format: "json"` for machine-readable output.
292
292
  npx guardvibe scan [path] # Scan a directory for security issues
293
293
  npx guardvibe scan . --format json # JSON output for automation
294
294
  npx guardvibe check <file> # Scan a single file
295
- npx guardvibe diff [base] # Scan only changed files since git ref
295
+ npx guardvibe diff [base] # Scan changed files reports only newly-introduced issues
296
+ npx guardvibe diff [base] --all-lines # Include pre-existing findings in changed files too
296
297
 
297
298
  # Close the loop — scan, apply verified fixes, re-verify
298
299
  npx guardvibe secure-this <file> # Dry run: show the fixes that would land + remaining manual work
package/build/cli/scan.js CHANGED
@@ -78,9 +78,13 @@ export async function runDiffScan(base, flags) {
78
78
  const { execFileSync } = await import("child_process");
79
79
  const { analyzeFileSecurity } = await import("../tools/file-security.js");
80
80
  const { EXTENSION_MAP, CONFIG_FILE_MAP } = await import("../utils/constants.js");
81
+ const { getAddedLinesForDiff, filterToAddedLines } = await import("../tools/diff-aware.js");
81
82
  const format = validateFormat(flags);
82
83
  const outputFile = getOutputPath(flags);
83
84
  const root = resolve(".");
85
+ // Diff-aware by default: report only issues on newly-added lines. --all-lines
86
+ // restores the old whole-changed-file behavior (surfaces pre-existing debt too).
87
+ const allLines = flags["all-lines"] === true;
84
88
  let changedFiles;
85
89
  try {
86
90
  const output = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", base], { cwd: root, encoding: "utf-8" });
@@ -95,6 +99,7 @@ export async function runDiffScan(base, flags) {
95
99
  return;
96
100
  }
97
101
  const allFindings = [];
102
+ let preExistingHidden = 0;
98
103
  for (const relPath of changedFiles) {
99
104
  const fullPath = resolve(root, relPath);
100
105
  if (!existsSync(fullPath))
@@ -110,24 +115,34 @@ export async function runDiffScan(base, flags) {
110
115
  try {
111
116
  const content = readFileSync(fullPath, "utf-8");
112
117
  const findings = analyzeFileSecurity(content, language, undefined, fullPath, root);
113
- for (const f of findings) {
118
+ let kept = findings;
119
+ if (!allLines) {
120
+ const added = getAddedLinesForDiff(base, relPath, root);
121
+ kept = filterToAddedLines(findings, added);
122
+ preExistingHidden += findings.length - kept.length;
123
+ }
124
+ for (const f of kept) {
114
125
  allFindings.push({ file: relPath, severity: f.rule.severity, name: f.rule.name, id: f.rule.id, line: f.line, fix: f.rule.fix });
115
126
  }
116
127
  }
117
128
  catch { /* skip */ }
118
129
  }
130
+ const mode = allLines ? "all changed lines" : "newly-introduced lines only";
119
131
  let result;
120
132
  if (format === "json") {
121
133
  const critical = allFindings.filter(f => f.severity === "critical").length;
122
134
  const high = allFindings.filter(f => f.severity === "high").length;
123
135
  const medium = allFindings.filter(f => f.severity === "medium").length;
124
136
  result = JSON.stringify({
125
- summary: { total: allFindings.length, critical, high, medium, changedFiles: changedFiles.length, blocked: critical > 0 || high > 0 },
137
+ summary: { total: allFindings.length, critical, high, medium, changedFiles: changedFiles.length, blocked: critical > 0 || high > 0, diffAware: !allLines, preExistingHidden },
126
138
  findings: allFindings,
127
139
  });
128
140
  }
129
141
  else {
130
- const lines = [`# GuardVibe Diff Report`, ``, `Base: ${base}`, `Changed files: ${changedFiles.length}`, `Issues: ${allFindings.length}`, ``];
142
+ const lines = [`# GuardVibe Diff Report`, ``, `Base: ${base}`, `Mode: ${mode}`, `Changed files: ${changedFiles.length}`, `Issues: ${allFindings.length}`, ``];
143
+ if (!allLines && preExistingHidden > 0) {
144
+ lines.push(`> ${preExistingHidden} pre-existing finding(s) on unchanged lines hidden — re-run with \`--all-lines\` to see them.`, ``);
145
+ }
131
146
  if (allFindings.length === 0) {
132
147
  lines.push(`All changed files passed security checks.`);
133
148
  }
package/build/cli.js CHANGED
@@ -23,7 +23,7 @@ function printUsage() {
23
23
 
24
24
  Commands:
25
25
  npx guardvibe scan [path] Scan a directory for security issues
26
- npx guardvibe diff [base] Scan only changed files since a git ref
26
+ npx guardvibe diff [base] Scan changed files; reports only newly-introduced issues (--all-lines for whole files)
27
27
  npx guardvibe check <file> Scan a single file for security issues
28
28
  npx guardvibe doctor [path] Run host security audit
29
29
  npx guardvibe audit [path] Full security audit with PASS/FAIL verdict
@@ -50,6 +50,7 @@ function printUsage() {
50
50
  critical (default) | high | medium | low | none
51
51
  --baseline <file> Compare against a previous scan JSON for fix tracking
52
52
  --save-baseline Save current scan as baseline (.guardvibe-baseline.json)
53
+ --all-lines (diff) Report all findings in changed files, not just newly-added lines
53
54
  --version, -V Print version and exit
54
55
  --help, -h Show this help message
55
56
 
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Parse unified-diff text and return the set of 1-based line numbers that are
3
+ * ADDED in the new version of the file. Works at any `--unified` context level.
4
+ */
5
+ export declare function addedLinesFromUnifiedDiff(diff: string): Set<number>;
6
+ /** Keep only findings whose line number is in the added-line set. */
7
+ export declare function filterToAddedLines<T extends {
8
+ line: number;
9
+ }>(findings: T[], added: Set<number>): T[];
10
+ /** Lines added in `relPath` relative to a git base ref (branch/commit/HEAD~N). */
11
+ export declare function getAddedLinesForDiff(base: string, relPath: string, cwd: string): Set<number>;
12
+ /** Lines added in `relPath` in the staged (index) changes — for pre-commit gating. */
13
+ export declare function getAddedLinesStaged(relPath: string, cwd: string): Set<number>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Diff-aware scanning — surface only the issues that were NEWLY introduced.
3
+ *
4
+ * Scanning a changed file whole re-reports pre-existing debt the author didn't
5
+ * touch, which trains agents (and people) to ignore the output. Diff-aware
6
+ * filtering keeps only findings that land on lines the current change actually
7
+ * ADDED, so the gate blocks what you just wrote — not the backlog.
8
+ *
9
+ * The hunk parser is pure and git-free (unit-tested); a thin wrapper shells out
10
+ * to `git diff` to obtain the unified diff for a file.
11
+ */
12
+ import { execFileSync } from "child_process";
13
+ /**
14
+ * Parse unified-diff text and return the set of 1-based line numbers that are
15
+ * ADDED in the new version of the file. Works at any `--unified` context level.
16
+ */
17
+ export function addedLinesFromUnifiedDiff(diff) {
18
+ const added = new Set();
19
+ let newLine = 0;
20
+ let inHunk = false;
21
+ for (const raw of diff.split("\n")) {
22
+ if (raw.startsWith("@@")) {
23
+ // @@ -oldStart,oldCount +newStart,newCount @@
24
+ const m = raw.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
25
+ if (m) {
26
+ newLine = parseInt(m[1], 10);
27
+ inHunk = true;
28
+ }
29
+ continue;
30
+ }
31
+ if (!inHunk || raw === "")
32
+ continue;
33
+ if (raw.startsWith("+++") || raw.startsWith("---"))
34
+ continue;
35
+ const c = raw[0];
36
+ if (c === "+") {
37
+ added.add(newLine);
38
+ newLine++;
39
+ }
40
+ else if (c === "-") {
41
+ // deletion — present only in the old file, does not advance the new line
42
+ }
43
+ else if (c === "\\") {
44
+ // "" — metadata, ignore
45
+ }
46
+ else {
47
+ // context line (leading space) — advances the new-file line counter
48
+ newLine++;
49
+ }
50
+ }
51
+ return added;
52
+ }
53
+ /** Keep only findings whose line number is in the added-line set. */
54
+ export function filterToAddedLines(findings, added) {
55
+ return findings.filter(f => added.has(f.line));
56
+ }
57
+ function gitDiffAddedLines(args, relPath, cwd) {
58
+ try {
59
+ const out = execFileSync("git", ["diff", "--no-color", "--unified=0", ...args, "--", relPath], {
60
+ cwd,
61
+ encoding: "utf-8",
62
+ maxBuffer: 32 * 1024 * 1024,
63
+ });
64
+ return addedLinesFromUnifiedDiff(out);
65
+ }
66
+ catch {
67
+ // Not a git repo, untracked path, or git unavailable — caller decides fallback.
68
+ return new Set();
69
+ }
70
+ }
71
+ /** Lines added in `relPath` relative to a git base ref (branch/commit/HEAD~N). */
72
+ export function getAddedLinesForDiff(base, relPath, cwd) {
73
+ return gitDiffAddedLines([base], relPath, cwd);
74
+ }
75
+ /** Lines added in `relPath` in the staged (index) changes — for pre-commit gating. */
76
+ export function getAddedLinesStaged(relPath, cwd) {
77
+ return gitDiffAddedLines(["--cached"], relPath, cwd);
78
+ }
@@ -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: `${pkg.name ?? "unknown"}: ${(vuln2.id ?? "CVE")}`,
280
- description: (vuln2.summary ?? vuln2.details ?? ""),
281
- fix: `Run: npm update ${pkg.name ?? ""}`,
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 declare function scanDependencies(manifestPath: string, format?: "markdown" | "json"): Promise<string>;
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
- export async function scanDependencies(manifestPath, format = "markdown") {
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.2.0",
3
+ "version": "3.4.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",