iamsecure 0.1.2 → 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 +11 -3
- package/bin/iamsecure.js +6 -1
- package/package.json +1 -1
- package/src/ai/index.js +3 -1
- package/src/code/context.js +2 -1
- package/src/code/index.js +2 -2
- package/src/reach/modules.js +218 -0
- package/src/reach/surface.js +76 -0
- package/src/reach/triggers.js +31 -0
- package/src/report/sarif.js +1 -1
- package/src/report/terminal.js +12 -2
- package/src/risk/score.js +72 -18
- package/src/scan.js +23 -2
- package/src/ui/index.html +67 -13
- package/src/ui/server.js +91 -8
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.
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
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/bin/iamsecure.js
CHANGED
|
@@ -121,13 +121,18 @@ try {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
if (values.ui) {
|
|
124
|
-
const { url } = await serveDashboard(report, {
|
|
124
|
+
const { url, replaced } = await serveDashboard(report, {
|
|
125
125
|
port: Number(values.port),
|
|
126
126
|
open: !values['no-open'],
|
|
127
127
|
rescan: runScan,
|
|
128
128
|
aiProvider: values.ai ?? null,
|
|
129
129
|
analyze: values.ai ? makeAnalyzer({ provider: values.ai, model: values['ai-model'] }) : undefined,
|
|
130
130
|
});
|
|
131
|
+
if (replaced) console.log(c.dim(`\nStopped the previous dashboard (${replaced.root}) and took over port ${replaced.port}.`));
|
|
132
|
+
const port = Number(new URL(url).port);
|
|
133
|
+
if (port !== Number(values.port)) {
|
|
134
|
+
console.log(c.yellow(`\n⚠ Port ${values.port} is busy by another app, using ${port}.`));
|
|
135
|
+
}
|
|
131
136
|
console.log(`\n${c.bold('Dashboard:')} ${c.cyan(url)} ${c.dim('(Ctrl+C to stop)')}`);
|
|
132
137
|
}
|
|
133
138
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iamsecure",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/code/context.js
CHANGED
|
@@ -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
|
+
}
|
package/src/report/sarif.js
CHANGED
|
@@ -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
|
|
package/src/report/terminal.js
CHANGED
|
@@ -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
|
-
|
|
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>}
|
|
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)
|
|
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.
|
|
172
|
-
if (f.kind === 'dependency' &&
|
|
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
|
-
|
|
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.
|
|
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
|
|
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>',
|
|
@@ -409,6 +430,7 @@ function groupRow(name, list) {
|
|
|
409
430
|
function header(r) {
|
|
410
431
|
const s = r.summary;
|
|
411
432
|
const p = r.stats.packages;
|
|
433
|
+
document.title = `${r.project.name} — iamsecure`;
|
|
412
434
|
$('#crumb').innerHTML = `<span>Projects</span><span class="sep">›</span><span>${esc(r.project.name)}</span>`;
|
|
413
435
|
$('#counters').innerHTML = LEVELS.map((l) => `
|
|
414
436
|
<button class="counter ${l} ${s.byRisk[l] ? 'has' : ''} ${state.level === l ? 'on' : ''}" data-level="${l}" title="Show only ${l} (real risk)">
|
|
@@ -618,7 +640,8 @@ function pkgCard(g) {
|
|
|
618
640
|
`${esc(p.name)}@${esc(p.version)}${root ? ` · via ${esc(root)}` : ''}${!p.prod ? ' · dev dependency' : ''}`,
|
|
619
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>
|
|
620
642
|
<div class="k">What could happen</div><div>${esc(IMPACT[top.vulnClass] ?? IMPACT.other)}</div>
|
|
621
|
-
<div class="k">Where it runs</div><div>${esc(
|
|
643
|
+
<div class="k">Where it runs</div><div>${esc(whereText(ctx))}</div>
|
|
644
|
+
${reachRow(top)}
|
|
622
645
|
<div class="k">How to fix</div><div>${how}</div>`,
|
|
623
646
|
`Show ${n === 1 ? 'details' : `all ${n} issues and details`}`);
|
|
624
647
|
}
|
|
@@ -681,7 +704,7 @@ function singleCard(g) {
|
|
|
681
704
|
`${esc(f.location.file)} · line ${f.location.line}`,
|
|
682
705
|
`<div class="k">What's wrong</div><div>${wrong}</div>
|
|
683
706
|
<div class="k">What could happen</div><div>${esc(IMPACT[f.vulnClass] ?? IMPACT.other)}</div>
|
|
684
|
-
<div class="k">Where it runs</div><div>${esc(
|
|
707
|
+
<div class="k">Where it runs</div><div>${esc(whereText(ctx))}</div>
|
|
685
708
|
${f.location.snippet ? `<div class="k">The code</div><div><pre>${esc(f.location.snippet)}</pre></div>` : ''}
|
|
686
709
|
<div class="k">How to fix</div><div>${esc(f.fix)}</div>`,
|
|
687
710
|
'Show details');
|
|
@@ -707,14 +730,44 @@ function toggleCard(card, open) {
|
|
|
707
730
|
btn.textContent = show ? 'Hide details' : btn.dataset.label;
|
|
708
731
|
}
|
|
709
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
|
+
|
|
710
756
|
function renderFix(list) {
|
|
711
|
-
const
|
|
712
|
-
|
|
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]));
|
|
713
763
|
const deps = groups.filter((g) => g.type === 'pkg').length;
|
|
714
|
-
$('#hint').textContent =
|
|
715
|
-
|
|
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>' : '') +
|
|
716
768
|
groups.slice(0, state.shown).map((g) => (g.type === 'pkg' ? pkgCard(g) : singleCard(g))).join('') +
|
|
717
|
-
(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>` : '');
|
|
718
771
|
$('#more')?.addEventListener('click', () => { state.shown += PAGE; render(); });
|
|
719
772
|
document.querySelectorAll('.card').forEach((c) => {
|
|
720
773
|
c.querySelector('.fbar').onclick = () => openCard(c);
|
|
@@ -729,9 +782,10 @@ function renderFix(list) {
|
|
|
729
782
|
function render() {
|
|
730
783
|
const r = state.report;
|
|
731
784
|
header(r);
|
|
732
|
-
$('#
|
|
733
|
-
? 'Worst first
|
|
734
|
-
: 'High → low
|
|
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 = '';
|
|
735
789
|
const list = filtered();
|
|
736
790
|
if (!list.length) {
|
|
737
791
|
$('#list').innerHTML = `<div class="empty">${r.findings.length ? 'No findings match these filters.' : '✓ No known vulnerabilities or insecure code patterns found.'}</div>`;
|
package/src/ui/server.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
2
3
|
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
import crypto from 'node:crypto';
|
|
4
6
|
import { spawn } from 'node:child_process';
|
|
5
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { cacheDir } from '../util/cache.js';
|
|
6
9
|
|
|
7
10
|
const PAGE = fileURLToPath(new URL('./index.html', import.meta.url));
|
|
8
11
|
|
|
@@ -14,16 +17,64 @@ function openBrowser(url) {
|
|
|
14
17
|
} catch {}
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
|
|
22
|
+
// Each running dashboard records {pid, port, secret, root} in a user-only file, so a newer run can
|
|
23
|
+
// ask it to stop. Only someone who can read that file knows the secret, so websites can't do it.
|
|
24
|
+
const lockFile = (port) => path.join(cacheDir, `dashboard-${port}.json`);
|
|
25
|
+
|
|
26
|
+
function portFree(port) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const probe = net.createServer();
|
|
29
|
+
probe.once('error', () => resolve(false));
|
|
30
|
+
probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ask the dashboard holding `port` to shut down. Returns its lock info if it stopped, else null. */
|
|
35
|
+
async function stopPrevious(port) {
|
|
36
|
+
let info;
|
|
37
|
+
try {
|
|
38
|
+
info = JSON.parse(fs.readFileSync(lockFile(port), 'utf8'));
|
|
39
|
+
} catch {
|
|
40
|
+
return null; // not one of ours (or from a version without this feature)
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/shutdown`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'x-iamsecure-shutdown': info.secret },
|
|
46
|
+
signal: AbortSignal.timeout(2000),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) return null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i < 40; i++) {
|
|
53
|
+
if (await portFree(port)) return info;
|
|
54
|
+
await sleep(100);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
function listen(server, port, attempts = 20) {
|
|
60
|
+
// One 'listening' handler that reads the port actually bound. (A callback per attempt would
|
|
61
|
+
// also fire for the failed attempts and report the busy port — opening another dashboard.)
|
|
18
62
|
return new Promise((resolve, reject) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
63
|
+
let p = port;
|
|
64
|
+
const onError = (err) => {
|
|
65
|
+
if (err.code === 'EADDRINUSE' && attempts-- > 0) server.listen(++p, '127.0.0.1');
|
|
66
|
+
else {
|
|
67
|
+
server.off('listening', onListening);
|
|
68
|
+
reject(err);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const onListening = () => {
|
|
72
|
+
server.off('error', onError);
|
|
73
|
+
resolve(server.address().port);
|
|
25
74
|
};
|
|
26
|
-
|
|
75
|
+
server.on('error', onError);
|
|
76
|
+
server.once('listening', onListening);
|
|
77
|
+
server.listen(p, '127.0.0.1');
|
|
27
78
|
});
|
|
28
79
|
}
|
|
29
80
|
|
|
@@ -36,7 +87,14 @@ function listen(server, port, attempts = 20) {
|
|
|
36
87
|
export async function serveDashboard(initialReport, { port = 4477, open = true, rescan, analyze, aiProvider = null } = {}) {
|
|
37
88
|
let report = initialReport;
|
|
38
89
|
const token = crypto.randomBytes(16).toString('hex');
|
|
90
|
+
const shutdownSecret = crypto.randomBytes(24).toString('hex');
|
|
39
91
|
let busy = false;
|
|
92
|
+
let lockPath = null;
|
|
93
|
+
const removeLock = () => {
|
|
94
|
+
try {
|
|
95
|
+
if (lockPath && JSON.parse(fs.readFileSync(lockPath, 'utf8')).pid === process.pid) fs.unlinkSync(lockPath);
|
|
96
|
+
} catch {}
|
|
97
|
+
};
|
|
40
98
|
|
|
41
99
|
const server = http.createServer(async (req, res) => {
|
|
42
100
|
const host = (req.headers.host ?? '').split(':')[0];
|
|
@@ -62,6 +120,15 @@ export async function serveDashboard(initialReport, { port = 4477, open = true,
|
|
|
62
120
|
.replace('__IAMSECURE_AI__', JSON.stringify(aiProvider));
|
|
63
121
|
return send(200, html, 'text/html; charset=utf-8');
|
|
64
122
|
}
|
|
123
|
+
if (req.method === 'POST' && url.pathname === '/api/shutdown') {
|
|
124
|
+
if (req.headers['x-iamsecure-shutdown'] !== shutdownSecret) return send(403, { error: 'forbidden' });
|
|
125
|
+
send(200, { ok: true });
|
|
126
|
+
console.log('\nThis dashboard was replaced by a newer `iamsecure --ui` run. Stopping.');
|
|
127
|
+
removeLock();
|
|
128
|
+
server.close();
|
|
129
|
+
setTimeout(() => process.exit(0), 100);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
65
132
|
if (!url.pathname.startsWith('/api/')) return send(404, { error: 'not found' });
|
|
66
133
|
if (req.headers['x-iamsecure-token'] !== token) return send(401, { error: 'bad token' });
|
|
67
134
|
|
|
@@ -90,8 +157,24 @@ export async function serveDashboard(initialReport, { port = 4477, open = true,
|
|
|
90
157
|
}
|
|
91
158
|
});
|
|
92
159
|
|
|
160
|
+
// Replace an older iamsecure dashboard on the same port instead of piling up new ones.
|
|
161
|
+
const replaced = (await portFree(port)) ? null : await stopPrevious(port);
|
|
162
|
+
|
|
93
163
|
const actual = await listen(server, port);
|
|
164
|
+
lockPath = lockFile(actual);
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
167
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, port: actual, secret: shutdownSecret, root: report.project.root, startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
168
|
+
} catch {}
|
|
169
|
+
process.once('exit', removeLock);
|
|
170
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
171
|
+
process.once(sig, () => {
|
|
172
|
+
removeLock();
|
|
173
|
+
process.exit(sig === 'SIGINT' ? 130 : 0);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
94
177
|
const url = `http://localhost:${actual}/`;
|
|
95
178
|
if (open) openBrowser(url);
|
|
96
|
-
return { url, server };
|
|
179
|
+
return { url, server, replaced };
|
|
97
180
|
}
|