safeey-cli 0.2.0 → 0.6.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/src/scan.js CHANGED
@@ -5,20 +5,73 @@ const { analyzeJs } = require("./engine/js");
5
5
  const { analyzeSecrets } = require("./engine/secrets");
6
6
  const { analyzeDeps } = require("./engine/deps");
7
7
  const { analyzePatterns } = require("./engine/patterns");
8
+ const { analyzeTaint } = require("./engine/taint");
9
+ const { analyzeInfra } = require("./engine/infra");
10
+ const { makeIgnoreChecker } = require("./engine/gitignore");
11
+ const { fileSuppressions, isSuppressed, loadPathIgnores } = require("./engine/suppress");
12
+ const { analyzeDependenciesOSV } = require("./engine/osv");
13
+ const { analyzeGitHistory } = require("./engine/githistory");
14
+
15
+ // Any .env-style file. `.env.example` / `.env.sample` / `.env.template` / .dist
16
+ // are meant to be committed and hold placeholders, so they're scanned normally
17
+ // (a *real* secret in one of those IS a finding). The bare/real env files are
18
+ // the ones whose secrets we suppress-if-ignored, flag-if-not.
19
+ const ENV_RE = /^\.env(\.|$)/;
20
+ const ENV_EXAMPLE_RE = /(example|sample|template|dist|defaults)/i;
21
+
22
+ function classifyEnvFile(base) {
23
+ if (!ENV_RE.test(base)) return null;
24
+ return ENV_EXAMPLE_RE.test(base) ? "example" : "real";
25
+ }
26
+
27
+ // Does the file actually contain KEY=value assignments with a value? Keeps us
28
+ // from flagging an empty/placeholder .env.
29
+ function hasEnvAssignment(code) {
30
+ return /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*\S/m.test(code);
31
+ }
32
+
33
+ function envNotIgnoredFinding(rel, hasSecret, unknown) {
34
+ return {
35
+ ruleId: "config/env-not-ignored",
36
+ title: "Environment file not covered by .gitignore",
37
+ severity: hasSecret ? "high" : "medium",
38
+ confidence: "medium",
39
+ category: "secrets",
40
+ cwe: "CWE-538",
41
+ owasp: "A05:2021 Security Misconfiguration",
42
+ file: rel,
43
+ line: 0,
44
+ column: 0,
45
+ message: unknown
46
+ ? `${rel} holds environment values and no .gitignore was found to protect it. If this directory is (or becomes) a git repo, the file will be committed and any credentials in it leaked.`
47
+ : `${rel} holds environment values but isn't matched by any .gitignore rule, so it can be committed to version control and its credentials leaked.`,
48
+ fix: "Add a matching rule to .gitignore (e.g. `.env` or `.env*`, keeping an explicit `!.env.example`). If the file was already committed, rotate the credentials and purge it from git history (e.g. git filter-repo).",
49
+ };
50
+ }
8
51
 
9
52
  const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage", ".turbo", ".cache"]);
10
53
  const JS_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
11
- const TEXT_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".env", ".yml", ".yaml", ".py", ".rb", ".go", ".php", ".java", ".txt", ".sh", ".config"]);
54
+ const TEXT_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".env", ".yml", ".yaml", ".py", ".rb", ".go", ".php", ".java", ".txt", ".sh", ".config", ".tf"]);
12
55
  const MAX_FILE_BYTES = 1_500_000;
56
+ const CONF_RANK = { low: 1, medium: 2, high: 3 };
13
57
 
14
58
  const SEV_WEIGHT = { critical: 25, high: 12, medium: 5, low: 1, info: 0 };
15
59
  const SEV_ORDER = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
16
60
 
