iamsecure 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -79,7 +79,8 @@ npm run iamsecure
79
79
  | 1. Full dependency tree | Parses `package-lock.json` (v1–v3), `yarn.lock` (classic + berry), `pnpm-lock.yaml` (v5–v9), `composer.lock`, `uv.lock`, `poetry.lock`, `Pipfile.lock`, pinned `requirements*.txt`. It records *why* each package is installed (`next › postcss › nanoid`) and whether it's production or dev-only. |
80
80
  | 2. Known vulnerabilities | Batch-queries [OSV.dev](https://osv.dev), which aggregates GitHub Advisories, npm, PyPA, Packagist and others, including `MAL-` **malicious package** reports. Picks the fix version from the range that contains your version. |
81
81
  | 3. Your own code | Rules for JS/TS, PHP and Python: command/SQL/template injection, SSRF, path traversal, XSS, open redirect, unsafe deserialization, JWT mistakes, disabled TLS, weak crypto, risky Next.js config, secrets exposed through `NEXT_PUBLIC_*`. It also finds hard-coded secrets and `.env` files that are committed or not gitignored. Lightweight taint tracking tells `exec(userInput)` (critical) apart from `exec("ls")` (not reported). |
82
- | 4. Validate | Context scoring (below), and optionally AI review of the top findings. |
82
+ | 4. How you use it | Works out the **attack surface**: whether anything listens on the network (`listen()`, `createServer`, Express/Fastify/Koa, MCP HTTP transports, web frameworks) or the app is a local CLI or stdio process. Then it traces the **real import graph** from your source files through `node_modules`, so it knows which vulnerable packages your code ever loads, and the chain that loads them (`lib/api.ts → axios → form-data`). For some packages it also checks whether you use the one feature the bug lives in (e.g. multipart bodies for `form-data`). |
83
+ | 5. Validate | Context scoring (below), and optionally AI review of the top findings. |
83
84
 
84
85
  ## How "real risk" is calculated
85
86
 
@@ -91,7 +92,11 @@ risk = severity × exposure × attack vector × exploit intel × reachability (
91
92
  - **Vulnerability class decides the browser discount.** A ReDoS in browser-only code is ×0.2, because an attacker can only freeze their own tab. SSRF and path traversal in the browser are ×0.25. XSS stays ×0.95. Dev/build-only code is ×0.3. Installed-but-never-imported packages are ×0.5. Malicious packages are always 10, because install scripts run on every machine that installs them.
92
93
  - **Attack vector.** Advisories that need local access (CVSS `AV:L`) or high privileges score lower.
93
94
  - **Exploit intel.** A CVE in [CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) (exploited in the wild) gets a floor of 9.0. A high [EPSS](https://www.first.org/epss/) exploitation probability boosts the score.
94
- - **Reachability.** If your code calls a function the advisory names (`_.template`, `jwt.verify`), the risk goes up. If not, it goes down slightly.
95
+ - **Attack surface.** Server-side code in an app with no network listener (a CLI, or an MCP server on stdio) is ranked as a *local process*. Only the person running it can feed it input, so DoS is ×0.35 and auth bypass ×0.4. If a server framework turns out to be loaded at runtime, the app counts as network-facing even when no `listen()` call was found.
96
+ - **Reachability**, strongest evidence first:
97
+ - **Module.** The package is installed but nothing your code imports ever loads it (an SDK's optional Express transport that you don't use): ×0.3, and the report names the parts of the parent package you *do* import. Needs `node_modules`, so run `npm ci` first. If `node_modules` doesn't match the lockfile, the result is "unknown", not "never loaded".
98
+ - **Feature.** The package is loaded, but you never use the feature the bug is in (`src/reach/triggers.js`): ×0.5.
99
+ - **Symbol.** Your code calls a function the advisory names (`_.template`, `jwt.verify`): the risk goes up. If it doesn't, the risk goes down slightly.
95
100
 
96
101
  Every adjustment adds a plain-English reason that shows in the terminal, the dashboard and SARIF, so the ranking can be explained.
97
102
 
@@ -159,6 +164,7 @@ bin/iamsecure.js CLI
159
164
  src/scan.js orchestrator → report object
160
165
  src/deps/ lockfile parsers, dependency graph, OSV client, CVSS
161
166
  src/code/ file walker, server/browser classification, rules + taint engine, secrets
167
+ src/reach/ attack surface, import-graph tracing through node_modules, feature triggers
162
168
  src/risk/ exploit intel (KEV/EPSS) and the risk model
163
169
  src/ai/ prompt builder and providers (claude-cli, anthropic, openai, ollama)
164
170
  src/report/ terminal and SARIF output
@@ -170,6 +176,8 @@ To try it from a clone of this repo, run `node bin/iamsecure.js /path/to/your/pr
170
176
  ## Limitations
171
177
 
172
178
  - Code rules are pattern- and taint-based, not a full data-flow engine. Taint tracking is per file and name-based, so two variables with the same name in different scopes can cause false positives. Use `--ai` to triage them.
173
- - The reachability check needs the advisory to name the function. Many advisories don't, and in that case the tool says so.
179
+ - Function-level reachability needs the advisory to name the function. Many advisories don't, and in that case the tool says so.
180
+ - Import tracing follows static `import`/`require` and literal `import()`. It doesn't follow computed `require(variable)`, plugin loaders or TS path aliases, so a package loaded only that way can be marked "never loaded". Each such finding shows its chain, or the lack of one, so you can check it.
181
+ - Attack-surface detection is pattern-based. A listener started in an unusual way can be missed. That's why a loaded server framework always overrides "local".
174
182
  - Packages are matched against published advisories. A zero-day nobody has reported yet won't show up.
175
183
  - Python and PHP import usage isn't mapped to packages yet. Their runtime dependencies are treated as server-side.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iamsecure",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Scan a codebase for vulnerable dependencies and insecure code, ranked by real-world risk \u2014 with an optional AI second opinion.",
5
5
  "author": "Ravindra Bisht",
6
6
  "repository": {
package/src/ai/index.js CHANGED
@@ -34,6 +34,8 @@ export function buildPrompt(f, report) {
34
34
  title: f.title,
35
35
  framework: report.project.framework,
36
36
  whereItRuns: f.exposure?.label,
37
+ attackSurface: report.reachability?.surface,
38
+ moduleReachability: f.reachability,
37
39
  scannerSeverity: f.severityScore,
38
40
  scannerRisk: f.riskScore,
39
41
  scannerReasons: f.reasons,
@@ -82,7 +84,7 @@ export function buildPrompt(f, report) {
82
84
  }
83
85
 
84
86
  parts.push(`Question: is this finding actually exploitable in this codebase, as deployed in production?
85
- Consider: can an attacker reach it (network-facing route, user input), does the vulnerable code path get used, is input validated or sanitised first, does it only run in the browser or at build time, and what an attacker gains.
87
+ Consider: can an attacker reach it (network-facing route, user input — attackSurface says whether the app listens on the network at all), does the vulnerable code path get used (moduleReachability is traced from the real import graph: "not-loaded" means the package is never loaded, "feature-unused" means it is loaded but the named feature is never used), is input validated or sanitised first, does it only run in the browser or at build time, and what an attacker gains.
86
88
 
87
89
  Respond with ONLY a JSON object, no markdown:
88
90
  {"verdict": "exploitable" | "likely" | "unlikely" | "not_exploitable",
@@ -7,6 +7,7 @@ const BUILTINS = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));
7
7
 
8
8
  export const CONTEXT_LABEL = {
9
9
  server: 'runs on server',
10
+ local: 'local process, no network listener',
10
11
  client: 'browser only',
11
12
  universal: 'server + browser',
12
13
  build: 'build/test only',
@@ -91,7 +92,7 @@ export function extractImports(text) {
91
92
  m[0].match(/^import\s+(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)[\s,]/)?.[1] ??
92
93
  before.match(/(?:const|let|var)\s+([\w$]+)\s*=\s*require\s*\(\s*['"][^'"]+['"]$/)?.[1] ??
93
94
  null;
94
- out.push({ pkg, line, binding });
95
+ out.push({ pkg, spec: m[1], line, binding });
95
96
  }
96
97
  return out;
97
98
  }
package/src/code/index.js CHANGED
@@ -4,7 +4,7 @@ import { classifyFile, extractImports } from './context.js';
4
4
  /**
5
5
  * Read the project's source once: classify every code file and index which packages it imports.
6
6
  * @returns {{files: {rel:string, kind:string, ext:string, context:string|null}[], texts: Map<string,string>,
7
- * contexts: Map<string,string>, usage: Map<string, {file:string,line:number,context:string,binding:string|null}[]>}}
7
+ * contexts: Map<string,string>, usage: Map<string, {file:string,line:number,context:string,binding:string|null,spec:string}[]>}}
8
8
  */
9
9
  export function analyzeSource(project) {
10
10
  const files = walkFiles(project.root);
@@ -22,7 +22,7 @@ export function analyzeSource(project) {
22
22
  if (!/\.(php|py)$/.test(f.rel)) {
23
23
  for (const imp of extractImports(text)) {
24
24
  if (!usage.has(imp.pkg)) usage.set(imp.pkg, []);
25
- usage.get(imp.pkg).push({ file: f.rel, line: imp.line, context: f.context, binding: imp.binding });
25
+ usage.get(imp.pkg).push({ file: f.rel, line: imp.line, context: f.context, binding: imp.binding, spec: imp.spec });
26
26
  }
27
27
  }
28
28
  }
@@ -0,0 +1,218 @@
1
+ // Module-level reachability: follow the real import/require graph from your source files into node_modules
2
+ // and record which installed packages actually get loaded at runtime.
3
+ //
4
+ // A package can sit in the lockfile (pulled in by a dependency) without ever being loaded — e.g. an SDK
5
+ // that ships an optional Express-based HTTP transport you never import. This is what tells those apart.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { builtinModules } from 'node:module';
10
+
11
+ const BUILTINS = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));
12
+ const EXTS = ['.js', '.cjs', '.mjs', '.json', '.node'];
13
+ const SRC_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
14
+ const MAX_MODULES = 30000;
15
+ // Two passes per package: the CommonJS view and the ESM view. Browser/edge builds are never followed.
16
+ const CONDITION_SETS = [new Set(['node', 'require', 'default', 'module-sync']), new Set(['node', 'import', 'default', 'module-sync'])];
17
+
18
+ // Static imports, re-exports, require() and literal dynamic import(). `import type` / `export type` are skipped.
19
+ const SPEC_RE =
20
+ /(?:\bimport\s+(type\s+)?(?:[\w*{}\s,$]+?\s+from\s+)?|\bexport\s+(type\s+)?(?:[\w*{}\s,$]+?\s+)?from\s+|\brequire\s*\(\s*|\bimport\s*\(\s*)(['"])([^'"\n]+)\3/g;
21
+
22
+ export function moduleSpecifiers(text) {
23
+ const out = [];
24
+ for (const m of text.matchAll(SPEC_RE)) if (!m[1] && !m[2]) out.push(m[4]);
25
+ return out;
26
+ }
27
+
28
+ const statFile = (p) => {
29
+ try {
30
+ return fs.statSync(p).isFile();
31
+ } catch {
32
+ return false;
33
+ }
34
+ };
35
+
36
+ function readJson(p) {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** Resolve a file path the way Node (and TS's `.js` → `.ts` mapping) would. */
45
+ function resolveFile(p, exts) {
46
+ if (statFile(p)) return p;
47
+ for (const e of exts) if (statFile(p + e)) return p + e;
48
+ // TypeScript sources import "./x.js" meaning "./x.ts".
49
+ const m = p.match(/\.(m|c)?js$/);
50
+ if (m) for (const e of SRC_EXTS) if (statFile(p.slice(0, -m[0].length) + e)) return p.slice(0, -m[0].length) + e;
51
+ for (const e of exts) if (statFile(path.join(p, 'index' + e))) return path.join(p, 'index' + e);
52
+ return null;
53
+ }
54
+
55
+ /** Pick the target(s) of an `exports` condition object for one condition set. */
56
+ function pickConditions(target, conds) {
57
+ if (typeof target === 'string') return [target];
58
+ if (Array.isArray(target)) return target.flatMap((t) => pickConditions(t, conds)).slice(0, 1);
59
+ if (target && typeof target === 'object') {
60
+ for (const [k, v] of Object.entries(target)) if (conds.has(k)) return pickConditions(v, conds);
61
+ }
62
+ return [];
63
+ }
64
+
65
+ /** Map a subpath ("." or "./server/mcp.js") through package.json "exports". */
66
+ function resolveExports(exp, sub) {
67
+ if (typeof exp === 'string' || Array.isArray(exp) || !Object.keys(exp).some((k) => k.startsWith('.'))) exp = { '.': exp };
68
+ const out = new Set();
69
+ for (const conds of CONDITION_SETS) {
70
+ if (exp[sub] !== undefined) {
71
+ pickConditions(exp[sub], conds).forEach((t) => out.add(t));
72
+ continue;
73
+ }
74
+ // Longest matching "./prefix/*suffix" pattern wins.
75
+ const pat = Object.keys(exp)
76
+ .filter((k) => k.includes('*'))
77
+ .map((k) => [k, ...k.split('*')])
78
+ .filter(([, pre, post]) => sub.startsWith(pre) && sub.endsWith(post) && sub.length >= pre.length + post.length)
79
+ .sort((a, b) => b[1].length - a[1].length)[0];
80
+ if (pat) {
81
+ const star = sub.slice(pat[1].length, sub.length - pat[2].length);
82
+ pickConditions(exp[pat[0]], conds).forEach((t) => out.add(t.replaceAll('*', star)));
83
+ }
84
+ }
85
+ return [...out];
86
+ }
87
+
88
+ /** All on-disk entry files a bare specifier can load, from a given directory. */
89
+ function resolveBare(spec, fromDir, pkgCache) {
90
+ const parts = spec.split('/');
91
+ const name = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
92
+ const sub = spec.slice(name.length);
93
+ for (let dir = fromDir; ; ) {
94
+ const pkgDir = path.join(dir, 'node_modules', name);
95
+ const manifest = pkgCache(pkgDir);
96
+ if (manifest) {
97
+ let targets;
98
+ if (manifest.exports !== undefined) targets = resolveExports(manifest.exports, '.' + sub);
99
+ else if (sub) targets = ['.' + sub];
100
+ else targets = [manifest.main ?? 'index.js'];
101
+ const files = targets.map((t) => resolveFile(path.join(pkgDir, t), EXTS)).filter(Boolean);
102
+ return { pkgDir, files };
103
+ }
104
+ const parent = path.dirname(dir);
105
+ if (parent === dir) return null;
106
+ dir = parent;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Walk from the project's runtime source files through every module they load.
112
+ * @param {string} root project root
113
+ * @param {{rel:string, context:string}[]} entries source files (build/test files are excluded by the caller)
114
+ * @returns {{available: boolean, loaded: Map<string, {chain: string[]}>, installed: Set<string>, modules: number, truncated: boolean}}
115
+ * `loaded` is keyed by "name@version" of every package that gets loaded; `installed` holds every one on disk.
116
+ */
117
+ export function walkModules(root, entries) {
118
+ root = path.resolve(root);
119
+ if (!fs.existsSync(path.join(root, 'node_modules'))) return { available: false, loaded: new Map(), installed: new Set(), modules: 0, truncated: false };
120
+
121
+ const manifests = new Map();
122
+ const pkgCache = (dir) => {
123
+ if (!manifests.has(dir)) manifests.set(dir, readJson(path.join(dir, 'package.json')));
124
+ return manifests.get(dir);
125
+ };
126
+ /** Nearest package root for a file inside node_modules. */
127
+ const ownerOf = (file) => {
128
+ const i = file.lastIndexOf(`${path.sep}node_modules${path.sep}`);
129
+ if (i === -1) return null;
130
+ const rest = file.slice(i + 14).split(path.sep);
131
+ const dir = file.slice(0, i + 14) + (rest[0].startsWith('@') ? rest.slice(0, 2).join(path.sep) : rest[0]);
132
+ const m = pkgCache(dir);
133
+ return m?.name && m.version ? { dir, key: `${m.name}@${m.version}`, name: m.name } : null;
134
+ };
135
+
136
+ const parent = new Map(); // file → file that first loaded it (null for entries)
137
+ const loaded = new Map();
138
+ const queue = [];
139
+ for (const e of entries) {
140
+ const abs = path.join(root, e.rel);
141
+ if (!parent.has(abs)) parent.set(abs, null), queue.push(abs);
142
+ }
143
+
144
+ const chainOf = (file) => {
145
+ const files = [];
146
+ for (let f = file; f; f = parent.get(f)) files.unshift(f);
147
+ // Collapse to "your file that imports it → pkg → pkg …" for the report.
148
+ const out = [];
149
+ let lastSrc = files[0];
150
+ for (const f of files) {
151
+ const o = ownerOf(f);
152
+ if (!o) lastSrc = f;
153
+ else {
154
+ if (!out.length) out.push(path.relative(root, lastSrc).split(path.sep).join('/'));
155
+ if (out.at(-1) !== o.key) out.push(o.key);
156
+ }
157
+ }
158
+ return out;
159
+ };
160
+
161
+ let truncated = false;
162
+ for (let qi = 0; qi < queue.length; qi++) {
163
+ if (qi >= MAX_MODULES) {
164
+ truncated = true;
165
+ break;
166
+ }
167
+ const file = queue[qi];
168
+ const owner = ownerOf(file);
169
+ if (owner && !loaded.has(owner.key)) loaded.set(owner.key, { chain: chainOf(file) });
170
+ if (!/\.([cm]?[jt]sx?)$/.test(file)) continue;
171
+ let text;
172
+ try {
173
+ text = fs.readFileSync(file, 'utf8');
174
+ } catch {
175
+ continue;
176
+ }
177
+ const dir = path.dirname(file);
178
+ for (const spec of moduleSpecifiers(text)) {
179
+ if (BUILTINS.has(spec) || BUILTINS.has(spec.split('/')[0]) || /^[a-z]+:/i.test(spec)) continue;
180
+ let targets = [];
181
+ if (spec.startsWith('.') || spec.startsWith('/')) {
182
+ const t = resolveFile(path.resolve(dir, spec), owner ? EXTS : [...SRC_EXTS, '.json']);
183
+ if (t) targets = [t];
184
+ } else if (!/^[~#@]\//.test(spec)) {
185
+ targets = resolveBare(spec, dir, pkgCache)?.files ?? [];
186
+ }
187
+ for (const t of targets) {
188
+ if (parent.has(t)) continue;
189
+ parent.set(t, file);
190
+ queue.push(t);
191
+ }
192
+ }
193
+ }
194
+ return { available: true, loaded, installed: installedPackages(root), modules: Math.min(queue.length, MAX_MODULES), truncated };
195
+ }
196
+
197
+ /** Every "name@version" present on disk, nested copies included — so a stale node_modules isn't read as "never loaded". */
198
+ function installedPackages(root) {
199
+ const out = new Set();
200
+ const subdirs = (dir) => {
201
+ try {
202
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => path.join(dir, e.name));
203
+ } catch {
204
+ return [];
205
+ }
206
+ };
207
+ const visit = (nm) => {
208
+ for (const entry of subdirs(nm)) {
209
+ for (const dir of path.basename(entry).startsWith('@') ? subdirs(entry) : [entry]) {
210
+ const m = readJson(path.join(dir, 'package.json'));
211
+ if (m?.name && m.version) out.add(`${m.name}@${m.version}`);
212
+ visit(path.join(dir, 'node_modules'));
213
+ }
214
+ }
215
+ };
216
+ visit(path.join(root, 'node_modules'));
217
+ return out;
218
+ }
@@ -0,0 +1,76 @@
1
+ // Attack surface: does this app accept connections from the network, or is it a local process
2
+ // (CLI, stdio MCP server, IDE extension) that only talks to the user who started it?
3
+ //
4
+ // "Runs on the server" is not the same as "reachable by attackers over the internet". A request-parsing
5
+ // DoS in express only matters if something actually calls listen().
6
+
7
+ const LISTENERS = [
8
+ { what: 'HTTP server', re: /\b(?:https?|http2)\s*\.\s*create(?:Secure)?Server\s*\(|\bcreateServer\s*\(/ },
9
+ { what: '.listen() call', re: /\.\s*listen\s*\(\s*(?:[\w$.]*port\b|\d{2,5}\b|process\.env\.)/i },
10
+ { what: 'Express app', re: /\bexpress\s*\(\s*\)/ },
11
+ { what: 'Fastify app', re: /\bfastify\s*\(\s*[{)]/i },
12
+ { what: 'Koa app', re: /\bnew\s+Koa\s*\(/ },
13
+ { what: 'Hapi server', re: /\bHapi\s*\.\s*server\s*\(/ },
14
+ { what: 'Hono server', re: /\bserve\s*\(\s*\{[^}]*\bfetch\b/ },
15
+ { what: 'Bun/Deno server', re: /\b(?:Bun|Deno)\s*\.\s*serve\s*\(/ },
16
+ { what: 'WebSocket server', re: /\bnew\s+(?:WebSocket\.)?(?:WebSocketServer|Server)\s*\(\s*\{[^}]*\b(?:port|server)\b/ },
17
+ { what: 'MCP HTTP transport', re: /\bnew\s+(?:StreamableHTTPServerTransport|SSEServerTransport)\s*\(|\bcreateMcpExpressApp\s*\(/ },
18
+ { what: 'NestJS app', re: /\bNestFactory\s*\.\s*create\s*\(/ },
19
+ { what: 'Python web server', re: /\buvicorn\.run\s*\(|\bapp\.run\s*\(|\bweb\.run_app\s*\(|\bserve_forever\s*\(/ },
20
+ ];
21
+
22
+ const LOCAL = [
23
+ { what: 'MCP stdio transport', re: /\bnew\s+StdioServerTransport\s*\(/ },
24
+ { what: 'reads commands from stdin', re: /\bprocess\.stdin\b/ },
25
+ ];
26
+
27
+ // Frameworks that always serve HTTP even when no file calls listen() itself.
28
+ const NETWORK_FRAMEWORKS = new Set(['next', 'nuxt', 'remix', 'sveltekit', 'astro', 'node-server', 'laravel', 'symfony', 'django', 'fastapi', 'flask']);
29
+
30
+ /**
31
+ * @param {{framework: string, manifest: any}} project
32
+ * @param {{files: {rel:string, kind:string, context?:string}[], texts: Map<string,string>}} src
33
+ * @returns {{kind: 'network'|'local'|'unknown', evidence: string[]}}
34
+ * network = something accepts connections; local = only a CLI/stdio entry point and no listener found.
35
+ */
36
+ export function detectSurface(project, src) {
37
+ const listeners = [];
38
+ const local = [];
39
+ for (const f of src.files) {
40
+ if (f.kind !== 'code' || f.context === 'build') continue;
41
+ const text = src.texts.get(f.rel);
42
+ if (!text) continue;
43
+ for (const [list, rules] of [[listeners, LISTENERS], [local, LOCAL]]) {
44
+ for (const r of rules) {
45
+ const m = r.re.exec(text);
46
+ if (m) list.push(`${r.what} in ${f.rel}:${text.slice(0, m.index).split('\n').length}`);
47
+ }
48
+ }
49
+ }
50
+
51
+ if (listeners.length) return { kind: 'network', evidence: listeners.slice(0, 3) };
52
+ if (NETWORK_FRAMEWORKS.has(project.framework)) return { kind: 'network', evidence: [`${project.framework} serves HTTP`] };
53
+
54
+ const bin = project.manifest?.bin;
55
+ if (bin) local.push(`package.json "bin" (${typeof bin === 'string' ? bin : Object.keys(bin).join(', ')})`);
56
+ // A library with no entry point of its own is "unknown": whoever imports it decides whether it's exposed.
57
+ if (local.length) return { kind: 'local', evidence: local.slice(0, 3) };
58
+ return { kind: 'unknown', evidence: [] };
59
+ }
60
+
61
+ const SERVER_PACKAGES = ['express', 'fastify', 'koa', '@hapi/hapi', 'hono', '@hono/node-server', 'ws', 'socket.io', '@nestjs/core', 'restify', 'polka', 'h3'];
62
+
63
+ /**
64
+ * Safety net for listeners the patterns above miss: if a server framework is actually loaded at runtime,
65
+ * don't call the app local.
66
+ * @param {ReturnType<typeof detectSurface>} surface
67
+ * @param {{available: boolean, loaded: Map<string, {chain: string[]}>}|null} modules
68
+ */
69
+ export function confirmSurface(surface, modules) {
70
+ if (surface.kind !== 'local' || !modules?.available) return surface;
71
+ for (const [key, { chain }] of modules.loaded) {
72
+ const name = key.slice(0, key.lastIndexOf('@'));
73
+ if (SERVER_PACKAGES.includes(name)) return { kind: 'network', evidence: [`${name} is loaded at runtime (${chain.join(' → ')})`] };
74
+ }
75
+ return surface;
76
+ }
@@ -0,0 +1,31 @@
1
+ // Feature-level reachability for packages that are loaded but only dangerous when you use one feature.
2
+ // axios always loads form-data on Node, yet form-data's bugs only matter when you send multipart bodies.
3
+ //
4
+ // Each entry: the vulnerable package, a description of the feature, and a pattern that shows YOUR code
5
+ // uses it. No match → the package is loaded but the vulnerable path is never exercised (weak-to-moderate
6
+ // evidence: the pattern can miss indirect uses, so the score is reduced, not zeroed).
7
+
8
+ export const TRIGGERS = {
9
+ 'form-data': {
10
+ feature: 'multipart/form-data request bodies',
11
+ re: /\bnew\s+FormData\s*\(|multipart\/form-data|\.\s*(?:postForm|putForm|patchForm|toFormData)\s*\(|\bformSerializer\b/,
12
+ },
13
+ 'proxy-addr': { feature: 'trust-proxy client IP resolution', re: /['"]trust proxy['"]|\btrustProxy\s*:/ },
14
+ 'tough-cookie': { feature: 'cookie jars', re: /\bCookieJar\b|\bjar\s*:/ },
15
+ };
16
+
17
+ /**
18
+ * Does any runtime source file use the feature that makes `pkg`'s advisories exploitable?
19
+ * @returns {null | {used: boolean, feature: string, where?: string}} null when no trigger is known.
20
+ */
21
+ export function featureUse(pkg, src) {
22
+ const t = TRIGGERS[pkg];
23
+ if (!t || !src) return null;
24
+ for (const f of src.files) {
25
+ if (f.kind !== 'code' || f.context === 'build') continue;
26
+ const text = src.texts.get(f.rel);
27
+ const m = text && t.re.exec(text);
28
+ if (m) return { used: true, feature: t.feature, where: `${f.rel}:${text.slice(0, m.index).split('\n').length}` };
29
+ }
30
+ return { used: false, feature: t.feature };
31
+ }
@@ -41,7 +41,7 @@ export function toSarif(report) {
41
41
  message: { text: [text, ...(f.reasons ?? []).map((r) => `• ${r}`)].join('\n') },
42
42
  locations: [{ physicalLocation: { artifactLocation: { uri: where.uri }, region: { startLine: where.line } } }],
43
43
  partialFingerprints: { iamsecure: f.id },
44
- properties: { riskScore: f.riskScore, severityScore: f.severityScore, exposure: f.exposure?.context, aiVerdict: f.ai?.verdict },
44
+ properties: { riskScore: f.riskScore, severityScore: f.severityScore, exposure: f.exposure?.context, reachability: f.reachability?.status, aiVerdict: f.ai?.verdict },
45
45
  });
46
46
  }
47
47
 
@@ -29,8 +29,11 @@ const TONE = {
29
29
  muted: c.gray,
30
30
  };
31
31
  const dot = (tone) => TONE[tone]('●');
32
- const EXPOSURE_TONE = { server: 'critical', universal: 'critical', client: 'medium', build: 'low', unused: 'muted', unknown: 'muted' };
33
- const EXPOSURE_WORD = { server: 'server', universal: 'server + browser', client: 'browser only', build: 'build/test only', unused: 'not imported', unknown: 'unknown' };
32
+ const EXPOSURE_TONE = { server: 'critical', universal: 'critical', local: 'medium', client: 'medium', build: 'low', unused: 'muted', unknown: 'muted' };
33
+ const EXPOSURE_WORD = { server: 'server', universal: 'server + browser', local: 'local process', client: 'browser only', build: 'build/test only', unused: 'not imported', unknown: 'unknown' };
34
+ const REACH_TONE = { loaded: 'critical', 'feature-unused': 'medium', 'not-loaded': 'low', unknown: 'muted' };
35
+ const REACH_WORD = { loaded: 'loaded', 'feature-unused': 'loaded, feature unused', 'not-loaded': 'never loaded', unknown: 'unknown' };
36
+ const SURFACE_WORD = { network: 'accepts network connections', local: 'local process, no network listener', unknown: 'unknown (no entry point found)' };
34
37
  const KIND_WORD = { dependency: 'Dependency', code: 'Code', secret: 'Secret' };
35
38
 
36
39
  // ── layout helpers (ANSI-aware) ──────────────────────────────────────────────
@@ -136,8 +139,14 @@ function latestPanel(report, width) {
136
139
  (report.stats.files != null ? ` and ${report.stats.files} source files` : '') +
137
140
  `. Found ${s.total} issue${s.total === 1 ? '' : 's'}: ${kinds.dependency.length} in dependencies, ${kinds.code.length} in your code, ${kinds.secret.length} secret${kinds.secret.length === 1 ? '' : 's'}.` +
138
141
  (s.total ? ` After weighing where each one runs, ${s.byRisk.critical} ${s.byRisk.critical === 1 ? 'is' : 'are'} critical here (raw severity says ${s.bySeverity.critical}).` : '');
142
+ const surface = report.reachability?.surface;
143
+ const notLoaded = kinds.dependency.filter((f) => f.reachability?.status === 'not-loaded').length;
144
+ const reach = [];
145
+ if (surface && report.stats.files != null) reach.push(`Attack surface: ${SURFACE_WORD[surface.kind]}${surface.evidence[0] ? ` (${surface.evidence[0]})` : ''}.`);
146
+ if (report.reachability?.modules) reach.push(`${notLoaded} of ${kinds.dependency.length} dependency issues are in packages your code never loads.`);
139
147
  const right = [
140
148
  ...wrap(prose, rightW),
149
+ ...(reach.length ? wrap(reach.join(' '), rightW).map(c.gray) : []),
141
150
  '',
142
151
  groupLine('Dependencies', kinds.dependency),
143
152
  groupLine('Your code', kinds.code),
@@ -165,6 +174,7 @@ function findingEntry(f, sort, width) {
165
174
  ` ${c.gray('Risk')} ${dot(f.risk)} ${c.bold(f.riskScore.toFixed(1))} ${c.gray('Sev')} ${dot(f.severity)} ${f.severityScore.toFixed(1)}`,
166
175
  ` ${c.gray('Runs')} ${dot(EXPOSURE_TONE[ctx])} ${EXPOSURE_WORD[ctx]}`,
167
176
  ];
177
+ if (f.reachability) left.push(` ${c.gray('Reach')} ${dot(REACH_TONE[f.reachability.status])} ${REACH_WORD[f.reachability.status]}`);
168
178
  const tags = [];
169
179
  if (f.kind === 'dependency') {
170
180
  tags.push(f.package.direct ? 'direct' : 'transitive');
package/src/risk/score.js CHANGED
@@ -2,26 +2,35 @@
2
2
  //
3
3
  // risk = severity × exposure(where it runs, what kind of bug) × attack-vector × exploit-intel × reachability
4
4
  //
5
+ // Reachability has three levels, strongest evidence first:
6
+ // 1. module — is the vulnerable package ever loaded from your code? (real import graph through node_modules)
7
+ // 2. feature — is it loaded but only dangerous when you use one feature you don't use? (reach/triggers.js)
8
+ // 3. symbol — does your code call the function the advisory names? (direct dependencies)
9
+ //
5
10
  // Every multiplier that fires adds a human-readable reason, so the ranking is explainable.
6
11
 
7
12
  import { levelFromScore } from '../deps/cvss.js';
8
13
  import { CONTEXT_LABEL, mergeContexts, frameworkRuntime } from '../code/context.js';
14
+ import { featureUse } from '../reach/triggers.js';
9
15
 
10
- const DEFAULT_WEIGHTS = { server: 1, universal: 1, client: 0.6, build: 0.3, unused: 0.5, unknown: 0.85 };
16
+ // `local` = server-side code with no network listener (CLI, stdio MCP server): only the user who runs it can feed it input.
17
+ const DEFAULT_WEIGHTS = { server: 1, universal: 1, local: 0.6, client: 0.6, build: 0.3, unused: 0.5, unknown: 0.85 };
18
+ const NOT_LOADED = 0.3;
19
+ const FEATURE_UNUSED = 0.5;
11
20
 
12
21
  /** Vulnerability classes. `client` = how much the bug still matters when the code only runs in a browser. */
13
22
  export const VULN_CLASSES = [
14
- { id: 'malicious', name: 'malicious code', cwes: [], re: /malicious (code|package)/i, weights: { client: 1, build: 1, unused: 1 } },
15
- { id: 'secret', name: 'leaked secret', clientNote: 'anything in client code ships to every visitor', cwes: ['CWE-798'], re: null, weights: { client: 1, server: 0.9, build: 0.8, unknown: 0.9 } },
16
- { id: 'rce', name: 'code execution', clientNote: 'code runs in the visitor\'s browser sandbox, not on your server', cwes: ['CWE-94', 'CWE-95', 'CWE-77', 'CWE-78', 'CWE-502', 'CWE-1336'], re: /remote code execution|command injection|code injection|\brce\b|arbitrary code|template injection/i, weights: { client: 0.5 } },
17
- { id: 'sqli', name: 'SQL injection', clientNote: 'the browser has no direct database access', cwes: ['CWE-89', 'CWE-943'], re: /sql injection|nosql injection/i, weights: { client: 0.3 } },
18
- { id: 'auth', name: 'auth bypass', clientNote: 'your server must enforce auth anyway', cwes: ['CWE-285', 'CWE-287', 'CWE-288', 'CWE-290', 'CWE-306', 'CWE-347', 'CWE-863', 'CWE-639', 'CWE-1390'], re: /authori[sz]ation bypass|authentication bypass|auth bypass|signature (verification|bypass)|forge/i, weights: { client: 0.4 } },
19
- { id: 'ssrf', name: 'SSRF', clientNote: 'browser requests can\'t reach your internal network', cwes: ['CWE-918'], re: /\bssrf\b|server-side request forgery/i, weights: { client: 0.25 } },
20
- { id: 'path', name: 'path traversal', clientNote: 'the browser has no access to your server\'s filesystem', cwes: ['CWE-22', 'CWE-23', 'CWE-24', 'CWE-73'], re: /path traversal|directory traversal|arbitrary file (read|write)/i, weights: { client: 0.25 } },
21
- { id: 'xss', name: 'XSS', clientNote: 'XSS targets exactly this environment', cwes: ['CWE-79', 'CWE-80', 'CWE-116'], re: /cross[- ]site scripting|\bxss\b|html injection/i, weights: { client: 0.95 } },
23
+ { id: 'malicious', name: 'malicious code', cwes: [], re: /malicious (code|package)/i, weights: { client: 1, build: 1, unused: 1, local: 1 } },
24
+ { id: 'secret', name: 'leaked secret', clientNote: 'anything in client code ships to every visitor', cwes: ['CWE-798'], re: null, weights: { client: 1, server: 0.9, local: 0.9, build: 0.8, unknown: 0.9 } },
25
+ { id: 'rce', name: 'code execution', clientNote: 'code runs in the visitor\'s browser sandbox, not on your server', cwes: ['CWE-94', 'CWE-95', 'CWE-77', 'CWE-78', 'CWE-502', 'CWE-1336'], re: /remote code execution|command injection|code injection|\brce\b|arbitrary code|template injection/i, weights: { client: 0.5, local: 0.7 } },
26
+ { id: 'sqli', name: 'SQL injection', clientNote: 'the browser has no direct database access', cwes: ['CWE-89', 'CWE-943'], re: /sql injection|nosql injection/i, weights: { client: 0.3, local: 0.5 } },
27
+ { id: 'auth', name: 'auth bypass', clientNote: 'your server must enforce auth anyway', cwes: ['CWE-285', 'CWE-287', 'CWE-288', 'CWE-290', 'CWE-306', 'CWE-347', 'CWE-863', 'CWE-639', 'CWE-1390'], re: /authori[sz]ation bypass|authentication bypass|auth bypass|signature (verification|bypass)|forge/i, weights: { client: 0.4, local: 0.4 } },
28
+ { id: 'ssrf', name: 'SSRF', clientNote: 'browser requests can\'t reach your internal network', cwes: ['CWE-918'], re: /\bssrf\b|server-side request forgery/i, weights: { client: 0.25, local: 0.5 } },
29
+ { id: 'path', name: 'path traversal', clientNote: 'the browser has no access to your server\'s filesystem', cwes: ['CWE-22', 'CWE-23', 'CWE-24', 'CWE-73'], re: /path traversal|directory traversal|arbitrary file (read|write)/i, weights: { client: 0.25, local: 0.6 } },
30
+ { id: 'xss', name: 'XSS', clientNote: 'XSS targets exactly this environment', cwes: ['CWE-79', 'CWE-80', 'CWE-116'], re: /cross[- ]site scripting|\bxss\b|html injection/i, weights: { client: 0.95, local: 0.4 } },
22
31
  { id: 'proto', name: 'prototype pollution', clientNote: 'can still be chained into client-side XSS', cwes: ['CWE-1321', 'CWE-915'], re: /prototype pollution/i, weights: { client: 0.65 } },
23
- { id: 'redirect', name: 'open redirect', clientNote: 'still usable for phishing', cwes: ['CWE-601'], re: /open redirect/i, weights: { client: 0.7 } },
24
- { id: 'dos', name: 'denial of service', clientNote: 'an attacker can only freeze their own tab', cwes: ['CWE-400', 'CWE-1333', 'CWE-770', 'CWE-674', 'CWE-835', 'CWE-407', 'CWE-776', 'CWE-1050', 'CWE-834'], re: /denial[- ]of[- ]service|\bdos\b|redos|regular expression|backtracking|resource exhaustion|uncontrolled resource|infinite loop|memory exhaustion/i, weights: { client: 0.2 } },
32
+ { id: 'redirect', name: 'open redirect', clientNote: 'still usable for phishing', cwes: ['CWE-601'], re: /open redirect/i, weights: { client: 0.7, local: 0.3 } },
33
+ { id: 'dos', name: 'denial of service', clientNote: 'an attacker can only freeze their own tab', cwes: ['CWE-400', 'CWE-1333', 'CWE-770', 'CWE-674', 'CWE-835', 'CWE-407', 'CWE-776', 'CWE-1050', 'CWE-834'], re: /denial[- ]of[- ]service|\bdos\b|redos|regular expression|backtracking|resource exhaustion|uncontrolled resource|infinite loop|memory exhaustion/i, weights: { client: 0.2, local: 0.35 } },
25
34
  { id: 'leak', name: 'information leak', clientNote: 'only data already sent to the browser is at risk', cwes: ['CWE-200', 'CWE-201', 'CWE-209', 'CWE-359', 'CWE-522', 'CWE-532', 'CWE-312'], re: /credential leak|information (disclosure|exposure)|exposure of sensitive|leaks?\b/i, weights: { client: 0.5 } },
26
35
  { id: 'crypto', name: 'weak crypto', clientNote: 'client-side crypto is rarely security-critical', cwes: ['CWE-327', 'CWE-328', 'CWE-330', 'CWE-338', 'CWE-326'], re: /weak (crypto|hash|random)|insecure random|predictable/i, weights: { client: 0.5 } },
27
36
  ];
@@ -108,9 +117,10 @@ const round1 = (x) => Math.round(x * 10) / 10;
108
117
  /**
109
118
  * Compute riskScore/risk/exposure/reasons on every finding (mutates).
110
119
  * @param {any[]} findings
111
- * @param {{src: ReturnType<import('../code/index.js').analyzeSource>|null, framework: string, exploit?: {kev:Map<string,any>, epss:Map<string,any>}}} ctx
120
+ * @param {{src: ReturnType<import('../code/index.js').analyzeSource>|null, framework: string, exploit?: {kev:Map<string,any>, epss:Map<string,any>},
121
+ * surface?: ReturnType<import('../reach/surface.js').detectSurface>, modules?: ReturnType<import('../reach/modules.js').walkModules>}} ctx
112
122
  */
113
- export function scoreFindings(findings, { src, framework, exploit }) {
123
+ export function scoreFindings(findings, { src, framework, exploit, surface, modules }) {
114
124
  const runtime = frameworkRuntime(framework);
115
125
  for (const f of findings) {
116
126
  const reasons = [];
@@ -129,12 +139,17 @@ export function scoreFindings(findings, { src, framework, exploit }) {
129
139
  f.kind === 'dependency'
130
140
  ? dependencyExposure(f, src, runtime)
131
141
  : { context: f.context ?? src?.contexts.get(f.location?.file) ?? 'unknown', evidence: [] };
142
+ // Server-side code in an app that never opens a port: only the local user can feed it input.
143
+ if (surface?.kind === 'local' && (exp.context === 'server' || exp.context === 'universal')) exp.context = 'local';
132
144
  const weight = { ...DEFAULT_WEIGHTS, ...cls.weights }[exp.context] ?? 1;
133
145
  f.exposure = { context: exp.context, label: CONTEXT_LABEL[exp.context], evidence: exp.evidence };
134
146
  s *= weight;
135
- const why = weight < 1 || exp.context === 'client' ? exposureReason(exp.context, cls, f) : null;
147
+ const why = weight < 1 || exp.context === 'client' ? exposureReason(exp.context, cls, f, surface) : null;
136
148
  if (why) reasons.push(why);
137
- else if (f.kind === 'dependency' && exp.evidence.length) reasons.push(`Runs on the server — ${exp.evidence[0]}`);
149
+ else if (f.kind === 'dependency' && exp.evidence.length) {
150
+ const net = surface?.kind === 'network' ? ` — and the app accepts network connections (${surface.evidence[0]})` : '';
151
+ reasons.push(`Runs on the server — ${exp.evidence[0]}${net}`);
152
+ }
138
153
 
139
154
  // 2. Attack vector (CVSS)
140
155
  const m = adv?.cvss?.metrics;
@@ -168,8 +183,33 @@ export function scoreFindings(findings, { src, framework, exploit }) {
168
183
  }
169
184
  }
170
185
 
171
- // 4. Reachability: does our code call the function the advisory names?
172
- if (f.kind === 'dependency' && src && f.package.direct && !adv.malicious) {
186
+ // 4. Module + feature reachability: is the package ever loaded, and is the vulnerable feature used?
187
+ if (f.kind === 'dependency' && modules?.available && f.package.ecosystem === 'npm' && !adv.malicious && !['build', 'unused'].includes(exp.context)) {
188
+ const key = `${f.package.name}@${f.package.version}`;
189
+ const hit = modules.loaded.get(key);
190
+ if (hit) {
191
+ const use = featureUse(f.package.name, src);
192
+ const chain = hit.chain.join(' → ');
193
+ if (use && !use.used) {
194
+ s *= FEATURE_UNUSED;
195
+ f.reachability = { status: 'feature-unused', chain: hit.chain, feature: use.feature };
196
+ reasons.push(`Loaded at runtime (${chain}), but your code never uses ${use.feature} — the code path this bug is in`);
197
+ } else {
198
+ f.reachability = { status: 'loaded', chain: hit.chain, ...(use && { feature: use.feature, where: use.where }) };
199
+ reasons.push(`Loaded at runtime: ${chain}${use ? ` — and your code uses ${use.feature} (${use.where})` : ''}`);
200
+ }
201
+ } else if (!modules.installed.has(key)) {
202
+ f.reachability = { status: 'unknown' };
203
+ reasons.push(`Can't check whether it's loaded: ${key} isn't in node_modules (out of sync with ${f.package.lockfile}? run a clean install)`);
204
+ } else if (!modules.truncated) {
205
+ s *= NOT_LOADED;
206
+ f.reachability = { status: 'not-loaded' };
207
+ reasons.push(notLoadedReason(f, src, modules));
208
+ }
209
+ }
210
+
211
+ // 5. Symbol reachability: does our code call the function the advisory names?
212
+ if (f.kind === 'dependency' && src && f.package.direct && !adv.malicious && f.reachability?.status !== 'not-loaded') {
173
213
  const uses = src.usage.get(f.package.name) ?? [];
174
214
  const symbols = vulnerableSymbols(adv, f.package.name);
175
215
  if (uses.length && symbols.length) {
@@ -199,8 +239,22 @@ export function scoreFindings(findings, { src, framework, exploit }) {
199
239
  return findings;
200
240
  }
201
241
 
202
- function exposureReason(context, cls, f) {
242
+ /** "Never loaded" with the most specific explanation we can give: which parts of the parent you do import. */
243
+ function notLoadedReason(f, src, modules) {
244
+ const roots = f.package.introducedBy.filter((r) => r !== f.package.name);
245
+ const specs = [...new Set(roots.flatMap((r) => (src?.usage.get(r) ?? []).filter((u) => u.context !== 'build').map((u) => u.spec)))].filter((s) => !roots.includes(s));
246
+ const followed = `followed ${modules.modules} modules from your code`;
247
+ if (!roots.length) return `Never loaded at runtime — none of your code's imports lead to it (${followed})`;
248
+ if (specs.length) {
249
+ return `Never loaded at runtime — only parts of ${roots.join(', ')} you don't import use it; you import ${specs.slice(0, 3).join(', ')}${specs.length > 3 ? ', …' : ''} (${followed})`;
250
+ }
251
+ return `Never loaded at runtime — ${roots.join(', ')} installs it, but not on any code path your app uses (${followed})`;
252
+ }
253
+
254
+ function exposureReason(context, cls, f, surface) {
203
255
  switch (context) {
256
+ case 'local':
257
+ return `No network listener found — runs as a local process (${surface.evidence[0]}), so only the person running it can send it input`;
204
258
  case 'client':
205
259
  return `Only runs in the browser — ${cls.clientNote ?? `${cls.name} impact is reduced there`}`;
206
260
  case 'build':
package/src/scan.js CHANGED
@@ -6,8 +6,10 @@ import { scanCode } from "./code/engine.js";
6
6
  import { scanSecrets } from "./code/secrets.js";
7
7
  import { loadExploitData } from "./risk/exploit.js";
8
8
  import { scoreFindings } from "./risk/score.js";
9
+ import { detectSurface, confirmSurface } from "./reach/surface.js";
10
+ import { walkModules } from "./reach/modules.js";
9
11
 
10
- export const VERSION = "0.1.3";
12
+ export const VERSION = "0.1.4";
11
13
 
12
14
  /**
13
15
  * Run a full scan and return a JSON-serialisable report.
@@ -36,11 +38,26 @@ export async function scan(root, opts = {}) {
36
38
  }
37
39
 
38
40
  let src = null;
41
+ let surface = null;
42
+ let modules = null;
39
43
  if (code) {
40
44
  log("Analysing source code…");
41
45
  src = analyzeSource(project);
42
46
  stats.files = src.contexts.size;
43
47
  findings.push(...scanCode(src), ...scanSecrets(src, project.root));
48
+ surface = detectSurface(project, src);
49
+
50
+ if (graphs.some((g) => g.ecosystem === "npm" && g.packages().length)) {
51
+ log("Tracing which packages your code actually loads…");
52
+ const entries = src.files.filter((f) => f.kind === "code" && f.context && f.context !== "build");
53
+ modules = walkModules(project.root, entries);
54
+ if (!modules.available) {
55
+ warnings.push("node_modules not found — install dependencies (e.g. `npm ci`) so iamsecure can tell which vulnerable packages your code actually loads.");
56
+ } else if (modules.truncated) {
57
+ warnings.push(`Stopped tracing after ${modules.modules} modules; packages not reached by then are not marked as unused.`);
58
+ }
59
+ surface = confirmSurface(surface, modules);
60
+ }
44
61
  }
45
62
 
46
63
  let exploitData;
@@ -51,13 +68,17 @@ export async function scan(root, opts = {}) {
51
68
  warnings.push(...exploitData.warnings);
52
69
  }
53
70
 
54
- scoreFindings(findings, { src, framework: project.framework, exploit: exploitData });
71
+ scoreFindings(findings, { src, framework: project.framework, exploit: exploitData, surface, modules });
55
72
 
56
73
  return {
57
74
  tool: { name: "iamsecure", version: VERSION },
58
75
  scannedAt: new Date().toISOString(),
59
76
  durationMs: Date.now() - started,
60
77
  project: { name: project.name, root: project.root, framework: project.framework },
78
+ reachability: {
79
+ surface: surface ?? { kind: "unknown", evidence: [] },
80
+ modules: modules?.available ? { traced: modules.modules, packagesLoaded: modules.loaded.size, truncated: modules.truncated } : null,
81
+ },
61
82
  sourceFiles: src ? [...src.contexts].slice(0, 400).map(([file, context]) => ({ file, context })) : [],
62
83
  stats,
63
84
  warnings,
package/src/ui/index.html CHANGED
@@ -156,6 +156,13 @@ label.chk { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5
156
156
  .showmore { text-align: center; margin: 28px 0 0; }
157
157
 
158
158
  /* ── fix list: one plain-language card per action ─── */
159
+ .safe { margin: 22px 0 8px; border: 1px solid var(--frame); border-radius: 8px; background: var(--panel); }
160
+ .safe > summary { cursor: pointer; padding: 12px 14px; font-weight: 600; }
161
+ .safe > summary .hint { font-weight: 400; margin-left: 6px; }
162
+ .saferow { display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr); gap: 4px 14px; padding: 10px 14px; border-top: 1px solid var(--frame); font-size: 13px; }
163
+ .saferow .reason { color: var(--text); }
164
+ .saferow .hint { grid-column: 2; margin: 0; }
165
+ @media (max-width: 640px) { .saferow { grid-template-columns: minmax(0, 1fr); } .saferow .hint { grid-column: 1; } }
159
166
  .listctl { display: flex; justify-content: flex-end; gap: 16px; margin: 4px 0 6px; }
160
167
  .listctl button { background: none; border: 0; padding: 0; cursor: pointer; font-size: 13px; color: var(--muted); text-decoration: underline; text-underline-offset: 2px; }
161
168
  .card { margin: 8px 0; border-radius: 8px; }
@@ -301,12 +308,13 @@ label.chk { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5
301
308
  <button data-v="" class="on">All</button><button data-v="dependency">Dependencies</button><button data-v="code">Code</button><button data-v="secret">Secrets</button>
302
309
  </div>
303
310
  <select id="exposure" aria-label="Where it runs">
304
- <option value="">Runs anywhere</option><option value="server">Server</option><option value="universal">Server + browser</option>
311
+ <option value="">Runs anywhere</option><option value="server">Server</option><option value="local">Local process</option><option value="universal">Server + browser</option>
305
312
  <option value="client">Browser only</option><option value="build">Build/test only</option><option value="unused">Not imported</option>
306
313
  </select>
307
314
  <label class="chk"><input type="checkbox" id="hideDev"> Hide build/dev-only</label>
308
315
  <input type="search" id="q" placeholder="Search package, file, CVE, rule…">
309
316
  </div>
317
+ <p class="hint" id="sorthint"></p>
310
318
  <p class="hint" id="hint"></p>
311
319
  <div id="list"></div>
312
320
  </section>
@@ -338,13 +346,26 @@ const IMPACT = {
338
346
  };
339
347
  const ACTION = { critical: 'Fix now', high: 'Fix soon', medium: 'Plan a fix', low: 'Low priority', none: 'Info' };
340
348
  const WHERE = {
341
- server: 'On your server, reachable by attackers over the internet',
349
+ server: 'On your server',
350
+ local: 'In a local process (CLI / stdio) with no network listener; only the person running it can send it input',
342
351
  universal: "On your server and in visitors' browsers",
343
352
  client: "Only in visitors' browsers",
344
353
  build: 'Only while building or testing; not shipped to users',
345
354
  unused: 'Installed, but your code never imports it',
346
355
  unknown: 'Could not tell where this runs',
347
356
  };
357
+ function whereText(ctx) {
358
+ const s = state.report?.reachability?.surface;
359
+ if ((ctx === 'server' || ctx === 'universal') && s?.kind === 'network') return `${WHERE[ctx]}, and the app accepts connections from the network (${s.evidence[0]})`;
360
+ return WHERE[ctx];
361
+ }
362
+ const REACH = {
363
+ loaded: (r) => `Yes: ${r.chain.join(' → ')}${r.where ? `, and your code uses ${r.feature} (${r.where})` : ''}`,
364
+ 'feature-unused': (r) => `It is loaded (${r.chain.join(' → ')}), but your code never uses ${r.feature}, which is where this bug lives`,
365
+ 'not-loaded': () => 'No. It is installed, but nothing your code imports ever loads it',
366
+ unknown: () => 'Unknown: node_modules does not match the lockfile, so loading could not be traced',
367
+ };
368
+ const reachRow = (f) => (f.reachability ? `<div class="k">Does your code load it?</div><div>${esc(REACH[f.reachability.status](f.reachability))}</div>` : '');
348
369
  const cap = (s) => s[0].toUpperCase() + s.slice(1);
349
370
  const lvl = (f) => (state.sort === 'severity' ? f.severity : f.risk);
350
371
  function cmpVer(a, b) {
@@ -358,7 +379,7 @@ function cmpVer(a, b) {
358
379
  }
359
380
  return 0;
360
381
  }
361
- const EXPO = { server: ['critical', 'Server'], universal: ['critical', 'Server + browser'], client: ['medium', 'Browser only'], build: ['low', 'Build/test'], unused: ['muted', 'Not imported'], unknown: ['muted', 'Unknown'] };
382
+ const EXPO = { server: ['critical', 'Server'], local: ['medium', 'Local process'], universal: ['critical', 'Server + browser'], client: ['medium', 'Browser only'], build: ['low', 'Build/test'], unused: ['muted', 'Not imported'], unknown: ['muted', 'Unknown'] };
362
383
  const KIND = { dependency: 'Dependency', code: 'Code', secret: 'Secret' };
363
384
  const ICON = {
364
385
  dependency: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3 4 7v10l8 4 8-4V7l-8-4z"/><path d="m4 7 8 4 8-4M12 11v10"/></svg>',
@@ -619,7 +640,8 @@ function pkgCard(g) {
619
640
  `${esc(p.name)}@${esc(p.version)}${root ? ` · via ${esc(root)}` : ''}${!p.prod ? ' · dev dependency' : ''}`,
620
641
  `<div class="k">What's wrong</div><div>${n === 1 ? 'This version has a known security issue' : `This version has <b>${n} known security issues</b>`}. The worst: “${esc(top.title)}”.</div>
621
642
  <div class="k">What could happen</div><div>${esc(IMPACT[top.vulnClass] ?? IMPACT.other)}</div>
622
- <div class="k">Where it runs</div><div>${esc(WHERE[ctx])}</div>
643
+ <div class="k">Where it runs</div><div>${esc(whereText(ctx))}</div>
644
+ ${reachRow(top)}
623
645
  <div class="k">How to fix</div><div>${how}</div>`,
624
646
  `Show ${n === 1 ? 'details' : `all ${n} issues and details`}`);
625
647
  }
@@ -682,7 +704,7 @@ function singleCard(g) {
682
704
  `${esc(f.location.file)} · line ${f.location.line}`,
683
705
  `<div class="k">What's wrong</div><div>${wrong}</div>
684
706
  <div class="k">What could happen</div><div>${esc(IMPACT[f.vulnClass] ?? IMPACT.other)}</div>
685
- <div class="k">Where it runs</div><div>${esc(WHERE[ctx])}</div>
707
+ <div class="k">Where it runs</div><div>${esc(whereText(ctx))}</div>
686
708
  ${f.location.snippet ? `<div class="k">The code</div><div><pre>${esc(f.location.snippet)}</pre></div>` : ''}
687
709
  <div class="k">How to fix</div><div>${esc(f.fix)}</div>`,
688
710
  'Show details');
@@ -708,14 +730,44 @@ function toggleCard(card, open) {
708
730
  btn.textContent = show ? 'Hide details' : btn.dataset.label;
709
731
  }
710
732
 
733
+ // The single most convincing reason a group is low risk here, strongest evidence first.
734
+ const SAFE_WHY = [/^Never loaded/, /never uses/, /^No network listener/, /^Dev\/build|^In test\/build/, /never imported/, /^Only runs in the browser/, /^AI \(/];
735
+ function safeWhy(g) {
736
+ const reasons = g.findings[0].reasons ?? [];
737
+ for (const re of SAFE_WHY) {
738
+ const hit = reasons.find((r) => re.test(r));
739
+ if (hit) return hit;
740
+ }
741
+ return 'Low risk once adjusted for how this codebase uses it.';
742
+ }
743
+
744
+ function safeRow(g) {
745
+ const f = g.findings[0];
746
+ const target = g.type === 'pkg' ? g.findings.map((x) => x.package.fixedIn).filter(Boolean).sort(cmpVer).at(-1) : null;
747
+ const subject = g.type === 'pkg' ? `${f.package.name}@${f.package.version}` : `${f.location.file}:${f.location.line}`;
748
+ const n = g.findings.length;
749
+ return `<div class="saferow">
750
+ <div><span class="pill low">${n === 1 ? '1 issue' : `${n} issues`}</span> <code>${esc(subject)}</code></div>
751
+ <div class="reason">${esc(safeWhy(g))}</div>
752
+ ${target ? `<p class="hint">Still worth updating to ${esc(target)} when convenient, so scanners stop flagging it.</p>` : ''}
753
+ </div>`;
754
+ }
755
+
711
756
  function renderFix(list) {
712
- const groups = buildGroups(list);
713
- state.groupMap = new Map(groups.map((g) => [g.key, g]));
757
+ const all = buildGroups(list);
758
+ // Split by REAL risk (even when sorted by raw severity): anything medium or worse needs action.
759
+ const isSafe = (g) => g.findings.every((f) => f.risk === 'low' || f.risk === 'none');
760
+ const groups = all.filter((g) => !isSafe(g));
761
+ const safe = all.filter(isSafe);
762
+ state.groupMap = new Map(all.map((g) => [g.key, g]));
714
763
  const deps = groups.filter((g) => g.type === 'pkg').length;
715
- $('#hint').textContent = `${groups.length} things to fix, most urgent first.${deps ? ` Issues in the same package are grouped (${deps} package${deps === 1 ? '' : 's'}), because one update usually fixes all of them.` : ''}`;
716
- $('#list').innerHTML = '<div class="listctl"><button type="button" data-all="1">Expand all</button><button type="button" data-all="">Collapse all</button></div>' +
764
+ $('#hint').textContent = (groups.length ? `${groups.length} thing${groups.length === 1 ? '' : 's'} to fix, most urgent first.` : 'Nothing here needs fixing in this codebase.') +
765
+ (deps ? ` Issues in the same package are grouped, because one update usually fixes all of them.` : '') +
766
+ (safe.length ? ` ${safe.length} more ${safe.length === 1 ? 'is' : 'are'} low risk here; see the list at the bottom for why.` : '');
767
+ $('#list').innerHTML = (groups.length ? '<div class="listctl"><button type="button" data-all="1">Expand all</button><button type="button" data-all="">Collapse all</button></div>' : '') +
717
768
  groups.slice(0, state.shown).map((g) => (g.type === 'pkg' ? pkgCard(g) : singleCard(g))).join('') +
718
- (groups.length > state.shown ? `<div class="showmore"><button class="btn" id="more">Show ${Math.min(PAGE, groups.length - state.shown)} more of ${groups.length - state.shown}</button></div>` : '');
769
+ (groups.length > state.shown ? `<div class="showmore"><button class="btn" id="more">Show ${Math.min(PAGE, groups.length - state.shown)} more of ${groups.length - state.shown}</button></div>` : '') +
770
+ (safe.length ? `<details class="safe"><summary>Low risk in this codebase (${safe.length})<span class="hint">why each one isn't a real threat here</span></summary>${safe.map(safeRow).join('')}</details>` : '');
719
771
  $('#more')?.addEventListener('click', () => { state.shown += PAGE; render(); });
720
772
  document.querySelectorAll('.card').forEach((c) => {
721
773
  c.querySelector('.fbar').onclick = () => openCard(c);
@@ -730,9 +782,10 @@ function renderFix(list) {
730
782
  function render() {
731
783
  const r = state.report;
732
784
  header(r);
733
- $('#hint').textContent = state.sort === 'risk'
734
- ? 'Worst first: severity adjusted for where the code runs, whether your code reaches it, and whether it is exploited in the wild.'
735
- : 'High → low: the raw CVSS score of each advisory or the rule severity, ignoring how your app uses it.';
785
+ $('#sorthint').textContent = state.sort === 'risk'
786
+ ? 'Worst first ranks by real risk in this codebase: where the code runs, whether your code loads it, and whether it is exploited in the wild. High → low shows the raw CVSS severity instead (what npm audit shows).'
787
+ : 'High → low ranks by raw CVSS severity, ignoring how your app uses it (what npm audit shows). Worst first ranks by real risk in this codebase instead.';
788
+ $('#hint').textContent = '';
736
789
  const list = filtered();
737
790
  if (!list.length) {
738
791
  $('#list').innerHTML = `<div class="empty">${r.findings.length ? 'No findings match these filters.' : '✓ No known vulnerabilities or insecure code patterns found.'}</div>`;