safeey-cli 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,40 +23,103 @@ safeey scan . --json > report.json # machine-readable output
23
23
 
24
24
  Your code never leaves your machine — scanning runs entirely locally.
25
25
 
26
+ ## Sign in (free)
27
+
28
+ Safeey is free, but asks you to sign in once so we know who's using it:
29
+
30
+ ```bash
31
+ safeey login # opens your browser to sign in / sign up
32
+ ```
33
+
34
+ That's it — you're signed in on this machine until you `safeey logout`.
35
+
36
+ ## Fixing issues
37
+
38
+ After a scan, Safeey can apply safe, mechanical fixes for you (gitignore an
39
+ exposed `.env`, bump a vulnerable dependency to its patched version):
40
+
41
+ ```bash
42
+ safeey fix . # preview the fixes, then apply
43
+ safeey fix . --dry-run # just preview
44
+ ```
45
+
46
+ Auto-fix is a paid feature; everything else — scanning, reports, exports — is free.
47
+ Findings you've reviewed can be silenced inline with `// safeey-ignore <rule-id>`.
48
+
26
49
  ## What it checks
27
50
 
28
51
  - **Dangerous code patterns** (AST-based): `eval`/`Function` code execution,
29
- command injection via `child_process`, SQL injection from string-built queries,
30
- XSS via `innerHTML`/`dangerouslySetInnerHTML`, disabled TLS verification, weak
31
- hashing (MD5/SHA-1), open redirects, permissive CORS, hardcoded secret fallbacks.
52
+ command injection via `child_process`, SQL & NoSQL injection, XSS via
53
+ `innerHTML`/`dangerouslySetInnerHTML`, prototype pollution, SSRF, ReDoS,
54
+ open redirects, permissive CORS, non-constant-time comparisons, disabled TLS
55
+ verification, weak hashing (MD5/SHA-1), hardcoded secret fallbacks.
56
+ - **Taint tracking** (JS/TS): follows untrusted input from a request to a
57
+ dangerous sink, so it catches real injections that arrive through a variable —
58
+ and stays quiet on safe, constant inputs.
59
+ - **Framework rules**: Express, Next.js, Django, Flask, Spring.
60
+ - **Infrastructure**: Dockerfile, Kubernetes, Terraform, and GitHub Actions
61
+ misconfigurations.
32
62
  - **Hardcoded secrets**: AWS keys, private keys, Stripe/Google/GitHub/Slack/OpenAI
33
- tokens, JWTs, and generic credential assignments (values are masked in output).
34
- - **Dependencies**: known-vulnerable and unpinned packages in `package.json`.
63
+ tokens, JWTs, high-entropy strings, and generic credential assignments (masked
64
+ in output). Optional `--history` scans your git history for secrets that were
65
+ committed and are still recoverable.
66
+ - **Dependencies**: unpinned packages, and (with `--osv`) known CVEs from the
67
+ OSV.dev database, including transitive dependencies.
35
68
 
36
69
  Every finding includes a plain-English explanation and the fix, plus a **risk grade**
37
70
  (A–F) for the whole codebase.
38
71
 
72
+ ## Reports & exports
73
+
74
+ Scanning a big codebase? Keep the terminal readable and put the full list in a file.
75
+
76
+ ```bash
77
+ safeey scan . --summary # counts, most-affected files, top rules
78
+ safeey scan . --severity critical,high # show only the severities you care about
79
+ safeey scan . -o report.html # full report — open in a browser, or Save as PDF
80
+ safeey scan . -o issues.csv # sort & filter in Excel / Sheets
81
+ safeey scan . -o report.md # drop into a PR or issue
82
+ ```
83
+
84
+ `--severity` is a **display** filter — your risk grade always reflects *all* findings.
85
+ The output format comes from the file extension (`.html`, `.csv`, `.md`, `.json`,
86
+ `.sarif`), or force it with `--format`. Writing to a file still prints the summary to
87
+ your terminal, so you see the shape at a glance while the file holds everything.
88
+
89
+ > **Want a PDF?** Open the `.html` report in your browser and use **Save as PDF** —
90
+ > same result without bundling a headless browser into the CLI.
91
+
39
92
  ## Options
