iamsecure 0.0.0-stage → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ravindra Bisht
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,175 @@
1
- # Temporary Holding Version
1
+ # iamsecure
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/iamsecure?color=d93a2b&label=npm)](https://www.npmjs.com/package/iamsecure)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-2f7fd8)](https://github.com/Ravindrabisht12/iamsecure/blob/main/LICENSE)
5
+ ![zero dependencies](https://img.shields.io/badge/dependencies-0-12a03b)
6
+ ![node >= 18](https://img.shields.io/badge/node-%E2%89%A518-339933)
7
+
8
+ **Find vulnerable dependencies and insecure code, ranked by how dangerous they are in *your* app, not just by CVSS.**
9
+
10
+ ```bash
11
+ npx iamsecure # scan the current folder
12
+ npx iamsecure --ui # open the dashboard
13
+ ```
14
+
15
+ <p align="center">
16
+ <img src="docs/images/terminal.png" alt="iamsecure terminal report" width="680">
17
+ </p>
18
+
19
+ Every finding is ranked in two ways:
20
+
21
+ - **Real risk (worst first)** is the default. It shows how dangerous each issue is for *this* codebase.
22
+ - **Severity (high → low)** is the raw CVSS / rule severity, the same thing `npm audit` shows.
23
+
24
+ The two rankings differ a lot in practice. A 9.8 CVE in a dev-only CLI tool drops to *low*. An 8.1 lodash advisory rises to *critical* because your API route calls the exact `_.template` function the advisory names.
25
+
26
+ It has **zero runtime dependencies** and needs only Node ≥ 18. A security scanner shouldn't add 300 packages to your supply chain.
27
+
28
+ ### Example: OWASP Juice Shop
29
+
30
+ A scan of [OWASP Juice Shop](https://github.com/juice-shop/juice-shop), a deliberately vulnerable Node.js app:
31
+
32
+ | | critical | high | medium | low |
33
+ |---|---|---|---|---|
34
+ | By raw severity | 16 | 51 | 44 | 2 |
35
+ | **By real risk** | **16** | **40** | **44** | **13** |
36
+
37
+ It covered 1,181 packages and 377 source files in about 5 seconds, and found the app's planted SQL injection in `routes/login.ts`, its `eval`, its path traversal routes and 11 hard-coded secrets.
38
+
39
+ Twelve "high" findings dropped to medium or low:
40
+ - Nine are in `tar`, which is installed but never imported anywhere.
41
+ - The rest are in a dev-only build tool and a test file.
42
+
43
+ One "high" `jsonwebtoken` advisory rose to *critical*, because the server's auth code imports that package.
44
+
45
+ ## Dashboard
46
+
47
+ `npx iamsecure --ui` opens a local dashboard. Its **Fix list** groups every issue into one action, explains it in plain language (what's wrong, what could happen, where it runs, how to fix) and gives the exact install command.
48
+
49
+ <p align="center">
50
+ <img src="docs/images/dashboard.png" alt="iamsecure dashboard: fix list" width="820">
51
+ </p>
52
+
53
+ The **Dependency tree** shows which of your direct dependencies brings in each vulnerable package. Its search answers "why is this installed?".
54
+
55
+ <p align="center">
56
+ <img src="docs/images/dependency-tree.png" alt="iamsecure dependency tree" width="820">
57
+ </p>
58
+
59
+ ## Quick start
60
+
61
+ ```bash
62
+ npx iamsecure # scan the current directory, print to terminal
63
+ npx iamsecure --ui # open the interactive dashboard on http://localhost:4477
64
+ npx iamsecure --ai claude-cli # let Claude double-check the top findings
65
+ ```
66
+
67
+ To get `npm run iamsecure` in a project:
68
+
69
+ ```bash
70
+ npm i -D iamsecure
71
+ npm pkg set scripts.iamsecure="iamsecure --ui"
72
+ npm run iamsecure
73
+ ```
74
+
75
+ ## What it does
76
+
77
+ | Step | How |
78
+ |---|---|
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
+ | 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
+ | 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. |
83
+
84
+ ## How "real risk" is calculated
85
+
86
+ ```
87
+ risk = severity × exposure × attack vector × exploit intel × reachability (× AI verdict)
88
+ ```
89
+
90
+ - **Exposure** depends on where the code runs. The tool classifies every file as server, browser, both, or build/test. It understands Next.js `app/`, `pages/api`, `'use client'`, middleware, Vite/CRA SPAs, Node servers, Nuxt/SvelteKit/Remix, PHP and Python. A vulnerable package inherits the exposure of the direct dependencies that pull it in.
91
+ - **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
+ - **Attack vector.** Advisories that need local access (CVSS `AV:L`) or high privileges score lower.
93
+ - **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
+
96
+ Every adjustment adds a plain-English reason that shows in the terminal, the dashboard and SARIF, so the ranking can be explained.
97
+
98
+ ## AI validation (optional)
99
+
100
+ | `--ai` | Uses | Notes |
101
+ |---|---|---|
102
+ | `claude-cli` | Your existing Claude Code login | No API key. Runs `claude -p` with all tools disabled. |
103
+ | `anthropic` | `ANTHROPIC_API_KEY` | Needs `@anthropic-ai/sdk` installed (loaded only when used). Defaults to `claude-opus-5-5`. |
104
+ | `openai` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` works with OpenRouter, Groq, LM Studio, vLLM… |
105
+ | `ollama` | Local Ollama | Nothing leaves your machine. |
106
+
107
+ - Only the finding and the surrounding code snippets are sent, never the whole repo. Run `--ai-dry-run` to see exactly what would be sent.
108
+ - The model returns `exploitable / likely / unlikely / not_exploitable` with a confidence, reasoning and a fix. That verdict adjusts the risk score.
109
+ - For example, it demoted the Next.js middleware-bypass CVE (EPSS 99%) to "unlikely" because the project has no `middleware.ts`.
110
+ - Code and advisory text are passed as untrusted data, which defends against prompt injection.
111
+ - Results are cached in `~/.cache/iamsecure`, so re-runs are free.
112
+ - In the dashboard, every finding has an **Ask AI** button.
113
+
114
+ ## CI
115
+
116
+ ```yaml
117
+ # .github/workflows/iamsecure.yml
118
+ name: iamsecure
119
+ on: [push, pull_request]
120
+ jobs:
121
+ scan:
122
+ runs-on: ubuntu-latest
123
+ permissions: { security-events: write, contents: read }
124
+ steps:
125
+ - uses: actions/checkout@v4
126
+ - uses: actions/setup-node@v4
127
+ with: { node-version: 22 }
128
+ - run: npx iamsecure --sarif --fail-on critical
129
+ - uses: github/codeql-action/upload-sarif@v3
130
+ if: always()
131
+ with: { sarif_file: iamsecure.sarif }
132
+ ```
133
+
134
+ The SARIF `security-severity` field is the contextual risk score, so GitHub's alert severities follow the real-risk ranking.
135
+
136
+ ## Options
137
+
138
+ ```
139
+ --sort <risk|severity> ranking order (default risk)
140
+ --min <level> hide findings below critical|high|medium|low
141
+ --limit <n> findings printed in the terminal (default 30)
142
+ --ui / --port / --no-open dashboard (binds 127.0.0.1 only, token-protected API)
143
+ --json [file] full report as JSON
144
+ --sarif [file] SARIF 2.1.0
145
+ --fail-on <level> exit 1 when real risk ≥ level
146
+ --ai <provider> / --ai-model / --ai-max <n> / --ai-dry-run
147
+ --no-deps / --no-code / --offline-intel
148
+ ```
149
+
150
+ Environment variables:
151
+ - `IAMSECURE_CACHE_DIR` and `IAMSECURE_NO_CACHE=1` control the cache.
152
+ - `IAMSECURE_AI_TIMEOUT` sets the AI timeout in seconds (default 180).
153
+ - `IAMSECURE_OPENAI_MODEL` and `IAMSECURE_OLLAMA_MODEL` override those providers' models.
154
+
155
+ ## Layout
156
+
157
+ ```
158
+ bin/iamsecure.js CLI
159
+ src/scan.js orchestrator → report object
160
+ src/deps/ lockfile parsers, dependency graph, OSV client, CVSS
161
+ src/code/ file walker, server/browser classification, rules + taint engine, secrets
162
+ src/risk/ exploit intel (KEV/EPSS) and the risk model
163
+ src/ai/ prompt builder and providers (claude-cli, anthropic, openai, ollama)
164
+ src/report/ terminal and SARIF output
165
+ src/ui/ localhost dashboard: fix list, all findings, dependency tree (single HTML file, no CDN)
166
+ ```
167
+
168
+ To try it from a clone of this repo, run `node bin/iamsecure.js /path/to/your/project --ui`.
169
+
170
+ ## Limitations
171
+
172
+ - 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.
174
+ - Packages are matched against published advisories. A zero-day nobody has reported yet won't show up.
175
+ - Python and PHP import usage isn't mapped to packages yet. Their runtime dependencies are treated as server-side.
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { scan, summarize, sortFindings, VERSION } from '../src/scan.js';
6
+ import { aiReview, makeAnalyzer } from '../src/ai/index.js';
7
+ import { printReport, c } from '../src/report/terminal.js';
8
+ import { serveDashboard } from '../src/ui/server.js';
9
+ import { toSarif } from '../src/report/sarif.js';
10
+
11
+ const HELP = `
12
+ ${c.bold('iamsecure')} — find vulnerable dependencies and insecure code, ranked by real-world risk
13
+
14
+ ${c.bold('Usage')}
15
+ npx iamsecure [path] [options]
16
+
17
+ ${c.bold('Options')}
18
+ --sort <risk|severity> risk = worst for THIS codebase first (default); severity = raw CVSS high → low
19
+ --min <level> only show findings at or above critical|high|medium|low
20
+ --limit <n> max findings printed in the terminal (default 30)
21
+ --ui open the interactive report in your browser (localhost only)
22
+ --port <n> dashboard port (default 4477; next free port is used if taken)
23
+ --no-open with --ui: don't launch the browser, just print the URL
24
+ --json [file] write the full report as JSON (default iamsecure-report.json)
25
+ --sarif [file] write SARIF for GitHub/GitLab code scanning (default iamsecure.sarif)
26
+ --fail-on <level> exit 1 if any finding's real risk is at/above critical|high|medium|low (CI)
27
+
28
+ ${c.bold('AI validation')} (optional — only code snippets around each finding are sent, never the whole repo)
29
+ --ai <provider> claude-cli (uses your Claude Code login) | anthropic (ANTHROPIC_API_KEY)
30
+ | openai (OPENAI_API_KEY, OPENAI_BASE_URL for compatible APIs) | ollama (local)
31
+ --ai-model <id> override the provider's default model
32
+ --ai-max <n> how many top findings to review (default 15)
33
+ --ai-dry-run print the prompts that would be sent, send nothing
34
+ --no-deps skip the dependency scan
35
+ --no-code skip source analysis (exposure then becomes "unknown")
36
+ --offline-intel skip CISA KEV / EPSS lookups
37
+ -v, --version print version
38
+ -h, --help show this help
39
+
40
+ ${c.bold('Examples')}
41
+ npx iamsecure scan the current directory
42
+ npx iamsecure ../my-next-app --sort severity
43
+ npx iamsecure --ui interactive dashboard on http://localhost:4477
44
+ npx iamsecure --ai claude-cli let Claude double-check the top 15 findings
45
+ `;
46
+
47
+ const { values, positionals } = parseArgs({
48
+ allowPositionals: true,
49
+ options: {
50
+ sort: { type: 'string', default: 'risk' },
51
+ min: { type: 'string' },
52
+ limit: { type: 'string', default: '30' },
53
+ json: { type: 'string' },
54
+ sarif: { type: 'string' },
55
+ 'fail-on': { type: 'string' },
56
+ ui: { type: 'boolean', default: false },
57
+ port: { type: 'string', default: '4477' },
58
+ 'no-open': { type: 'boolean', default: false },
59
+ ai: { type: 'string' },
60
+ 'ai-model': { type: 'string' },
61
+ 'ai-max': { type: 'string', default: '15' },
62
+ 'ai-dry-run': { type: 'boolean', default: false },
63
+ 'no-deps': { type: 'boolean', default: false },
64
+ 'no-code': { type: 'boolean', default: false },
65
+ 'offline-intel': { type: 'boolean', default: false },
66
+ version: { type: 'boolean', short: 'v' },
67
+ help: { type: 'boolean', short: 'h' },
68
+ },
69
+ // `--json` / `--sarif` with no value are allowed; parseArgs needs a value, so patch argv below.
70
+ args: process.argv.slice(2).flatMap((a, i, arr) => {
71
+ const defaults = { '--json': 'iamsecure-report.json', '--sarif': 'iamsecure.sarif' };
72
+ return defaults[a] && (arr[i + 1] === undefined || arr[i + 1].startsWith('-')) ? [a, defaults[a]] : [a];
73
+ }),
74
+ });
75
+
76
+ if (values.help) {
77
+ console.log(HELP);
78
+ process.exit(0);
79
+ }
80
+ if (values.version) {
81
+ console.log(VERSION);
82
+ process.exit(0);
83
+ }
84
+
85
+ const root = path.resolve(positionals[0] ?? '.');
86
+ if (!fs.existsSync(root)) {
87
+ console.error(`Path not found: ${root}`);
88
+ process.exit(2);
89
+ }
90
+
91
+ const log = (m) => process.stderr.write(c.dim(`› ${m}\n`));
92
+
93
+ const runScan = async () => {
94
+ const report = await scan(root, { deps: !values['no-deps'], code: !values['no-code'], exploit: !values['offline-intel'], log });
95
+ if (values.ai) {
96
+ await aiReview(report, { provider: values.ai, model: values['ai-model'], max: Number(values['ai-max']), dryRun: values['ai-dry-run'], log });
97
+ report.summary = summarize(report.findings);
98
+ report.findings = sortFindings(report.findings, 'risk');
99
+ }
100
+ return report;
101
+ };
102
+
103
+ try {
104
+ const report = await runScan();
105
+ printReport(report, { sort: values.sort, limit: values.ui ? Math.min(Number(values.limit), 10) : Number(values.limit), min: values.min });
106
+ if (values.json) {
107
+ fs.writeFileSync(values.json, JSON.stringify(report, null, 2));
108
+ log(`JSON report written to ${values.json}`);
109
+ }
110
+ if (values.sarif) {
111
+ fs.writeFileSync(values.sarif, JSON.stringify(toSarif(report), null, 2));
112
+ log(`SARIF report written to ${values.sarif}`);
113
+ }
114
+ const order = ['critical', 'high', 'medium', 'low'];
115
+ if (values['fail-on'] && !values.ui) {
116
+ if (!order.includes(values['fail-on'])) throw new Error(`--fail-on must be one of ${order.join(', ')}`);
117
+ const failing = report.findings.filter((f) => order.indexOf(f.risk) !== -1 && order.indexOf(f.risk) <= order.indexOf(values['fail-on']));
118
+ if (failing.length) {
119
+ console.error(c.red(`✖ ${failing.length} finding(s) at or above "${values['fail-on']}" real risk.`));
120
+ process.exitCode = 1;
121
+ }
122
+ }
123
+ if (values.ui) {
124
+ const { url } = await serveDashboard(report, {
125
+ port: Number(values.port),
126
+ open: !values['no-open'],
127
+ rescan: runScan,
128
+ aiProvider: values.ai ?? null,
129
+ analyze: values.ai ? makeAnalyzer({ provider: values.ai, model: values['ai-model'] }) : undefined,
130
+ });
131
+ console.log(`\n${c.bold('Dashboard:')} ${c.cyan(url)} ${c.dim('(Ctrl+C to stop)')}`);
132
+ }
133
+ } catch (err) {
134
+ console.error(c.red(`iamsecure failed: ${err.stack ?? err.message}`));
135
+ process.exit(2);
136
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "iamsecure",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "Scan a codebase for vulnerable dependencies and insecure code, ranked by real-world risk \u2014 with an optional AI second opinion.",
5
+ "author": "Ravindra Bisht",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravindrabisht12/iamsecure.git"
9
+ },
10
+ "homepage": "https://github.com/Ravindrabisht12/iamsecure#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/Ravindrabisht12/iamsecure/issues"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "iamsecure": "bin/iamsecure.js"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src/**/*.js",
21
+ "src/ui/index.html"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18.17"
25
+ },
26
+ "scripts": {
27
+ "iamsecure": "node bin/iamsecure.js"
28
+ },
29
+ "keywords": [
30
+ "security",
31
+ "vulnerability",
32
+ "audit",
33
+ "osv",
34
+ "sca",
35
+ "sast"
36
+ ],
37
+ "license": "MIT"
38
+ }
@@ -0,0 +1,170 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { mapLimit } from '../util/http.js';
5
+ import { cacheGet, cacheSet } from '../util/cache.js';
6
+ import { levelFromScore } from '../deps/cvss.js';
7
+ import { createProvider } from './providers.js';
8
+
9
+ const VERDICTS = { exploitable: 1.15, likely: 1, unlikely: 0.6, not_exploitable: 0.3 };
10
+ const MAX_DETAILS = 3000;
11
+
12
+ function readLines(root, rel, center, radius) {
13
+ const abs = path.resolve(root, rel);
14
+ if (!abs.startsWith(path.resolve(root) + path.sep)) return null;
15
+ let text;
16
+ try {
17
+ text = fs.readFileSync(abs, 'utf8');
18
+ } catch {
19
+ return null;
20
+ }
21
+ const lines = text.split('\n');
22
+ const from = Math.max(1, center - radius);
23
+ const to = Math.min(lines.length, center + radius);
24
+ const body = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(4)}| ${l}`).join('\n');
25
+ return `<code file="${rel}" lines="${from}-${to}">\n${body}\n</code>`;
26
+ }
27
+
28
+ /** Evidence the model needs: the finding plus the code that uses it. */
29
+ export function buildPrompt(f, report) {
30
+ const root = report.project.root;
31
+ const parts = [];
32
+ const meta = {
33
+ type: f.kind,
34
+ title: f.title,
35
+ framework: report.project.framework,
36
+ whereItRuns: f.exposure?.label,
37
+ scannerSeverity: f.severityScore,
38
+ scannerRisk: f.riskScore,
39
+ scannerReasons: f.reasons,
40
+ };
41
+ if (f.kind === 'dependency') {
42
+ Object.assign(meta, {
43
+ package: `${f.package.name}@${f.package.version}`,
44
+ direct: f.package.direct,
45
+ productionDependency: f.package.prod,
46
+ installedVia: f.package.path,
47
+ advisory: f.advisory.id,
48
+ aliases: f.advisory.aliases,
49
+ cwes: f.advisory.cwes,
50
+ cvss: f.advisory.cvss?.vector,
51
+ fixedIn: f.package.fixedIn,
52
+ advisoryText: `${f.advisory.summary}\n\n${f.advisory.details}`.slice(0, MAX_DETAILS),
53
+ });
54
+ } else {
55
+ Object.assign(meta, { rule: f.ruleId, cwe: f.cwe, userInputDetected: !!f.tainted, location: `${f.location.file}:${f.location.line}` });
56
+ }
57
+ parts.push(`<finding>\n${JSON.stringify(meta, null, 2)}\n</finding>`);
58
+
59
+ if (f.kind === 'dependency') {
60
+ // Show where the package (or the direct dependency that pulls it in) is used.
61
+ const evid = [];
62
+ if (f.reachable?.file) evid.push([f.reachable.file, f.reachable.line]);
63
+ for (const e of f.exposure?.evidence ?? []) {
64
+ const m = e.match(/in (.+):(\d+)$/);
65
+ if (m) evid.push([m[1], Number(m[2])]);
66
+ }
67
+ const seen = new Set();
68
+ for (const [file, line] of evid) {
69
+ if (seen.has(file) || seen.size >= 3) continue;
70
+ seen.add(file);
71
+ const block = readLines(root, file, line, 25);
72
+ if (block) parts.push(block);
73
+ }
74
+ if (!seen.size) parts.push('(No source file imports this package directly; it is pulled in transitively or loaded by the framework.)');
75
+ if (report.sourceFiles?.length) {
76
+ const list = report.sourceFiles.slice(0, 120).map((s) => `${s.file} [${s.context}]`).join('\n');
77
+ parts.push(`Project source files (to judge whether the affected feature is used):\n${list}`);
78
+ }
79
+ } else {
80
+ const block = readLines(root, f.location.file, f.location.line, 30);
81
+ if (block) parts.push(block);
82
+ }
83
+
84
+ 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.
86
+
87
+ Respond with ONLY a JSON object, no markdown:
88
+ {"verdict": "exploitable" | "likely" | "unlikely" | "not_exploitable",
89
+ "confidence": <number 0..1>,
90
+ "reasoning": "<at most 3 sentences citing the specific code>",
91
+ "fix": "<the concrete change to make; a short code snippet if it helps>"}`);
92
+ return parts.join('\n\n');
93
+ }
94
+
95
+ function parseVerdict(text) {
96
+ const start = text.indexOf('{');
97
+ const end = text.lastIndexOf('}');
98
+ if (start === -1 || end <= start) throw new Error('AI response had no JSON object');
99
+ const obj = JSON.parse(text.slice(start, end + 1));
100
+ const verdict = String(obj.verdict ?? '').toLowerCase().replace(/[\s-]+/g, '_');
101
+ if (!(verdict in VERDICTS)) throw new Error(`AI returned unknown verdict "${obj.verdict}"`);
102
+ const confidence = Math.min(1, Math.max(0, Number(obj.confidence) || 0.5));
103
+ return { verdict, confidence, reasoning: String(obj.reasoning ?? '').slice(0, 1200), fix: String(obj.fix ?? '').slice(0, 2000) };
104
+ }
105
+
106
+ /** Fold an AI verdict into the risk score; low confidence moves it less. */
107
+ function applyVerdict(f, ai) {
108
+ const m = 1 + (VERDICTS[ai.verdict] - 1) * ai.confidence;
109
+ f.ai = ai;
110
+ f.riskScore = Math.round(Math.min(10, f.riskScore * m) * 10) / 10;
111
+ f.risk = levelFromScore(f.riskScore);
112
+ f.reasons = [...(f.reasons ?? []), `AI (${ai.provider}) judged it ${ai.verdict.replace('_', ' ')} (${Math.round(ai.confidence * 100)}% confidence)`];
113
+ }
114
+
115
+ async function reviewOne(f, report, provider) {
116
+ const prompt = buildPrompt(f, report);
117
+ const key = `${provider.name}:${provider.model}:${crypto.createHash('sha256').update(prompt).digest('hex')}`;
118
+ let ai = cacheGet('ai', key);
119
+ if (!ai) {
120
+ ai = { ...parseVerdict(await provider.complete(prompt)), provider: provider.name, model: provider.model };
121
+ cacheSet('ai', key, ai);
122
+ }
123
+ applyVerdict(f, ai);
124
+ return ai;
125
+ }
126
+
127
+ /**
128
+ * Ask an AI provider to validate the top findings.
129
+ * @param {any} report scan report (findings are updated in place)
130
+ * @param {{provider: string, model?: string, max?: number, dryRun?: boolean, log?: (m:string)=>void}} opts
131
+ */
132
+ export async function aiReview(report, { provider: name, model, max = 15, dryRun = false, log = () => {} }) {
133
+ const provider = createProvider(name, { model, root: report.project.root });
134
+ const candidates = [...report.findings]
135
+ .filter((f) => f.riskScore >= 4 && !f.ai && !f.advisory?.malicious)
136
+ .sort((a, b) => b.riskScore - a.riskScore)
137
+ .slice(0, max);
138
+ if (!candidates.length) return { reviewed: 0, failed: 0, provider };
139
+
140
+ if (dryRun) {
141
+ for (const f of candidates) log(`--- prompt for ${f.id} ---\n${buildPrompt(f, report)}\n`);
142
+ log(`Dry run: ${candidates.length} prompts shown above; nothing was sent.`);
143
+ return { reviewed: 0, failed: 0, provider };
144
+ }
145
+
146
+ log(`Asking ${provider.name} (${provider.model}) to validate the top ${candidates.length} findings…`);
147
+ let done = 0;
148
+ let failed = 0;
149
+ const errors = new Set();
150
+ await mapLimit(candidates, provider.concurrency, async (f) => {
151
+ try {
152
+ await reviewOne(f, report, provider);
153
+ } catch (err) {
154
+ failed++;
155
+ errors.add(err.message);
156
+ }
157
+ log(` AI review ${++done}/${candidates.length}`);
158
+ });
159
+ if (errors.size) report.warnings.push(`AI review: ${failed} of ${candidates.length} failed — ${[...errors].slice(0, 2).join('; ')}`);
160
+ return { reviewed: candidates.length - failed, failed, provider };
161
+ }
162
+
163
+ /** Single on-demand review (dashboard "Ask AI" button). */
164
+ export function makeAnalyzer({ provider: name, model }) {
165
+ let provider;
166
+ return async (f, report) => {
167
+ provider ??= createProvider(name, { model, root: report.project.root });
168
+ return reviewOne(f, report, provider);
169
+ };
170
+ }