iamsecure 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ import { DepGraph } from '../graph.js';
2
+ import { parseYaml } from '../../util/yaml.js';
3
+
4
+ const specName = (spec) => spec.slice(0, spec.indexOf('@', 1));
5
+
6
+ const unquote = (s) => (s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s);
7
+
8
+ /** Classic yarn.lock (v1) — its own non-YAML format. Returns Map<spec, entry>. */
9
+ function parseClassic(text) {
10
+ const entries = new Map();
11
+ let current = null;
12
+ let section = null;
13
+ for (const raw of text.split(/\r?\n/)) {
14
+ if (!raw.trim() || raw.trimStart().startsWith('#')) continue;
15
+ const indent = raw.length - raw.trimStart().length;
16
+ const line = raw.trim();
17
+ if (indent === 0 && line.endsWith(':')) {
18
+ const specs = line.slice(0, -1).split(/,\s*/).map(unquote);
19
+ current = { name: specName(specs[0]), version: null, dependencies: {}, optionalDependencies: {} };
20
+ for (const s of specs) entries.set(s, current);
21
+ section = null;
22
+ } else if (current && indent <= 2) {
23
+ const m = line.match(/^(\S+)\s+"?([^"]*)"?$/);
24
+ if (line === 'dependencies:' || line === 'optionalDependencies:') section = current[line.slice(0, -1)];
25
+ else {
26
+ section = null;
27
+ if (m && m[1] === 'version') current.version = m[2];
28
+ }
29
+ } else if (current && section) {
30
+ const m = line.match(/^("(?:[^"\\]|\\.)*"|\S+)\s+("(?:[^"\\]|\\.)*"|\S+)$/);
31
+ if (m) section[unquote(m[1])] = unquote(m[2]);
32
+ }
33
+ }
34
+ return entries;
35
+ }
36
+
37
+ /** Yarn 2+ (berry) lockfile — YAML with "name@npm:range" keys. */
38
+ function parseBerry(text) {
39
+ const doc = parseYaml(text);
40
+ const entries = new Map();
41
+ for (const [keyList, e] of Object.entries(doc)) {
42
+ if (keyList === '__metadata' || typeof e !== 'object') continue;
43
+ const resolution = e.resolution ?? keyList.split(/,\s*/)[0];
44
+ const entry = {
45
+ name: specName(resolution),
46
+ version: e.version,
47
+ local: resolution.includes('@workspace:'),
48
+ dependencies: e.dependencies ?? {},
49
+ optionalDependencies: e.optionalDependencies ?? {},
50
+ };
51
+ for (const s of keyList.split(/,\s*/)) entries.set(s, entry);
52
+ }
53
+ return entries;
54
+ }
55
+
56
+ /**
57
+ * @param {string} text yarn.lock contents
58
+ * @param {{dir:string, manifest:any}[]} manifests root + workspace package.json files — yarn.lock
59
+ * doesn't record which deps are dev, so roots come from the manifests.
60
+ */
61
+ export function parseYarnLock(text, manifests) {
62
+ const berry = /^__metadata:/m.test(text);
63
+ const entries = berry ? parseBerry(text) : parseClassic(text);
64
+ const g = new DepGraph('npm', 'yarn.lock');
65
+
66
+ const lookup = (name, range) =>
67
+ entries.get(`${name}@${range}`) ?? entries.get(`${name}@npm:${range}`) ?? null;
68
+
69
+ const unique = new Set(entries.values());
70
+ for (const e of unique) {
71
+ if (!e.version) continue;
72
+ e.key = e.local ? null : g.addNode(e.name, e.version);
73
+ }
74
+ for (const e of unique) {
75
+ if (!e.key) continue;
76
+ for (const [dep, range] of Object.entries({ ...e.optionalDependencies, ...e.dependencies })) {
77
+ const target = lookup(dep, range);
78
+ if (target?.key) g.addEdge(e.key, target.key);
79
+ }
80
+ }
81
+ for (const { dir, manifest } of manifests) {
82
+ const add = (deps, dev) => {
83
+ for (const [name, range] of Object.entries(deps ?? {})) {
84
+ const target = lookup(name, range) ?? [...unique].find((e) => e.name === name && e.key);
85
+ if (target?.key) g.addRoot(name, target.key, { dev, workspace: dir });
86
+ }
87
+ };
88
+ add(manifest.dependencies, false);
89
+ add(manifest.optionalDependencies, false);
90
+ add(manifest.devDependencies, true);
91
+ }
92
+ return g.annotate();
93
+ }
@@ -0,0 +1,106 @@
1
+ import { fetchJson, mapLimit } from '../util/http.js';
2
+ import { cacheGet, cacheSet } from '../util/cache.js';
3
+ import { compareVersions } from '../util/semver.js';
4
+ import { cvssScore, levelFromScore, scoreFromLabel } from './cvss.js';
5
+
6
+ const OSV = 'https://api.osv.dev/v1';
7
+ const BATCH = 500;
8
+
9
+ /**
10
+ * Look up every package against OSV.dev (which aggregates GitHub Advisories, npm, PyPA, Packagist, …).
11
+ * @param {{name:string, version:string, ecosystem:string}[]} pkgs
12
+ * @returns {Promise<Map<string, any[]>>} "ecosystem:name@version" → full OSV records
13
+ */
14
+ export async function queryOsv(pkgs, { onProgress } = {}) {
15
+ const idsByPkg = new Map();
16
+ const wanted = new Map(); // id → modified
17
+
18
+ for (let i = 0; i < pkgs.length; i += BATCH) {
19
+ const chunk = pkgs.slice(i, i + BATCH);
20
+ const res = await fetchJson(`${OSV}/querybatch`, {
21
+ method: 'POST',
22
+ body: { queries: chunk.map((p) => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version })) },
23
+ });
24
+ await Promise.all(
25
+ (res.results ?? []).map(async (r, j) => {
26
+ let vulns = r.vulns ?? [];
27
+ // A package with a very long advisory history gets paginated; follow it.
28
+ for (let token = r.next_page_token; token; ) {
29
+ const p = chunk[j];
30
+ const more = await fetchJson(`${OSV}/query`, {
31
+ method: 'POST',
32
+ body: { package: { name: p.name, ecosystem: p.ecosystem }, version: p.version, page_token: token },
33
+ });
34
+ vulns = vulns.concat(more.vulns ?? []);
35
+ token = more.next_page_token;
36
+ }
37
+ if (!vulns.length) return;
38
+ idsByPkg.set(pkgId(chunk[j]), vulns.map((v) => v.id));
39
+ for (const v of vulns) wanted.set(v.id, v.modified ?? '');
40
+ }),
41
+ );
42
+ onProgress?.(Math.min(i + BATCH, pkgs.length), pkgs.length);
43
+ }
44
+
45
+ const details = new Map();
46
+ await mapLimit([...wanted], 12, async ([id, modified]) => {
47
+ const cached = cacheGet('osv', id);
48
+ if (cached && (!modified || cached.modified === modified)) return details.set(id, cached);
49
+ const full = await fetchJson(`${OSV}/vulns/${encodeURIComponent(id)}`);
50
+ cacheSet('osv', id, full);
51
+ details.set(id, full);
52
+ });
53
+
54
+ const out = new Map();
55
+ for (const [key, ids] of idsByPkg) out.set(key, ids.map((id) => details.get(id)).filter(Boolean));
56
+ return out;
57
+ }
58
+
59
+ export const pkgId = (p) => `${p.ecosystem}:${p.name}@${p.version}`;
60
+
61
+ /** Flatten an OSV record into what the report needs, for one affected package/version. */
62
+ export function normalizeVuln(v, pkg) {
63
+ const sev = (v.severity ?? []).find((s) => s.type === 'CVSS_V3') ?? (v.severity ?? [])[0];
64
+ const cvss = sev ? cvssScore(sev.score) : null;
65
+ const affected = (v.affected ?? []).filter(
66
+ (a) => a.package?.name === pkg.name && a.package?.ecosystem?.toLowerCase() === pkg.ecosystem.toLowerCase(),
67
+ );
68
+ const label = v.database_specific?.severity ?? affected.find((a) => a.ecosystem_specific?.severity)?.ecosystem_specific.severity;
69
+ const malicious = v.id.startsWith('MAL-');
70
+ const score = malicious ? 10 : (cvss?.score ?? scoreFromLabel(label) ?? 5);
71
+
72
+ return {
73
+ id: v.id,
74
+ aliases: v.aliases ?? [],
75
+ summary: v.summary || firstLine(v.details) || v.id,
76
+ details: (v.details ?? '').slice(0, 4000),
77
+ published: v.published,
78
+ modified: v.modified,
79
+ url: `https://osv.dev/vulnerability/${v.id}`,
80
+ references: (v.references ?? []).slice(0, 6).map((r) => r.url),
81
+ cwes: v.database_specific?.cwe_ids ?? [],
82
+ cvss: cvss && { vector: sev.score, version: cvss.version, score: cvss.score, metrics: cvss.metrics },
83
+ severityScore: score,
84
+ severity: levelFromScore(score),
85
+ fixedIn: fixedVersion(affected, pkg.version),
86
+ malicious,
87
+ };
88
+ }
89
+
90
+ /** Prefer the fix of the range that actually contains `current` (e.g. a 13.x backport over 15.x). */
91
+ function fixedVersion(affected, current) {
92
+ const later = [];
93
+ for (const range of affected.flatMap((a) => a.ranges ?? [])) {
94
+ let introduced = null;
95
+ for (const e of range.events ?? []) {
96
+ if (e.introduced !== undefined) introduced = e.introduced;
97
+ if (e.fixed === undefined) continue;
98
+ const inRange = (introduced === '0' || introduced === null || compareVersions(current, introduced) >= 0) && compareVersions(current, e.fixed) < 0;
99
+ if (inRange) return e.fixed;
100
+ if (compareVersions(e.fixed, current) > 0) later.push(e.fixed);
101
+ }
102
+ }
103
+ return later.sort(compareVersions)[0] ?? null;
104
+ }
105
+
106
+ const firstLine = (s) => (s ?? '').split('\n').find((l) => l.trim())?.slice(0, 160);
package/src/project.js ADDED
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function readJson(file) {
5
+ try {
6
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ /** Expand package.json "workspaces" globs (supports "dir", "dir/*" and "dir/**"). */
13
+ export function findWorkspaces(root, manifest) {
14
+ const patterns = Array.isArray(manifest?.workspaces) ? manifest.workspaces : (manifest?.workspaces?.packages ?? []);
15
+ const dirs = new Set();
16
+ for (const pattern of patterns) {
17
+ if (pattern.startsWith('!')) continue;
18
+ let current = [''];
19
+ for (const seg of pattern.replace(/\/$/, '').split('/')) {
20
+ const next = [];
21
+ for (const base of current) {
22
+ if (seg === '*' || seg === '**') {
23
+ for (const d of safeReaddir(path.join(root, base))) {
24
+ if (d.isDirectory() && d.name !== 'node_modules' && !d.name.startsWith('.')) next.push(path.join(base, d.name));
25
+ }
26
+ } else next.push(path.join(base, seg));
27
+ }
28
+ current = next;
29
+ }
30
+ for (const d of current) if (fs.existsSync(path.join(root, d, 'package.json'))) dirs.add(d);
31
+ }
32
+ return [...dirs].map((dir) => ({ dir, manifest: readJson(path.join(root, dir, 'package.json')) ?? {} }));
33
+ }
34
+
35
+ function safeReaddir(dir) {
36
+ try {
37
+ return fs.readdirSync(dir, { withFileTypes: true });
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ /** What kind of app is this? Drives how code is classified as browser vs server. */
44
+ export function detectFramework(deps) {
45
+ const has = (n) => deps.has(n);
46
+ if (has('next')) return 'next';
47
+ if (has('nuxt')) return 'nuxt';
48
+ if (has('@remix-run/react') || has('@remix-run/node')) return 'remix';
49
+ if (has('@sveltejs/kit')) return 'sveltekit';
50
+ if (has('astro')) return 'astro';
51
+ if (has('react-scripts')) return 'cra';
52
+ if (has('vite') && (has('react') || has('vue') || has('svelte'))) return 'vite-spa';
53
+ if (['express', 'fastify', 'koa', '@nestjs/core', '@hapi/hapi', 'hono'].some(has)) return 'node-server';
54
+ if (has('react') || has('vue')) return 'spa';
55
+ return deps.size ? 'node' : 'unknown';
56
+ }
57
+
58
+ /** Non-JS projects: everything runs server-side, but name the stack for the report. */
59
+ function detectBackend(root) {
60
+ const composer = readJson(path.join(root, 'composer.json'));
61
+ if (composer) {
62
+ const req = { ...composer.require, ...composer['require-dev'] };
63
+ if (req['laravel/framework']) return 'laravel';
64
+ if (Object.keys(req).some((n) => n.startsWith('symfony/'))) return 'symfony';
65
+ return 'php';
66
+ }
67
+ let py = '';
68
+ for (const f of ['pyproject.toml', 'requirements.txt', 'Pipfile']) {
69
+ try {
70
+ py += fs.readFileSync(path.join(root, f), 'utf8').toLowerCase();
71
+ } catch {}
72
+ }
73
+ if (py) {
74
+ if (/\bdjango\b/.test(py)) return 'django';
75
+ if (/\bfastapi\b/.test(py)) return 'fastapi';
76
+ if (/\bflask\b/.test(py)) return 'flask';
77
+ return 'python';
78
+ }
79
+ return 'unknown';
80
+ }
81
+
82
+ export function loadProject(root) {
83
+ root = path.resolve(root);
84
+ const manifest = readJson(path.join(root, 'package.json'));
85
+ const workspaces = manifest ? findWorkspaces(root, manifest) : [];
86
+ const manifests = manifest ? [{ dir: '.', manifest }, ...workspaces] : [];
87
+ const deps = new Set();
88
+ for (const { manifest: m } of manifests) {
89
+ for (const k of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
90
+ for (const n of Object.keys(m[k] ?? {})) deps.add(n);
91
+ }
92
+ }
93
+ let framework = detectFramework(deps);
94
+ if (framework === 'unknown') framework = detectBackend(root);
95
+ return {
96
+ root,
97
+ name: manifest?.name ?? readJson(path.join(root, 'composer.json'))?.name ?? path.basename(root),
98
+ manifest,
99
+ manifests,
100
+ framework,
101
+ };
102
+ }
@@ -0,0 +1,58 @@
1
+ // SARIF 2.1.0 — consumed by GitHub code scanning, GitLab, Azure DevOps, VS Code SARIF viewer.
2
+
3
+ const LEVEL = { critical: 'error', high: 'error', medium: 'warning', low: 'note', none: 'note' };
4
+
5
+ export function toSarif(report) {
6
+ const rules = new Map();
7
+ const results = [];
8
+
9
+ for (const f of report.findings) {
10
+ const ruleId = f.kind === 'dependency' ? f.advisory.id : f.ruleId;
11
+ const helpUri = f.kind === 'dependency' ? f.advisory.url : f.cwe ? `https://cwe.mitre.org/data/definitions/${f.cwe.replace('CWE-', '')}.html` : undefined;
12
+ const prev = rules.get(ruleId);
13
+ // GitHub reads `security-severity` (0–10) to bucket alerts; use the contextual risk score.
14
+ if (!prev || Number(prev.properties['security-severity']) < f.riskScore) {
15
+ rules.set(ruleId, {
16
+ id: ruleId,
17
+ name: ruleId.replace(/[^A-Za-z0-9]+/g, '_'),
18
+ shortDescription: { text: f.title.slice(0, 200) },
19
+ fullDescription: { text: (f.kind === 'dependency' ? f.advisory.details || f.title : f.title).slice(0, 1000) },
20
+ helpUri,
21
+ help: { text: f.fix ?? '' },
22
+ properties: {
23
+ 'security-severity': f.riskScore.toFixed(1),
24
+ tags: ['security', f.kind, ...(f.cwe ? [f.cwe] : f.advisory?.cwes ?? [])],
25
+ },
26
+ });
27
+ }
28
+
29
+ const where =
30
+ f.kind === 'dependency'
31
+ ? { uri: f.package.lockfile.split(',')[0].trim(), line: 1 }
32
+ : { uri: f.location.file, line: f.location.line };
33
+ const text =
34
+ f.kind === 'dependency'
35
+ ? `${f.package.name}@${f.package.version}: ${f.title}. ${f.fix}${f.package.path.length > 1 ? ` (via ${f.package.path.join(' > ')})` : ''}`
36
+ : `${f.title}. ${f.fix}`;
37
+
38
+ results.push({
39
+ ruleId,
40
+ level: LEVEL[f.risk],
41
+ message: { text: [text, ...(f.reasons ?? []).map((r) => `• ${r}`)].join('\n') },
42
+ locations: [{ physicalLocation: { artifactLocation: { uri: where.uri }, region: { startLine: where.line } } }],
43
+ partialFingerprints: { iamsecure: f.id },
44
+ properties: { riskScore: f.riskScore, severityScore: f.severityScore, exposure: f.exposure?.context, aiVerdict: f.ai?.verdict },
45
+ });
46
+ }
47
+
48
+ return {
49
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
50
+ version: '2.1.0',
51
+ runs: [
52
+ {
53
+ tool: { driver: { name: 'iamsecure', version: report.tool.version, informationUri: 'https://github.com/Ravindrabisht12/iamsecure', rules: [...rules.values()] } },
54
+ results,
55
+ },
56
+ ],
57
+ };
58
+ }
@@ -0,0 +1,253 @@
1
+ import { sortFindings } from '../scan.js';
2
+
3
+ const useColor = (process.stdout.isTTY || !!process.env.FORCE_COLOR) && !process.env.NO_COLOR;
4
+ const wrapAnsi = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
5
+ export const c = {
6
+ bold: wrapAnsi('1'),
7
+ dim: wrapAnsi('2'),
8
+ italic: wrapAnsi('3'),
9
+ red: wrapAnsi('31'),
10
+ green: wrapAnsi('32'),
11
+ yellow: wrapAnsi('33'),
12
+ blue: wrapAnsi('34'),
13
+ magenta: wrapAnsi('35'),
14
+ cyan: wrapAnsi('36'),
15
+ gray: wrapAnsi('90'),
16
+ orange: wrapAnsi('38;5;208'),
17
+ bgRed: wrapAnsi('41;97;1'),
18
+ };
19
+
20
+ const LEVELS = ['critical', 'high', 'medium', 'low'];
21
+ const TONE = {
22
+ critical: (s) => c.red(c.bold(s)),
23
+ high: c.orange,
24
+ medium: c.yellow,
25
+ low: c.blue,
26
+ ok: c.green,
27
+ none: c.gray,
28
+ muted: c.gray,
29
+ };
30
+ const dot = (tone) => TONE[tone]('●');
31
+ const EXPOSURE_TONE = { server: 'critical', universal: 'critical', client: 'medium', build: 'low', unused: 'muted', unknown: 'muted' };
32
+ const EXPOSURE_WORD = { server: 'server', universal: 'server + browser', client: 'browser only', build: 'build/test only', unused: 'not imported', unknown: 'unknown' };
33
+ const KIND_WORD = { dependency: 'Dependency', code: 'Code', secret: 'Secret' };
34
+
35
+ // ── layout helpers (ANSI-aware) ──────────────────────────────────────────────
36
+ const ANSI = /\x1b\[[0-9;]*m/g;
37
+ const vlen = (s) => String(s).replace(ANSI, '').length;
38
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - vlen(s)));
39
+ const center = (s, n) => {
40
+ const left = Math.floor((n - vlen(s)) / 2);
41
+ return pad(' '.repeat(Math.max(0, left)) + s, n);
42
+ };
43
+
44
+ /** Word-wrap plain text to `width`, hard-breaking words that don't fit. */
45
+ function wrap(text, width) {
46
+ const out = [];
47
+ for (const para of String(text).split('\n')) {
48
+ let line = '';
49
+ for (let word of para.split(/\s+/).filter(Boolean)) {
50
+ while (word.length > width) {
51
+ if (line) out.push(line), (line = '');
52
+ out.push(word.slice(0, width));
53
+ word = word.slice(width);
54
+ }
55
+ if (!line) line = word;
56
+ else if (line.length + 1 + word.length <= width) line += ' ' + word;
57
+ else out.push(line), (line = word);
58
+ }
59
+ out.push(line);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** Wrap, then style every line (styles must not span lines). */
65
+ const block = (text, width, style = (s) => s, firstPrefix = '', restPrefix = firstPrefix) =>
66
+ wrap(text, width - vlen(firstPrefix)).map((l, i) => (i ? restPrefix : firstPrefix) + style(l));
67
+
68
+ const ellipsisStart = (s, n) => (s.length <= n ? s : '…' + s.slice(-(n - 1)));
69
+ const ellipsisEnd = (s, n) => (s.length <= n ? s : s.slice(0, n - 1) + '…');
70
+
71
+ /** Lay two columns side by side; falls back to stacked when the terminal is narrow. */
72
+ function columns(left, right, leftWidth, total, indent = ' ') {
73
+ if (total < 88) return [...left.map((l) => indent + l), ...right.map((r) => indent + ' ' + r)];
74
+ const rows = Math.max(left.length, right.length);
75
+ const out = [];
76
+ for (let i = 0; i < rows; i++) out.push(indent + pad(left[i] ?? '', leftWidth) + ' ' + (right[i] ?? ''));
77
+ return out;
78
+ }
79
+
80
+ // ── sections ─────────────────────────────────────────────────────────────────
81
+ function counters(summary) {
82
+ const top = [];
83
+ const mid = [];
84
+ const lab = [];
85
+ const bot = [];
86
+ for (const l of LEVELS) {
87
+ const n = summary.byRisk[l];
88
+ const tone = n ? l : 'muted';
89
+ top.push(c.gray('╭──────────╮'));
90
+ mid.push(c.gray('│') + center(`${dot(tone)} ${n ? c.bold(n) : c.gray(n)}`, 10) + c.gray('│'));
91
+ lab.push(c.gray('│') + center(n ? c.bold(l.toUpperCase()) : c.gray(l.toUpperCase()), 10) + c.gray('│'));
92
+ bot.push(c.gray('╰──────────╯'));
93
+ }
94
+ return [top, mid, lab, bot].map((r) => ' ' + r.join(' '));
95
+ }
96
+
97
+ function overall(summary) {
98
+ const r = summary.byRisk;
99
+ if (r.critical) return { tone: 'critical', icon: '✖', label: 'ACTION REQUIRED', detail: `${r.critical} critical` };
100
+ if (r.high) return { tone: 'high', icon: '▲', label: 'ATTENTION', detail: `${r.high} high` };
101
+ if (summary.total) return { tone: 'medium', icon: '●', label: 'MONITOR', detail: 'minor issues' };
102
+ return { tone: 'ok', icon: '✔', label: 'ALL CLEAR', detail: 'no issues' };
103
+ }
104
+
105
+ function groupLine(name, list) {
106
+ if (!list.length) return `${dot('ok')} ${c.bold(name)} – ${c.green('Clear')}`;
107
+ const worst = LEVELS.find((l) => list.some((f) => f.risk === l)) ?? 'muted';
108
+ const counts = LEVELS.map((l) => [l, list.filter((f) => f.risk === l).length]).filter(([, n]) => n);
109
+ return `${dot(worst)} ${c.bold(name)} – ${counts.map(([l, n]) => `${n} ${l}`).join(', ')}`;
110
+ }
111
+
112
+ const TRIO = [11, 8, 10, 6];
113
+
114
+ function latestPanel(report, width) {
115
+ const s = report.summary;
116
+ const p = report.stats.packages;
117
+ const st = overall(s);
118
+ const leftW = 36;
119
+ const rightW = width < 88 ? width - 6 : width - leftW - 8;
120
+ const bar = c.gray('┃ ');
121
+
122
+ const left = [
123
+ `${TONE[st.tone](st.icon)} ${c.bold(st.label)} ${c.gray('–')} ${st.detail}`,
124
+ c.gray(new Date(report.scannedAt).toLocaleString() + ` · ${(report.durationMs / 1000).toFixed(1)}s`),
125
+ '',
126
+ LEVELS.map((l, i) => pad(c.gray(l[0].toUpperCase() + l.slice(1) + ':'), TRIO[i])).join(''),
127
+ LEVELS.map((l, i) => pad(`${dot(s.byRisk[l] ? l : 'muted')} ${s.byRisk[l]}`, TRIO[i])).join(''),
128
+ c.gray(`by raw severity: ${LEVELS.map((l) => s.bySeverity[l]).join(' · ')}`),
129
+ ];
130
+
131
+ const kinds = { dependency: [], code: [], secret: [] };
132
+ for (const f of report.findings) kinds[f.kind].push(f);
133
+ const prose =
134
+ `Scanned ${p.total} packages (${p.direct} direct, ${p.transitive} transitive)` +
135
+ (report.stats.files != null ? ` and ${report.stats.files} source files` : '') +
136
+ `. 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'}.` +
137
+ (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}).` : '');
138
+ const right = [
139
+ ...wrap(prose, rightW),
140
+ '',
141
+ groupLine('Dependencies', kinds.dependency),
142
+ groupLine('Your code', kinds.code),
143
+ groupLine('Secrets', kinds.secret),
144
+ ];
145
+ if (width < 88) return [...left, '', ...right].map((l) => ' ' + bar + l);
146
+ const rows = Math.max(left.length, right.length);
147
+ const out = [];
148
+ for (let i = 0; i < rows; i++) out.push(' ' + bar + pad(left[i] ?? '', leftW) + ' ' + (right[i] ?? ''));
149
+ return out;
150
+ }
151
+
152
+ function findingEntry(f, sort, width) {
153
+ const leftW = 34;
154
+ const rightW = width < 88 ? width - 6 : Math.max(36, width - leftW - 7);
155
+ const level = sort === 'severity' ? f.severity : f.risk;
156
+
157
+ // Left: label, subject, score trio — like "UPDATE 3 – Identified / time / Up Affected Down".
158
+ const subject =
159
+ f.kind === 'dependency' ? ellipsisEnd(`${f.package.name}@${f.package.version}`, leftW - 2) : ellipsisStart(`${f.location.file}:${f.location.line}`, leftW - 2);
160
+ const ctx = f.exposure?.context ?? 'unknown';
161
+ const left = [
162
+ `${TONE[level]('◉')} ${TONE[level](c.bold(level.toUpperCase()))} ${c.gray('–')} ${c.bold(KIND_WORD[f.kind])}`,
163
+ ' ' + c.cyan(subject),
164
+ ` ${c.gray('Risk')} ${dot(f.risk)} ${c.bold(f.riskScore.toFixed(1))} ${c.gray('Sev')} ${dot(f.severity)} ${f.severityScore.toFixed(1)}`,
165
+ ` ${c.gray('Runs')} ${dot(EXPOSURE_TONE[ctx])} ${EXPOSURE_WORD[ctx]}`,
166
+ ];
167
+ const tags = [];
168
+ if (f.kind === 'dependency') {
169
+ tags.push(f.package.direct ? 'direct' : 'transitive');
170
+ if (!f.package.prod) tags.push('dev');
171
+ } else if (f.tainted) tags.push('user input');
172
+ if (tags.length) left.push(' ' + c.gray(tags.join(' · ')));
173
+
174
+ // Right: what happened, why it ranks here, what to do.
175
+ const right = [];
176
+ const flags = [];
177
+ if (f.advisory?.malicious) flags.push(c.bgRed(' MALICIOUS PACKAGE '));
178
+ if (f.exploit?.kev) flags.push(c.bgRed(' EXPLOITED IN THE WILD '));
179
+ right.push(...block(f.title, rightW, c.bold));
180
+ if (flags.length) right.push(flags.join(' '));
181
+ if (f.kind === 'dependency') {
182
+ right.push(c.gray(ellipsisEnd(`${f.advisory.id}${f.advisory.aliases.length ? ' · ' + f.advisory.aliases.slice(0, 2).join(', ') : ''}`, rightW)));
183
+ if (f.package.path.length > 1) right.push(...block(`via ${f.package.path.join(' › ')}`, rightW, c.gray));
184
+ } else if (f.location?.snippet) {
185
+ right.push(c.cyan('│ ') + c.cyan(ellipsisEnd(f.location.snippet.trim(), rightW - 2)));
186
+ }
187
+ for (const r of f.reasons ?? []) {
188
+ if (/^AI \(/.test(r)) continue;
189
+ right.push(...block(r, rightW, c.gray, c.gray('· '), ' '));
190
+ }
191
+ if (f.fix) right.push(...block(f.fix, rightW, (s) => s, c.green('Fix: '), ' '));
192
+ if (f.ai) {
193
+ right.push(...block(`${f.ai.verdict.replace('_', ' ')} (${Math.round(f.ai.confidence * 100)}%) — ${f.ai.reasoning}`.slice(0, 420), rightW, c.magenta, c.magenta(`AI ${f.ai.provider}: `), ' '));
194
+ }
195
+
196
+ return columns(left, right, leftW, width);
197
+ }
198
+
199
+ /**
200
+ * @param {any} report
201
+ * @param {{sort?: 'risk'|'severity', limit?: number, min?: string}} opts
202
+ */
203
+ export function printReport(report, { sort = 'risk', limit = 30, min } = {}) {
204
+ const width = Math.max(60, Math.min(Number(process.env.COLUMNS) || process.stdout.columns || 110, 130));
205
+ const rule = (ch = '─') => ' ' + c.gray(ch.repeat(width - 4));
206
+ const out = [''];
207
+
208
+ // Header strip + title, like a status page.
209
+ out.push(c.gray('━'.repeat(width)));
210
+ const fw = report.project.framework;
211
+ const brand = ` ${c.bold('iamsecure')} ${c.gray('v' + report.tool.version)}`;
212
+ const where = c.gray(ellipsisStart(report.project.root, Math.max(10, width - 30)));
213
+ out.push(brand + ' '.repeat(Math.max(2, width - vlen(brand) - vlen(where) - 2)) + where);
214
+ out.push(c.gray('━'.repeat(width)));
215
+ out.push('');
216
+ out.push(` ${c.gray('Projects ›')} ${report.project.name}`);
217
+ out.push('');
218
+ out.push(` ${c.yellow('⚠')} ${c.bold(`Security Report — ${report.project.name}`)} ${c.gray(`(${fw})`)}`);
219
+ out.push('');
220
+ out.push(...counters(report.summary));
221
+ out.push('');
222
+ for (const w of report.warnings) out.push(...block(w, width - 4, c.yellow, ' ' + c.yellow('⚠ '), ' '));
223
+ if (report.warnings.length) out.push('');
224
+
225
+ out.push(` ${c.gray('Latest scan:')}`);
226
+ out.push(...latestPanel(report, width));
227
+ out.push('');
228
+
229
+ if (!report.findings.length) {
230
+ out.push(' ' + c.green('✔ No known vulnerabilities or insecure code patterns found.'), '');
231
+ console.log(out.join('\n'));
232
+ return;
233
+ }
234
+
235
+ const field = sort === 'severity' ? 'severity' : 'risk';
236
+ const order = [...LEVELS, 'none'];
237
+ let list = sortFindings(report.findings, sort);
238
+ if (min) list = list.filter((f) => order.indexOf(f[field]) <= order.indexOf(min));
239
+
240
+ const heading = sort === 'severity' ? 'Findings — by severity (raw CVSS, high → low):' : 'Findings — worst first (real risk in this codebase):';
241
+ const hint = sort === 'severity' ? '--sort risk for contextual order' : '--sort severity for raw CVSS order';
242
+ out.push(` ${c.gray(heading)}${' '.repeat(Math.max(2, width - 4 - heading.length - hint.length))}${c.gray(hint)}`);
243
+ out.push(rule());
244
+ for (const f of list.slice(0, limit)) {
245
+ out.push(...findingEntry(f, sort, width));
246
+ out.push(rule());
247
+ }
248
+ if (list.length > limit) {
249
+ out.push(` ${c.gray('…')} ${c.bold(list.length - limit)} ${c.gray('more — use --limit <n>, --json, or open the dashboard with')} ${c.cyan('--ui')}`);
250
+ }
251
+ out.push('');
252
+ console.log(out.join('\n'));
253
+ }
@@ -0,0 +1,53 @@
1
+ // Exploit intelligence: is anyone actually exploiting this?
2
+ // - CISA KEV: catalog of vulnerabilities known to be exploited in the wild.
3
+ // - FIRST EPSS: probability a CVE gets exploited in the next 30 days.
4
+
5
+ import { fetchJson } from '../util/http.js';
6
+ import { cacheGet, cacheSet } from '../util/cache.js';
7
+
8
+ const KEV_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
9
+ const EPSS_URL = 'https://api.first.org/data/v1/epss';
10
+ const DAY = 24 * 60 * 60 * 1000;
11
+
12
+ async function loadKev() {
13
+ const cached = cacheGet('kev', 'catalog', DAY);
14
+ if (cached) return new Map(cached);
15
+ const res = await fetchJson(KEV_URL, { timeout: 45000 });
16
+ const entries = (res.vulnerabilities ?? []).map((v) => [
17
+ v.cveID,
18
+ { dateAdded: v.dateAdded, ransomware: v.knownRansomwareCampaignUse === 'Known', name: v.vulnerabilityName },
19
+ ]);
20
+ cacheSet('kev', 'catalog', entries);
21
+ return new Map(entries);
22
+ }
23
+
24
+ async function loadEpss(cves) {
25
+ const out = new Map();
26
+ const missing = [];
27
+ for (const cve of cves) {
28
+ const hit = cacheGet('epss', cve, DAY);
29
+ if (hit !== undefined) out.set(cve, hit);
30
+ else missing.push(cve);
31
+ }
32
+ for (let i = 0; i < missing.length; i += 80) {
33
+ const chunk = missing.slice(i, i + 80);
34
+ const res = await fetchJson(`${EPSS_URL}?cve=${chunk.join(',')}`);
35
+ const got = new Map((res.data ?? []).map((d) => [d.cve, { epss: Number(d.epss), percentile: Number(d.percentile) }]));
36
+ for (const cve of chunk) {
37
+ const v = got.get(cve) ?? null;
38
+ out.set(cve, v);
39
+ cacheSet('epss', cve, v);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** @returns {Promise<{kev: Map<string,any>, epss: Map<string,any>, warnings: string[]}>} */
46
+ export async function loadExploitData(cves) {
47
+ const warnings = [];
48
+ const [kev, epss] = await Promise.all([
49
+ loadKev().catch((e) => (warnings.push(`CISA KEV unavailable: ${e.message}`), new Map())),
50
+ loadEpss(cves).catch((e) => (warnings.push(`EPSS unavailable: ${e.message}`), new Map())),
51
+ ]);
52
+ return { kev, epss, warnings };
53
+ }