40
93
 
41
94
  | Flag | Description |
42
95
  |------|-------------|
43
96
  | `--fail-on <sev>` | Exit non-zero if any finding ≥ severity (`critical`/`high`/`medium`/`low`/`off`; default `high`) |
97
+ | `--severity <levels>` | Show only these severities, e.g. `high` or `critical,high` (display only; grade unaffected) |
98
+ | `--confidence <level>` | Show only findings at/above confidence (`low`/`medium`/`high`) |
99
+ | `--summary` | Print a rollup: counts, most-affected files, top rules |
100
+ | `-o, --output <file>` | Write the full report to a file (format from the extension) |
101
+ | `--format <fmt>` | Force output format: `html`/`md`/`csv`/`json`/`sarif` |
102
+ | `--osv` | Check dependencies against known CVEs via OSV.dev (requires network) |
103
+ | `--history` | Scan git history for committed secrets |
104
+ | `--sarif` | SARIF 2.1.0 output (for GitHub code scanning) |
44
105
  | `--json` | Output findings as JSON |
106
+ | `--baseline <file>` · `--update-baseline <file>` | Report only findings that are new vs a saved baseline |
45
107
  | `--no-secrets` | Skip secret detection |
46
- | `-h, --help` | Help |
47
- | `-v, --version` | Version |
108
+ | `-h, --help` · `-v, --version` | Help · version |
48
109
 
49
110
  ## Languages
50
111
 
51
- JavaScript / TypeScript today (AST-based). Secret and dependency scanning are
52
- language-agnostic. More languages (Python, Go) are on the roadmap.
112
+ JavaScript / TypeScript get the deepest analysis (AST rules + taint tracking).
113
+ Pattern-based rules also cover Python, Go, PHP, and Java, and secret and
114
+ dependency scanning are language-agnostic. Broader language support is on the
115
+ roadmap.
53
116
 
54
117
  ## Roadmap
55
118
 
56
- - More languages via tree-sitter grammars
57
- - Full CVE coverage via the OSV database
119
+ - Deeper language coverage via tree-sitter grammars
120
+ - Interprocedural taint tracking
58
121
  - Optional AI layer to explain findings and suggest patches
