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 +75 -12
- package/bin/safeey.js +97 -11
- package/package.json +1 -1
- package/src/report-csv.js +18 -0
- package/src/report-html.js +99 -0
- package/src/report-md.js +43 -0
- package/src/report.js +39 -1
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
|
|
30
|
-
|
|
31
|
-
|
|
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 (
|
|
34
|
-
|
|
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
|
|
52
|
-
|
|
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
|
-
-
|
|
57
|
-
-
|
|
119
|
+
- Deeper language coverage via tree-sitter grammars
|
|
120
|
+
- Interprocedural taint tracking
|
|
58
121
|
- Optional AI layer to explain findings and suggest patches
|
|
59
|
-
-
|
|
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
|
|
57
|
-
safeey scan . --
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
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
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch])); }
|
|
97
|
+
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
|
98
|
+
|
|
99
|
+
module.exports = { toHtml };
|
package/src/report-md.js
ADDED
|
@@ -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
|
-
|
|
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 };
|