61
+ // Files that should be scanned even without a known text extension.
62
+ function isScannable(base, ext, envClass) {
63
+ if (TEXT_EXT.has(ext)) return true;
64
+ if (base === "package.json") return true;
65
+ if (envClass) return true;
66
+ if (/^Dockerfile($|\.)/i.test(base)) return true; // Dockerfile, Dockerfile.prod
67
+ return false;
68
+ }
69
+
17
70
  function walk(dir, files = []) {
18
71
  let entries = [];
19
72
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
20
73
  for (const e of entries) {
21
- if (e.name.startsWith(".") && e.name !== ".env") { if (e.isDirectory()) continue; }
74
+ if (e.name.startsWith(".") && e.name !== ".env" && !e.name.startsWith(".env")) { if (e.isDirectory() && e.name !== ".github") continue; }
22
75
  const full = path.join(dir, e.name);
23
76
  if (e.isDirectory()) { if (!IGNORE_DIRS.has(e.name)) walk(full, files); }
24
77
  else files.push(full);
@@ -35,14 +88,40 @@ function grade(findings) {
35
88
  return { score, letter, counts };
36
89
  }
37
90
 
38
- function scan(target, opts = {}) {
91
+ function dedupeSort(findings) {
92
+ const seen = new Set();
93
+ const unique = findings.filter((f) => {
94
+ const k = `${f.ruleId}|${f.file}|${f.line}|${f.column}`;
95
+ if (seen.has(k)) return false; seen.add(k); return true;
96
+ });
97
+ unique.sort((a, b) =>
98
+ (SEV_ORDER[b.severity] - SEV_ORDER[a.severity]) ||
99
+ a.file.localeCompare(b.file) || (a.line - b.line));
100
+ return unique;
101
+ }
102
+
103
+ async function scan(target, opts = {}) {
39
104
  const root = path.resolve(target);
40
105
  const stat = fs.existsSync(root) && fs.statSync(root);
41
106
  if (!stat) throw new Error(`Path not found: ${root}`);
42
107
 
43
108
  const files = stat.isDirectory() ? walk(root) : [root];
44
- const findings = [];
109
+ let findings = [];
45
110
  let scanned = 0;
111
+ const meta = {};
112
+ const report = typeof opts.onProgress === "function" ? opts.onProgress : () => {};
113
+ report({ phase: "discover", total: files.length });
114
+
115
+ // Decide whether a .env's secrets are safely ignored or exposed.
116
+ const ignore = makeIgnoreChecker(root);
117
+ // .safeeyignore: skip whole paths the user has opted out of.
118
+ const pathIgnored = loadPathIgnores(
119
+ (p) => { try { return fs.readFileSync(p, "utf8"); } catch { return null; } },
120
+ (name) => path.join(root, name)
121
+ );
122
+
123
+ const minConf = CONF_RANK[opts.confidence] || 0;
124
+ const keepConf = (f) => !minConf || (CONF_RANK[f.confidence || "medium"] >= minConf);
46
125
 
47
126
  for (const file of files) {
48
127
  const ext = path.extname(file).toLowerCase();
@@ -50,29 +129,66 @@ function scan(target, opts = {}) {
50
129
  let size = 0;
51
130
  try { size = fs.statSync(file).size; } catch { continue; }
52
131
  if (size > MAX_FILE_BYTES) continue;
53
- if (!TEXT_EXT.has(ext) && base !== "package.json" && base !== ".env") continue;
132
+ const envClass = classifyEnvFile(base);
133
+ if (!isScannable(base, ext, envClass)) continue;
134
+
135
+ const rel = path.relative(root, file) || base;
136
+ if (pathIgnored(rel)) continue;
54
137
 
55
138
  let code = "";
56
139
  try { code = fs.readFileSync(file, "utf8"); } catch { continue; }
57
140
  scanned++;
58
- const rel = path.relative(root, file) || base;
141
+ report({ phase: "scan", current: scanned, total: files.length, file: rel });
59
142
 
60
- if (JS_EXT.has(ext)) for (const f of analyzeJs(code, rel)) findings.push(f);
61
- if (base === "package.json") for (const f of analyzeDeps(code, rel)) findings.push(f);
62
- if (opts.secrets !== false) for (const f of analyzeSecrets(code, rel)) findings.push(f);
63
- // Regex/source-pattern rules run on every text file, gated by language tag.
64
- for (const f of analyzePatterns(code, rel, ext)) findings.push(f);
65
- }
143
+ // Collect this file's findings, then apply inline suppressions once.
144
+ const fileFindings = [];
66
145
 
67
- // De-duplicate identical findings (same rule at the same spot).
68
- const seen = new Set();
69
- const unique = findings.filter((f) => { const k = `${f.ruleId}|${f.file}|${f.line}|${f.column}`; if (seen.has(k)) return false; seen.add(k); return true; });
70
- findings.length = 0; findings.push(...unique);
146
+ if (envClass === "real") {
147
+ // A real .env is *meant* to hold secrets — the question is whether it's
148
+ // protected from being committed. Suppress when ignored; flag when not.
149
+ const secretFindings = opts.secrets !== false ? analyzeSecrets(code, rel) : [];
150
+ const ignored = ignore.isIgnored(file); // true | false | null(unknown)
151
+ if (ignored !== true && hasEnvAssignment(code)) {
152
+ fileFindings.push(envNotIgnoredFinding(rel, secretFindings.length > 0, ignored === null));
153
+ for (const f of secretFindings) fileFindings.push(f);
154
+ }
155
+ } else {
156
+ if (JS_EXT.has(ext)) {
157
+ for (const f of analyzeJs(code, rel)) fileFindings.push(f);
158
+ for (const f of analyzeTaint(code, rel)) fileFindings.push(f); // dataflow-backed
159
+ }
160
+ if (base === "package.json") for (const f of analyzeDeps(code, rel)) fileFindings.push(f);
161
+ if (opts.secrets !== false) for (const f of analyzeSecrets(code, rel)) fileFindings.push(f);
162
+ for (const f of analyzePatterns(code, rel, ext)) fileFindings.push(f); // + framework rules
163
+ for (const f of analyzeInfra(code, rel, base)) fileFindings.push(f); // docker/k8s/tf/gha
164
+ }
165
+
166
+ // Inline suppressions (// safeey-ignore ...) for this file.
167
+ const sup = fileSuppressions(code);
168
+ for (const f of fileFindings) {
169
+ if (f.line && isSuppressed(sup, f.line, f.ruleId)) continue;
170
+ if (!keepConf(f)) continue;
171
+ findings.push(f);
172
+ }
173
+ }
71
174
 
72
- // Sort worst-first, then by file/line.
73
- findings.sort((a, b) => (SEV_ORDER[b.severity] - SEV_ORDER[a.severity]) || a.file.localeCompare(b.file) || a.line - b.line);
175
+ // ---- opt-in engines that aren't per-file --------------------------------
176
+ if (opts.osv) {
177
+ report({ phase: "osv" });
178
+ const r = await analyzeDependenciesOSV(root);
179
+ meta.osv = { checked: r.checked, error: r.error };
180
+ for (const f of r.findings) if (keepConf(f)) findings.push(f);
181
+ }
182
+ if (opts.history) {
183
+ report({ phase: "history" });
184
+ const r = analyzeGitHistory(root);
185
+ meta.history = { scannedBlobs: r.scannedBlobs, error: r.error };
186
+ for (const f of r.findings) if (keepConf(f)) findings.push(f);
187
+ }
74
188
 
75
- return { findings, filesScanned: scanned, filesTotal: files.length, grade: grade(findings), SEV_ORDER };
189
+ report({ phase: "grade" });
190
+ findings = dedupeSort(findings);
191
+ return { findings, filesScanned: scanned, filesTotal: files.length, grade: grade(findings), meta, SEV_ORDER };
76
192
  }
77
193
 
78
- module.exports = { scan, grade, SEV_ORDER };
194
+ module.exports = { scan, grade, dedupeSort, SEV_ORDER };
package/src/spinner.js ADDED
@@ -0,0 +1,97 @@
1
+ // src/spinner.js — lightweight progress UX for the scan.
2
+ //
3
+ // Writes to stderr (so --json/--sarif on stdout stays clean), animates in place
4
+ // on a TTY, and degrades to plain one-line-per-phase output when not a TTY
5
+ // (e.g. CI logs). The goal is simply to carry the user along: show that files
6
+ // are being scanned, then dependencies, then history, then grading.
7
+
8
+ const FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
9
+ const GREEN = "\x1b[32m", DIM = "\x1b[2m", RESET = "\x1b[0m";
10
+
11
+ class Spinner {
12
+ constructor(stream = process.stderr) {
13
+ this.stream = stream;
14
+ this.tty = !!stream.isTTY;
15
+ this.text = "";
16
+ this.frame = 0;
17
+ this.timer = null;
18
+ this.start = Date.now();
19
+ this.lastPlain = "";
20
+ }
21
+
22
+ _render() {
23
+ if (!this.tty) return;
24
+ const f = FRAMES[(this.frame = (this.frame + 1) % FRAMES.length)];
25
+ this.stream.write(`\r\x1b[2K ${GREEN}${f}${RESET} ${this.text}`);
26
+ }
27
+
28
+ begin(text) {
29
+ this.text = text;
30
+ if (this.tty) {
31
+ if (!this.timer) this.timer = setInterval(() => this._render(), 80);
32
+ this._render();
33
+ } else {
34
+ this.stream.write(` ${text}\n`);
35
+ this.lastPlain = text;
36
+ }
37
+ return this;
38
+ }
39
+
40
+ // Update the current line's text (in place on a TTY; new line otherwise,
41
+ // but only when the headline phase changes, to avoid log spam).
42
+ update(text, { phaseChanged = false } = {}) {
43
+ this.text = text;
44
+ if (this.tty) this._render();
45
+ else if (phaseChanged && text !== this.lastPlain) { this.stream.write(` ${text}\n`); this.lastPlain = text; }
46
+ }
47
+
48
+ // Stop and erase the line (TTY), so the report/output prints cleanly after.
49
+ stop() {
50
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
51
+ if (this.tty) this.stream.write("\r\x1b[2K");
52
+ }
53
+
54
+ // Stop and leave a final check-marked line in place.
55
+ done(text) {
56
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
57
+ const secs = ((Date.now() - this.start) / 1000).toFixed(1);
58
+ if (this.tty) {
59
+ this.stream.write(`\r\x1b[2K ${GREEN}\u2714${RESET} ${text} ${DIM}(${secs}s)${RESET}\n`);
60
+ } else {
61
+ this.stream.write(` \u2714 ${text} (${secs}s)\n`);
62
+ }
63
+ }
64
+ }
65
+
66
+ // Build an onProgress handler for scan() that drives a spinner. Throttled so we
67
+ // don't repaint on every single file.
68
+ function attachProgress(spinner) {
69
+ let lastPaint = 0;
70
+ let curPhase = null;
71
+ return (ev) => {
72
+ const now = Date.now();
73
+ if (ev.phase !== curPhase) {
74
+ curPhase = ev.phase;
75
+ const headline = {
76
+ discover: "Discovering files\u2026",
77
+ scan: "Scanning source\u2026",
78
+ osv: "Checking dependencies against OSV.dev\u2026",
79
+ history: "Scanning git history for secrets\u2026",
80
+ grade: "Grading results\u2026",
81
+ }[ev.phase] || ev.phase;
82
+ spinner.update(headline, { phaseChanged: true });
83
+ lastPaint = now;
84
+ return;
85
+ }
86
+ if (ev.phase === "scan" && ev.total) {
87
+ if (now - lastPaint < 60) return; // throttle
88
+ lastPaint = now;
89
+ const name = ev.file ? ` ${DIM}${truncate(ev.file, 40)}${RESET}` : "";
90
+ spinner.update(`Scanning source ${ev.current}/${ev.total}\u2026${name}`);
91
+ }
92
+ };
93
+ }
94
+
95
+ function truncate(s, n) { return s.length <= n ? s : "\u2026" + s.slice(-(n - 1)); }
96
+
97
+ module.exports = { Spinner, attachProgress };