59
- - `safeey login` to sync results to your Safeey dashboard
122
+ - Team dashboards and trend tracking on [safeey.io](https://safeey.io)
60
123
 
61
124
  ## License
62
125
 
package/bin/safeey.js CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // bin/safeey.js — Safeey CLI entry point.
3
3
  const { scan, grade, SEV_ORDER } = require("../src/scan");
4
- const { printReport } = require("../src/report");
4
+ const { printReport, printSummary } = require("../src/report");
5
5
  const { toSarif } = require("../src/report-sarif");
6
+ const { toMarkdown } = require("../src/report-md");
7
+ const { toHtml } = require("../src/report-html");
8
+ const { toCsv } = require("../src/report-csv");
6
9
  const { getConfig, setConfig, CONFIG_PATH } = require("../src/config");
7
10
  const { syncResults } = require("../src/sync");
8
11
  const { loadBaseline, writeBaseline, filterNew } = require("../src/baseline");
@@ -10,6 +13,8 @@ const auth = require("../src/auth");
10
13
  const { Spinner, attachProgress } = require("../src/spinner");
11
14
  const { planFixes, planEdits, applyEdits } = require("../src/fix");
12
15
  const readline = require("readline");
16
+ const fs = require("fs");
17
+ const path = require("path");
13
18
 
14
19
  const pkg = require("../package.json");
15
20
 
@@ -30,8 +35,13 @@ Account
30
35
  Options
31
36
  --fail-on <severity> Exit non-zero if any finding >= severity
32
37
  (critical|high|medium|low|off; default: high)
38
+ --severity <levels> Show only these severities (e.g. high or high,critical)
33
39
  --confidence <level> Only show findings at/above confidence
34
40
  (low|medium|high; default: low)
41
+ --summary Print a rollup (counts, worst files, top rules)
42
+ -o, --output <file> Write the full report to a file
43
+ (format from extension: .html .md .csv .json .sarif)
44
+ --format <fmt> Force output format (html|md|csv|json|sarif)
35
45
  --osv Check dependencies against known CVEs (OSV.dev; network)
36
46
  --history Scan git history for committed secrets
37
47
  --no-secrets Skip secret detection
@@ -51,10 +61,11 @@ Suppress a finding inline:
51
61
  // safeey-ignore-next-line <rule-id>
52
62
 
53
63
  Examples
54
- safeey register
55
64
  safeey scan ./ --osv
56
- safeey scan ./src --fail-on critical --confidence high
57
- safeey scan . --sarif > safeey.sarif
65
+ safeey scan . --severity critical,high
66
+ safeey scan . --summary
67
+ safeey scan . -o report.html # open in a browser, or Save as PDF
68
+ safeey scan . -o issues.csv # sort & filter in Excel/Sheets
58
69
  safeey fix . --dry-run
59
70
  `;
60
71
 
@@ -78,6 +89,10 @@ function parseArgs(argv) {
78
89
  else if (a === "--no-fix") args.noFix = true;
79
90
  else if (a === "--yes" || a === "-y") args.yes = true;
80
91
  else if (a === "--dry-run") args.dryRun = true;
92
+ else if (a === "--severity") args.severity = argv[++i];
93
+ else if (a === "-o" || a === "--output") args.output = argv[++i];
94
+ else if (a === "--format") args.format = argv[++i];
95
+ else if (a === "--summary") args.summary = true;
81
96
  else if (a === "-h" || a === "--help") args.help = true;
82
97
  else if (a === "-v" || a === "--version") args.version = true;
83
98
  else args._.push(a);
@@ -163,12 +178,33 @@ async function main() {
163
178
  result.grade = grade(result.findings);
164
179
  }
165
180
 
166
- if (args.sarif) {
167
- console.log(toSarif(result, target));
168
- } else if (args.json) {
169
- console.log(JSON.stringify({ target, ...result, SEV_ORDER: undefined }, null, 2));
181
+ // Severity filter is a *display* filter — it never changes the grade, which
182
+ // always reflects the full set of findings.
183
+ const sevSet = parseSeverity(args.severity);
184
+ const dres = displayResult(result, filterBySeverity(result.findings, sevSet));
185
+
186
+ const fmt = resolveFormat(args);
187
+
188
+ if (args.output) {
189
+ const useFmt = (fmt && fmt !== "console") ? fmt : "md";
190
+ const content = render(useFmt, dres, target);
191
+ if (content == null) {
192
+ console.error(` Unknown format for ${args.output}. Use --format md|html|csv|json|sarif.`);
193
+ process.exit(2);
194
+ }
195
+ try { fs.writeFileSync(args.output, content); }
196
+ catch (e) { console.error(` Couldn't write ${args.output}: ${e.message}`); process.exit(2); }
197
+ // Still give the terminal the shape at a glance; the file has everything.
198
+ printSummary(dres, target);
199
+ console.log(` \u2714 Full report written to ${args.output}` + dimNote(dres, sevSet) + "\n");
200
+ } else if (fmt === "sarif") {
201
+ console.log(render("sarif", dres, target));
202
+ } else if (fmt === "json") {
203
+ console.log(render("json", dres, target));
204
+ } else if (args.summary) {
205
+ printSummary(dres, target);
170
206
  } else {
171
- printReport(result, target);
207
+ printReport(dres, target);
172
208
  if (result.meta && result.meta.osv && result.meta.osv.error) console.log(` \u26a0 OSV check skipped: ${result.meta.osv.error}\n`);
173
209
  if (result.meta && result.meta.history && result.meta.history.error) console.log(` \u26a0 History scan skipped: ${result.meta.history.error}\n`);
174
210
  }
@@ -176,8 +212,8 @@ async function main() {
176
212
  // ---- offer to auto-fix (paid) ------------------------------------------
177
213
  // On a human-facing run with findings, ask whether Safeey should fix them.
178
214
  // Saying no just leaves the results as-is. The `fix` command skips the ask
179
- // and goes straight to the (plan-gated) fix flow.
180
- if (!quiet && result.findings.length) {
215
+ // and goes straight to the (plan-gated) fix flow. Skipped when exporting.
216
+ if (!quiet && !args.output && result.findings.length) {
181
217
  const { fixable } = planFixes(result.findings);
182
218
  let proceed = false;
183
219
  if (isFixCmd) {
@@ -211,6 +247,56 @@ function ask(question) {
211
247
  });
212
248
  }
213
249
 
250
+ const SEV_LEVELS = ["critical", "high", "medium", "low", "info"];
251
+
252
+ // Parse --severity: a comma-separated list of exact levels (e.g. "high" or
253
+ // "critical,high"). Returns a Set, or null when not filtering.
254
+ function parseSeverity(spec) {
255
+ if (!spec) return null;
256
+ const set = new Set(String(spec).toLowerCase().split(",").map((s) => s.trim()).filter(Boolean));
257
+ for (const s of set) if (!SEV_LEVELS.includes(s)) { console.error(` Unknown severity "${s}". Use: ${SEV_LEVELS.join(", ")}.`); process.exit(2); }
258
+ return set.size ? set : null;
259
+ }
260
+
261
+ function filterBySeverity(findings, set) {
262
+ return set ? findings.filter((f) => set.has(f.severity)) : findings;
263
+ }
264
+
265
+ // A result view for display/export: same grade letter/score (the true grade),
266
+ // but counts recomputed to match the (possibly filtered) findings shown.
267
+ function displayResult(result, findings) {
268
+ if (findings === result.findings) return result;
269
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
270
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
271
+ return { ...result, findings, grade: { ...result.grade, counts } };
272
+ }
273
+
274
+ function resolveFormat(args) {
275
+ if (args.format) return String(args.format).toLowerCase();
276
+ if (args.output) {
277
+ const ext = path.extname(args.output).slice(1).toLowerCase();
278
+ return { md: "md", markdown: "md", html: "html", htm: "html", csv: "csv", json: "json", sarif: "sarif" }[ext] || null;
279
+ }
280
+ if (args.sarif) return "sarif";
281
+ if (args.json) return "json";
282
+ return "console";
283
+ }
284
+
285
+ function render(fmt, res, target) {
286
+ switch (fmt) {
287
+ case "md": return toMarkdown(res, target, res.findings);
288
+ case "html": return toHtml(res, target, res.findings);
289
+ case "csv": return toCsv(res, target, res.findings);
290
+ case "json": return JSON.stringify({ target, ...res, SEV_ORDER: undefined }, null, 2);
291
+ case "sarif": return toSarif(res, target);
292
+ default: return null;
293
+ }
294
+ }
295
+
296
+ function dimNote(dres, sevSet) {
297
+ return ` (${dres.findings.length} finding${dres.findings.length === 1 ? "" : "s"}${sevSet ? ", filtered by severity" : ""})`;
298
+ }
299
+
214
300
  // Plan-gated fix flow: verify the plan, preview the mechanical changes, confirm,
215
301
  // apply. Free users get an upgrade nudge; the scan results stay theirs either way.
216
302
  async function runFix(result, target, args, fixable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safeey-cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Scan your source code for security vulnerabilities — a Safeey product by Noohra Innovate Ltd.",
5
5
  "bin": {
6
6
  "safeey": "bin/safeey.js"
@@ -0,0 +1,18 @@
1
+ // src/report-csv.js — findings as CSV (open in Excel/Sheets to sort & filter).
2
+ const COLUMNS = ["severity", "confidence", "category", "cwe", "ruleId", "file", "line", "column", "title", "message", "fix"];
3
+
4
+ function toCsv(result, target, findings) {
5
+ const rows = [COLUMNS.join(",")];
6
+ for (const f of findings) {
7
+ rows.push(COLUMNS.map((k) => esc(f[k])).join(","));
8
+ }
9
+ return rows.join("\n") + "\n";
10
+ }
11
+
12
+ // RFC-4180-ish escaping: wrap in quotes if the value has a comma, quote or newline.
13
+ function esc(v) {
14
+ const s = v == null ? "" : String(v);
15
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
16
+ }
17
+
18
+ module.exports = { toCsv };
@@ -0,0 +1,99 @@
1
+ // src/report-html.js — a self-contained, styled HTML report.
2
+ // Opens in any browser; "Save as PDF" from the browser gives a clean PDF with
3
+ // no extra tooling. Includes a small client-side severity filter (which print
4
+ // ignores, so a printed/exported PDF always contains everything).
5
+ const SEV_ORDER = ["critical", "high", "medium", "low", "info"];
6
+ const pkg = require("../package.json");
7
+
8
+ function toHtml(result, target, findings) {
9
+ const g = result.grade;
10
+ const counts = g.counts;
11
+ const chips = SEV_ORDER.filter((s) => counts[s]).map((s) => `<span class="chip ${s}">${counts[s]} ${s}</span>`).join("");
12
+ const filterBtns = ["all", ...SEV_ORDER.filter((s) => counts[s])]
13
+ .map((s) => `<button class="fbtn${s === "all" ? " active" : ""}" data-f="${s}">${s === "all" ? "All" : cap(s)}</button>`).join("");
14
+
15
+ const rows = findings.map((f) => `
16
+ <tr data-sev="${f.severity}">
17
+ <td><span class="sev ${f.severity}">${f.severity}</span></td>
18
+ <td class="loc"><code>${esc(f.file)}${f.line ? ":" + f.line : ""}</code></td>
19
+ <td class="rule"><code>${esc(f.ruleId)}</code></td>
20
+ <td><div class="title">${esc(f.title)}</div><div class="msg">${esc(f.message)}</div></td>
21
+ <td class="fix">${esc(f.fix)}</td>
22
+ </tr>`).join("");
23
+
24
+ const body = findings.length
25
+ ? `<div class="filters">${filterBtns}</div>
26
+ <table>
27
+ <thead><tr><th>Severity</th><th>Location</th><th>Rule</th><th>Issue</th><th>Fix</th></tr></thead>
28
+ <tbody>${rows}</tbody>
29
+ </table>`
30
+ : `<p class="clean">✔ No issues found.</p>`;
31
+
32
+ return `<!doctype html>
33
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
34
+ <title>Safeey report — ${esc(target)}</title>
35
+ <style>
36
+ :root { --bg:#0b0d10; --card:#15181d; --line:#262b33; --ink:#e7ebf0; --dim:#9aa4b2; }
37
+ * { box-sizing: border-box; }
38
+ body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif; }
39
+ .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 20px 64px; }
40
+ header { border-bottom:1px solid var(--line); padding-bottom:18px; margin-bottom:20px; }
41
+ h1 { font-size:20px; margin:0 0 6px; }
42
+ .meta { color:var(--dim); font-size:13px; }
43
+ .grade { display:inline-flex; align-items:center; gap:10px; margin:14px 0 8px; }
44
+ .badge { font-weight:700; font-size:22px; width:40px; height:40px; border-radius:10px; display:flex; align-items:center; justify-content:center; }
45
+ .badge.A{background:#0f2a1a;color:#39d98a}.badge.B{background:#0e2530;color:#38bdf8}.badge.C{background:#2a2410;color:#eab308}.badge.D{background:#2a1030;color:#c084fc}.badge.F{background:#2a1015;color:#f87171}
46
+ .chips { margin-top:10px; display:flex; gap:8px; flex-wrap:wrap; }
47
+ .chip { font-size:12px; padding:3px 9px; border-radius:20px; border:1px solid var(--line); color:var(--dim); }
48
+ .chip.critical,.chip.high{color:#f87171}.chip.medium{color:#eab308}.chip.low{color:#60a5fa}
49
+ .filters { display:flex; gap:8px; margin-bottom:14px; flex-wrap:wrap; }
50
+ .fbtn { background:var(--card); border:1px solid var(--line); color:var(--dim); padding:5px 12px; border-radius:8px; cursor:pointer; font-size:13px; }
51
+ .fbtn.active { color:var(--ink); border-color:#3a4552; background:#1c2128; }
52
+ table { width:100%; border-collapse:collapse; font-size:13px; }
53
+ thead th { position:sticky; top:0; background:var(--bg); text-align:left; color:var(--dim); font-weight:600; padding:8px 10px; border-bottom:1px solid var(--line); }
54
+ td { padding:10px; border-bottom:1px solid var(--line); vertical-align:top; }
55
+ code { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:#cbd5e1; }
56
+ .sev { text-transform:uppercase; font-size:11px; font-weight:700; padding:2px 7px; border-radius:5px; }
57
+ .sev.critical{background:#f87171;color:#210607}.sev.high{background:#3a1216;color:#f87171}.sev.medium{background:#2a2410;color:#eab308}.sev.low{background:#0e2233;color:#60a5fa}.sev.info{background:#1c2128;color:#9aa4b2}
58
+ .title { font-weight:600; margin-bottom:3px; }
59
+ .msg { color:var(--dim); }
60
+ .fix { color:#a7f3d0; max-width:320px; }
61
+ .loc,.rule { white-space:nowrap; }
62
+ .clean { color:#39d98a; font-size:18px; }
63
+ footer { margin-top:24px; color:var(--dim); font-size:12px; }
64
+ @media print {
65
+ :root { --bg:#fff; --card:#fff; --line:#ddd; --ink:#111; --dim:#555; }
66
+ body { background:#fff; } .filters { display:none !important; }
67
+ tr[data-sev]{ display:table-row !important; }
68
+ .sev.high{background:#fde8e8}.fix{color:#065f46}.msg{color:#555} code{color:#334}
69
+ }
70
+ </style></head>
71
+ <body><div class="wrap">
72
+ <header>
73
+ <h1>Safeey security report</h1>
74
+ <div class="meta">Target <code>${esc(target)}</code> · ${result.filesScanned} files scanned · ${new Date().toISOString().slice(0, 10)}</div>
75
+ <div class="grade"><span class="badge ${g.letter}">${g.letter}</span><span class="meta">Risk score ${g.score}/100</span></div>
76
+ <div class="chips">${chips || '<span class="chip">no findings</span>'}</div>
77
+ </header>
78
+ ${body}
79
+ <footer>Generated by safeey-cli ${pkg.version}. Tip: use your browser's “Save as PDF” to export this report.</footer>
80
+ </div>
81
+ <script>
82
+ document.querySelectorAll('.fbtn').forEach(function(b){
83
+ b.addEventListener('click', function(){
84
+ document.querySelectorAll('.fbtn').forEach(function(x){x.classList.remove('active');});
85
+ b.classList.add('active');
86
+ var f = b.getAttribute('data-f');
87
+ document.querySelectorAll('tr[data-sev]').forEach(function(r){
88
+ r.style.display = (f === 'all' || r.getAttribute('data-sev') === f) ? '' : 'none';
89
+ });
90
+ });
91
+ });
92
+ </script>
93
+ </body></html>`;
94
+ }
95
+
96
+ function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch])); }
97
+ function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
98
+
99
+ module.exports = { toHtml };
@@ -0,0 +1,43 @@
1
+ // src/report-md.js — findings as Markdown (great for PRs / issues).
2
+ const SEV_ORDER = ["critical", "high", "medium", "low", "info"];
3
+
4
+ function toMarkdown(result, target, findings) {
5
+ const g = result.grade;
6
+ const out = [];
7
+ out.push(`# Safeey security report`);
8
+ out.push("");
9
+ out.push(`**Target:** \`${target}\` · **Files scanned:** ${result.filesScanned} · **Grade:** ${g.letter} (${g.score}/100)`);
10
+ out.push("");
11
+ out.push(countsLine(g.counts));
12
+ out.push("");
13
+
14
+ if (!findings.length) { out.push("_No issues to report._"); return out.join("\n") + "\n"; }
15
+
16
+ const bySev = groupBy(findings, (f) => f.severity);
17
+ for (const sev of SEV_ORDER) {
18
+ const list = bySev[sev];
19
+ if (!list || !list.length) continue;
20
+ out.push(`## ${cap(sev)} (${list.length})`);
21
+ out.push("");
22
+ out.push("| File | Rule | Issue | Fix |");
23
+ out.push("| --- | --- | --- | --- |");
24
+ for (const f of list) {
25
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
26
+ out.push(`| \`${loc}\` | ${f.ruleId} | ${md(f.title)} | ${md(f.fix)} |`);
27
+ }
28
+ out.push("");
29
+ }
30
+ out.push(`<sub>Generated by safeey-cli</sub>`);
31
+ return out.join("\n") + "\n";
32
+ }
33
+
34
+ function countsLine(counts) {
35
+ const parts = [];
36
+ for (const s of SEV_ORDER) if (counts[s]) parts.push(`${counts[s]} ${s}`);
37
+ return parts.length ? parts.join(" · ") : "no findings";
38
+ }
39
+ function groupBy(arr, fn) { const o = {}; for (const x of arr) (o[fn(x)] ||= []).push(x); return o; }
40
+ function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
41
+ function md(s) { return String(s || "").replace(/\|/g, "\\|").replace(/\n/g, " "); }
42
+
43
+ module.exports = { toMarkdown };
package/src/report.js CHANGED
@@ -49,4 +49,42 @@ function printReport({ findings, filesScanned, grade }, target) {
49
49
 
50
50
  function underlineSafe(s) { return useColor ? `\x1b[4m${s}\x1b[24m` : s; }
51
51
 
52
- module.exports = { printReport };
52
+ // Compact rollup for big scans: severity counts, worst files, top rules.
53
+ function printSummary({ findings, filesScanned, grade }, target) {
54
+ const g = grade;
55
+ console.log("");
56
+ console.log(bold(` Safeey CLI`) + dim(` · scanned ${filesScanned} file(s) in ${target}`));
57
+ console.log("");
58
+
59
+ if (!findings.length) { console.log(" " + green("✔ No issues found.") + dim(" (Grade A)")); console.log(""); return; }
60
+
61
+ const { counts } = g;
62
+ const parts = [];
63
+ if (counts.critical) parts.push(red(`${counts.critical} critical`));
64
+ if (counts.high) parts.push(red(`${counts.high} high`));
65
+ if (counts.medium) parts.push(yellow(`${counts.medium} medium`));
66
+ if (counts.low) parts.push(blue(`${counts.low} low`));
67
+ if (counts.info) parts.push(dim(`${counts.info} info`));
68
+ console.log(` ${bold("Risk grade")} ${(GRADE_STYLE[g.letter] || dim)(bold(g.letter))} ${dim(`(${g.score}/100)`)} ${parts.join(dim(" · "))}`);
69
+ console.log("");
70
+
71
+ const topFiles = tally(findings, (f) => f.file).slice(0, 10);
72
+ const topRules = tally(findings, (f) => f.ruleId).slice(0, 10);
73
+
74
+ console.log(" " + bold("Most affected files"));
75
+ for (const [file, n] of topFiles) console.log(` ${dim(String(n).padStart(4))} ${file}`);
76
+ console.log("");
77
+ console.log(" " + bold("Most common issues"));
78
+ for (const [rule, n] of topRules) console.log(` ${dim(String(n).padStart(4))} ${rule}`);
79
+ console.log("");
80
+ console.log(" " + dim(`${findings.length} findings total. Write the full list with `) + cyan("-o report.html") + dim(" (or .csv / .md)."));
81
+ console.log("");
82
+ }
83
+
84
+ function tally(findings, keyFn) {
85
+ const m = new Map();
86
+ for (const f of findings) m.set(keyFn(f), (m.get(keyFn(f)) || 0) + 1);
87
+ return [...m.entries()].sort((a, b) => b[1] - a[1]);
88
+ }
89
+
90
+ module.exports = { printReport, printSummary };