safeey-cli 0.2.1 → 0.6.1

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/bin/safeey.js CHANGED
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // bin/safeey.js — Safeey CLI entry point.
3
- const { scan, SEV_ORDER } = require("../src/scan");
3
+ const { scan, grade, SEV_ORDER } = require("../src/scan");
4
4
  const { printReport } = require("../src/report");
5
+ const { toSarif } = require("../src/report-sarif");
5
6
  const { getConfig, setConfig, CONFIG_PATH } = require("../src/config");
6
7
  const { syncResults } = require("../src/sync");
8
+ const { loadBaseline, writeBaseline, filterNew } = require("../src/baseline");
9
+ const auth = require("../src/auth");
10
+ const { Spinner, attachProgress } = require("../src/spinner");
11
+ const { planFixes, planEdits, applyEdits } = require("../src/fix");
12
+ const readline = require("readline");
7
13
 
8
14
  const pkg = require("../package.json");
9
15
 
@@ -12,32 +18,66 @@ Safeey CLI ${pkg.version} — scan your source code for security issues.
12
18
 
13
19
  Usage
14
20
  safeey scan [path] Scan a directory or file (default: current dir)
15
- safeey login <api-key> Save your Safeey API key (for --sync)
16
- safeey logout Remove the saved API key
21
+ safeey fix [path] Scan, then apply safe fixes (paid plan)
22
+ safeey login Sign in / sign up in your browser
23
+ safeey logout Sign out (removes the key from this machine)
24
+ safeey whoami Show the signed-in account
25
+
26
+ Account
27
+ Safeey is free but requires a one-time registration so we know who's using it.
28
+ Register once, then you're signed in on this machine until you log out.
17
29
 
18
30
  Options
19
- --sync Send results to your Safeey dashboard (code stays local)
20
- --json Output findings as JSON
21
31
  --fail-on <severity> Exit non-zero if any finding >= severity
22
32
  (critical|high|medium|low|off; default: high)
33
+ --confidence <level> Only show findings at/above confidence
34
+ (low|medium|high; default: low)
35
+ --osv Check dependencies against known CVEs (OSV.dev; network)
36
+ --history Scan git history for committed secrets
23
37
  --no-secrets Skip secret detection
38
+ --json Output findings as JSON
39
+ --sarif Output findings as SARIF 2.1.0 (for GitHub code scanning)
40
+ --baseline <file> Only report findings not present in <file>
41
+ --update-baseline <file> Write the current findings to <file> as the baseline
42
+ --no-fix Don't offer to auto-fix after a scan
43
+ --dry-run With fix: preview changes without writing
44
+ -y, --yes With fix: apply without the confirm prompt
45
+ --sync Send results to your Safeey dashboard (code stays local)
24
46
  -h, --help Show this help
25
47
  -v, --version Show version
26
48
 
49
+ Suppress a finding inline:
50
+ riskyCall() // safeey-ignore <rule-id>
51
+ // safeey-ignore-next-line <rule-id>
52
+
27
53
  Examples
28
- safeey scan ./
29
- safeey scan ./src --fail-on critical
30
- safeey login sk_safeey_xxx && safeey scan . --sync
54
+ safeey register
55
+ safeey scan ./ --osv
56
+ safeey scan ./src --fail-on critical --confidence high
57
+ safeey scan . --sarif > safeey.sarif
58
+ safeey fix . --dry-run
31
59
  `;
32
60
 
33
61
  function parseArgs(argv) {
34
- const args = { _: [], failOn: "high", json: false, secrets: true, sync: false };
62
+ const args = { _: [], failOn: "high", json: false, sarif: false, secrets: true, sync: false, osv: false, history: false, confidence: null, baseline: null, updateBaseline: null };
35
63
  for (let i = 0; i < argv.length; i++) {
36
64
  const a = argv[i];
37
65
  if (a === "--json") args.json = true;
66
+ else if (a === "--sarif") args.sarif = true;
38
67
  else if (a === "--sync") args.sync = true;
68
+ else if (a === "--osv") args.osv = true;
69
+ else if (a === "--history") args.history = true;
39
70
  else if (a === "--no-secrets") args.secrets = false;
40
71
  else if (a === "--fail-on") args.failOn = (argv[++i] || "high").toLowerCase();
72
+ else if (a === "--confidence") args.confidence = (argv[++i] || "low").toLowerCase();
73
+ else if (a === "--baseline") args.baseline = argv[++i];
74
+ else if (a === "--update-baseline") args.updateBaseline = argv[++i];
75
+ else if (a === "--email") args.email = argv[++i];
76
+ else if (a === "--name") args.name = argv[++i];
77
+ else if (a === "--key") args.key = argv[++i];
78
+ else if (a === "--no-fix") args.noFix = true;
79
+ else if (a === "--yes" || a === "-y") args.yes = true;
80
+ else if (a === "--dry-run") args.dryRun = true;
41
81
  else if (a === "-h" || a === "--help") args.help = true;
42
82
  else if (a === "-v" || a === "--version") args.version = true;
43
83
  else args._.push(a);
@@ -52,33 +92,101 @@ async function main() {
52
92
 
53
93
  const command = args._[0];
54
94
 
95
+ // ---- account commands --------------------------------------------------
96
+ if (command === "register" || command === "signup") {
97
+ const r = await auth.register({ email: args.email, name: args.name });
98
+ console.log((r.ok ? "\n " : "\n \u2717 ") + r.message + "\n");
99
+ process.exit(r.ok ? 0 : 1);
100
+ }
55
101
  if (command === "login") {
56
- const key = args._[1];
57
- if (!key) { console.error("Usage: safeey login <api-key>"); process.exit(2); }
58
- setConfig({ apiKey: key });
59
- console.log(`Saved. API key stored in ${CONFIG_PATH}`);
60
- return;
102
+ const r = await auth.login({ keyArg: args._[1], email: args.email, key: args.key });
103
+ console.log((r.ok ? "\n \u2714 " : "\n \u2717 ") + r.message + "\n");
104
+ process.exit(r.ok ? 0 : 1);
61
105
  }
62
106
  if (command === "logout") {
63
- setConfig({ apiKey: null });
64
- console.log("Logged out. API key removed.");
65
- return;
107
+ const r = auth.logout();
108
+ console.log("\n " + r.message + "\n");
109
+ process.exit(0);
66
110
  }
111
+ if (command === "whoami") {
112
+ const r = await auth.whoami();
113
+ console.log((r.ok ? "\n " : "\n \u2717 ") + r.message + "\n");
114
+ process.exit(r.ok ? 0 : 1);
115
+ }
116
+
117
+ const isFixCmd = command === "fix";
118
+ const target = ((command === "scan" || isFixCmd) ? args._[1] : command) || ".";
119
+
120
+ // Account gate: Safeey is free but requires registration + login.
121
+ const gate = await auth.requireAuth();
122
+ if (!gate.allowed) {
123
+ console.error("\n " + gate.message + "\n");
124
+ process.exit(3);
125
+ }
126
+ if (gate.warn) console.error(`\n \u26a0 ${gate.warn}\n`);
67
127
 
68
- const target = (command === "scan" ? args._[1] : command) || ".";
128
+ // Progress UX only for human-facing runs (skip for --json/--sarif so piped
129
+ // output isn't accompanied by spinner chatter).
130
+ const quiet = args.json || args.sarif;
131
+ const spinner = quiet ? null : new Spinner();
132
+ if (spinner) spinner.begin("Starting scan\u2026");
69
133
 
70
134
  let result;
71
135
  try {
72
- result = scan(target, { secrets: args.secrets });
136
+ result = await scan(target, {
137
+ secrets: args.secrets,
138
+ osv: args.osv || isFixCmd, // fixing needs OSV to know which deps to bump
139
+ history: args.history,
140
+ confidence: args.confidence,
141
+ onProgress: spinner ? attachProgress(spinner) : undefined,
142
+ });
73
143
  } catch (e) {
144
+ if (spinner) spinner.stop();
74
145
  console.error("Error:", e.message);
75
146
  process.exit(2);
76
147
  }
148
+ if (spinner) spinner.done(`Scanned ${result.filesScanned} file${result.filesScanned === 1 ? "" : "s"}`);
149
+
150
+ // Baseline: write mode, or filter mode.
151
+ if (args.updateBaseline) {
152
+ const n = writeBaseline(args.updateBaseline, result.findings);
153
+ console.log(`Baseline written to ${args.updateBaseline} (${n} findings snapshotted).`);
154
+ return;
155
+ }
156
+ if (args.baseline) {
157
+ const base = loadBaseline(args.baseline);
158
+ if (!base) {
159
+ console.error(`Baseline not found or invalid: ${args.baseline} (run --update-baseline first)`);
160
+ process.exit(2);
161
+ }
162
+ result.findings = filterNew(result.findings, base);
163
+ result.grade = grade(result.findings);
164
+ }
77
165
 
78
- if (args.json) {
166
+ if (args.sarif) {
167
+ console.log(toSarif(result, target));
168
+ } else if (args.json) {
79
169
  console.log(JSON.stringify({ target, ...result, SEV_ORDER: undefined }, null, 2));
80
170
  } else {
81
171
  printReport(result, target);
172
+ if (result.meta && result.meta.osv && result.meta.osv.error) console.log(` \u26a0 OSV check skipped: ${result.meta.osv.error}\n`);
173
+ if (result.meta && result.meta.history && result.meta.history.error) console.log(` \u26a0 History scan skipped: ${result.meta.history.error}\n`);
174
+ }
175
+
176
+ // ---- offer to auto-fix (paid) ------------------------------------------
177
+ // On a human-facing run with findings, ask whether Safeey should fix them.
178
+ // 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) {
181
+ const { fixable } = planFixes(result.findings);
182
+ let proceed = false;
183
+ if (isFixCmd) {
184
+ proceed = true;
185
+ } else if (process.stdin.isTTY && !args.noFix && fixable.length) {
186
+ const ans = await ask(` Safeey can auto-fix ${fixable.length} of ${result.findings.length} issue${result.findings.length === 1 ? "" : "s"} for you. Fix them now? (paid plan) [y/N] `);
187
+ proceed = /^y(es)?$/i.test(ans.trim());
188
+ }
189
+ if (proceed) await runFix(result, target, args, fixable);
82
190
  }
83
191
 
84
192
  if (args.sync) {
@@ -95,4 +203,50 @@ async function main() {
95
203
  process.exit(0);
96
204
  }
97
205
 
206
+ // Interactive yes/no (and free-text) prompt on stdin.
207
+ function ask(question) {
208
+ return new Promise((resolve) => {
209
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
210
+ rl.question("\n" + question, (a) => { rl.close(); resolve(a || ""); });
211
+ });
212
+ }
213
+
214
+ // Plan-gated fix flow: verify the plan, preview the mechanical changes, confirm,
215
+ // apply. Free users get an upgrade nudge; the scan results stay theirs either way.
216
+ async function runFix(result, target, args, fixable) {
217
+ const plan = await auth.currentPlan({ refresh: true });
218
+ if (!auth.isPaid(plan)) {
219
+ const url = getConfig().apiUrl.replace(/\/$/, "") + "/upgrade";
220
+ console.log([
221
+ "",
222
+ " \u2728 Auto-fix is part of the paid plan.",
223
+ ` Upgrade to let Safeey apply these fixes for you: ${url}`,
224
+ " The scanner stays free — the results above are yours to keep.",
225
+ "",
226
+ ].join("\n"));
227
+ return;
228
+ }
229
+
230
+ const root = require("path").resolve(target);
231
+ const edits = planEdits(fixable, root);
232
+ if (!edits.length) {
233
+ console.log("\n Nothing to auto-apply — the fixable issues need no file change (or are already resolved).\n");
234
+ return;
235
+ }
236
+
237
+ console.log("\n Planned changes:");
238
+ for (const e of edits) console.log(` \u2022 ${e.file}: ${e.summary}${e.note ? ` (${e.note})` : ""}`);
239
+
240
+ if (args.dryRun) { console.log("\n Dry run \u2014 no files written.\n"); return; }
241
+
242
+ if (!args.yes) {
243
+ if (!process.stdin.isTTY) { console.log("\n Re-run with --yes to apply these changes.\n"); return; }
244
+ const c = await ask(" Apply these changes? [y/N] ");
245
+ if (!/^y(es)?$/i.test(c.trim())) { console.log(" Skipped. No files changed.\n"); return; }
246
+ }
247
+
248
+ const done = applyEdits(edits);
249
+ console.log(`\n \u2714 Applied ${done.length} fix${done.length === 1 ? "" : "es"}. Re-run \`safeey scan\` to confirm.\n`);
250
+ }
251
+
98
252
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safeey-cli",
3
- "version": "0.2.1",
3
+ "version": "0.6.1",
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"
@@ -36,7 +36,7 @@
36
36
  "url": "https://github.com/davolu/safeey-cli/issues"
37
37
  },
38
38
  "scripts": {
39
- "test": "node bin/safeey.js scan ./test-fixtures --fail-on off",
39
+ "test": "node test/run.js",
40
40
  "prepublishOnly": "node -e \"require('./src/scan')\""
41
41
  }
42
42
  }
package/src/auth.js ADDED
@@ -0,0 +1,232 @@
1
+ // src/auth.js — account gate for the CLI.
2
+ //
3
+ // Safeey is free, but requires a one-time registration + login so there's a
4
+ // record of who's using it. The flow mirrors the existing /api/cli/* + Bearer
5
+ // convention used by sync.js:
6
+ // POST /api/cli/register { email, name } -> { apiKey } (backend also emails it)
7
+ // POST /api/cli/login { email, apiKey } -> { apiKey, email, plan }
8
+ // GET /api/cli/me (Bearer apiKey) -> { email, plan }
9
+ //
10
+ // Storage: ~/.safeey/config.json (0600), same as config.js. We cache a
11
+ // verifiedAt so we only hit the network on fresh installs / periodically,
12
+ // keeping normal scans fast and usable offline once verified.
13
+
14
+ const readline = require("readline");
15
+ const { spawn } = require("child_process");
16
+ const { getConfig, setConfig, CONFIG_PATH } = require("./config");
17
+
18
+ const REVERIFY_MS = 30 * 24 * 60 * 60 * 1000; // re-check with the server monthly
19
+
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+
22
+ // Best-effort "open this URL in the user's browser". Never throws; if it can't
23
+ // open (headless, no opener), we've already printed the URL for manual use.
24
+ function openBrowser(url) {
25
+ if (process.env.SAFEEY_NO_BROWSER) return false;
26
+ const p = process.platform;
27
+ const cmd = p === "darwin" ? "open" : p === "win32" ? "cmd" : "xdg-open";
28
+ const args = p === "win32" ? ["/c", "start", "", url] : [url];
29
+ try {
30
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
31
+ child.on("error", () => {});
32
+ child.unref();
33
+ return true;
34
+ } catch { return false; }
35
+ }
36
+
37
+ function apiBase() { return getConfig().apiUrl; }
38
+
39
+ async function api(pathname, { method = "GET", token, body } = {}) {
40
+ const headers = { "Content-Type": "application/json" };
41
+ if (token) headers.Authorization = `Bearer ${token}`;
42
+ const res = await fetch(`${apiBase()}${pathname}`, {
43
+ method, headers, body: body ? JSON.stringify(body) : undefined,
44
+ });
45
+ let data = {};
46
+ try { data = await res.json(); } catch { /* non-JSON */ }
47
+ return { status: res.status, ok: res.ok, data };
48
+ }
49
+
50
+ // --- interactive prompts ----------------------------------------------------
51
+
52
+ function prompt(question, { mask = false } = {}) {
53
+ return new Promise((resolve) => {
54
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
55
+ if (mask) {
56
+ // Replace echoed characters with '*'.
57
+ const orig = rl._writeToOutput.bind(rl);
58
+ rl._writeToOutput = (str) => {
59
+ if (str.includes(question)) return orig(str);
60
+ orig("*".repeat(str.length > 0 ? 1 : 0));
61
+ };
62
+ }
63
+ rl.question(question, (answer) => { rl.close(); if (mask) process.stdout.write("\n"); resolve(answer.trim()); });
64
+ });
65
+ }
66
+
67
+ function isTTY() { return process.stdin.isTTY && process.stdout.isTTY; }
68
+
69
+ // --- commands ---------------------------------------------------------------
70
+
71
+ // Browser/device flow (like `gh auth login`): we ask the server to start a
72
+ // device authorization, show the user a short code + URL, they sign in / sign
73
+ // up on the web (that's where the email/lead is captured), and we poll until
74
+ // the server hands back an API key. Both `register` and `login` use this — the
75
+ // web page decides whether it's a new signup or a returning user.
76
+ async function deviceLogin() {
77
+ if (!isTTY() && !process.env.SAFEEY_NO_BROWSER) {
78
+ return fail("Interactive sign-in needs a terminal.\n In CI, set SAFEEY_API_KEY to a key from your Safeey dashboard instead.");
79
+ }
80
+
81
+ let start;
82
+ try {
83
+ start = await api("/api/cli/device/start", {
84
+ method: "POST",
85
+ body: { client: "safeey-cli", version: require("../package.json").version },
86
+ });
87
+ } catch (e) { return fail(`Couldn't reach ${apiBase()}: ${e.message}`); }
88
+ if (!start.ok) return fail(start.data.message || `Couldn't start sign-in (HTTP ${start.status}).`);
89
+
90
+ const d = start.data;
91
+ const deviceCode = d.device_code || d.deviceCode;
92
+ const userCode = d.user_code || d.userCode;
93
+ const verifyUri = d.verification_uri || d.verificationUri;
94
+ const verifyComplete = d.verification_uri_complete || d.verificationUriComplete || verifyUri;
95
+ let interval = Math.max(1, d.interval || 5) * 1000;
96
+ const expiresAt = Date.now() + (d.expires_in || d.expiresIn || 900) * 1000;
97
+ if (!deviceCode || !userCode || !verifyUri) return fail("The server's sign-in response was incomplete.");
98
+
99
+ console.log("\n Sign in to Safeey to continue.\n");
100
+ console.log(` 1. Open: ${verifyComplete}`);
101
+ console.log(` 2. Enter code: ${userCode}\n`);
102
+ if (openBrowser(verifyComplete)) console.log(" (opened your browser)\n");
103
+ process.stdout.write(" Waiting for you to finish in the browser\u2026");
104
+
105
+ while (Date.now() < expiresAt) {
106
+ await sleep(interval);
107
+ let r;
108
+ try { r = await api("/api/cli/device/poll", { method: "POST", body: { device_code: deviceCode } }); }
109
+ catch { continue; } // transient network blip — keep polling
110
+ const status = (r.data && (r.data.status || r.data.error)) || (r.ok && r.data.apiKey ? "complete" : "pending");
111
+
112
+ if (status === "complete" || (r.ok && r.data.apiKey)) {
113
+ process.stdout.write("\n");
114
+ setConfig({ apiKey: r.data.apiKey, email: r.data.email || null, plan: r.data.plan || "free", verifiedAt: Date.now() });
115
+ return ok(`Signed in${r.data.email ? ` as ${r.data.email}` : ""}. You're all set — run \`safeey scan .\`.`);
116
+ }
117
+ if (status === "slow_down") { interval += 2000; process.stdout.write("."); continue; }
118
+ if (status === "denied" || status === "access_denied") { process.stdout.write("\n"); return fail("Sign-in was denied in the browser."); }
119
+ if (status === "expired" || status === "expired_token") break;
120
+ process.stdout.write("."); // pending
121
+ }
122
+ process.stdout.write("\n");
123
+ return fail("Sign-in timed out. Run `safeey login` to try again.");
124
+ }
125
+
126
+ function register() { return deviceLogin(); }
127
+
128
+ async function login({ keyArg, key } = {}) {
129
+ // Scriptable/CI fallback: `safeey login <api-key>` or --key stores a key
130
+ // directly (also available via the SAFEEY_API_KEY env var).
131
+ const directKey = keyArg || key;
132
+ if (directKey) {
133
+ const v = await verifyToken(directKey);
134
+ if (v.status === 401) return fail("That key is invalid. Run `safeey login` to sign in via your browser.");
135
+ if (v.networkError) {
136
+ setConfig({ apiKey: directKey, verifiedAt: null });
137
+ return ok("Key saved (couldn't reach the server to verify right now — it'll verify on your next scan).");
138
+ }
139
+ setConfig({ apiKey: directKey, email: v.email || undefined, plan: v.plan || "free", verifiedAt: Date.now() });
140
+ return ok(`Logged in${v.email ? ` as ${v.email}` : ""}.`);
141
+ }
142
+ return deviceLogin();
143
+ }
144
+
145
+ function logout() {
146
+ setConfig({ apiKey: null, email: null, verifiedAt: null });
147
+ return ok("Logged out. Your CLI key was removed from this machine.");
148
+ }
149
+
150
+ async function whoami() {
151
+ const { apiKey, email } = getConfig();
152
+ if (!apiKey) return fail("Not logged in. Run `safeey login` (or `safeey register` for a free account).");
153
+ const v = await verifyToken(apiKey);
154
+ if (v.status === 401) return fail("Your saved key is no longer valid. Run `safeey login` again.");
155
+ if (v.networkError) return ok(`${email || "logged in"} (offline — couldn't reach the server to confirm).`);
156
+ return ok(`${v.email || email || "logged in"}${v.plan ? ` · plan: ${v.plan}` : ""}`);
157
+ }
158
+
159
+ // --- verification + gate ----------------------------------------------------
160
+
161
+ async function verifyToken(token) {
162
+ try {
163
+ const r = await api("/api/cli/me", { token });
164
+ return { status: r.status, ok: r.ok, email: r.data.email, plan: r.data.plan, networkError: false };
165
+ } catch {
166
+ return { status: 0, ok: false, networkError: true };
167
+ }
168
+ }
169
+
170
+ // Returns { allowed: true } or { allowed: false, message }.
171
+ // Enforces: a key must exist; on a fresh install (or monthly) it's verified
172
+ // with the server. Invalid keys are blocked; transient network failures are
173
+ // tolerated once a key has previously verified (so offline use still works).
174
+ async function requireAuth() {
175
+ const cfg = getConfig();
176
+ if (!cfg.apiKey) {
177
+ return { allowed: false, message: authWall() };
178
+ }
179
+
180
+ const fresh = !cfg.verifiedAt;
181
+ const stale = cfg.verifiedAt && (Date.now() - cfg.verifiedAt) > REVERIFY_MS;
182
+ if (!fresh && !stale) return { allowed: true }; // fast path
183
+
184
+ const v = await verifyToken(cfg.apiKey);
185
+ if (v.status === 401) {
186
+ return { allowed: false, message: "Your Safeey key is invalid or expired.\n Run `safeey login` to sign in again." };
187
+ }
188
+ if (v.networkError) {
189
+ if (fresh) {
190
+ // Never verified and offline: allow, but let them know.
191
+ return { allowed: true, warn: "Couldn't reach Safeey to verify your account — continuing offline. It'll verify on your next online scan." };
192
+ }
193
+ return { allowed: true }; // previously verified, just offline now
194
+ }
195
+ setConfig({ verifiedAt: Date.now(), email: v.email || cfg.email, plan: v.plan || cfg.plan || "free" });
196
+ return { allowed: true };
197
+ }
198
+
199
+ // Is this plan a paid tier? Anything that isn't null/"free"/"none".
200
+ function isPaid(plan) { return !!plan && !/^(free|none)$/i.test(plan); }
201
+
202
+ // Current plan for gating paid features. With refresh, re-checks the server
203
+ // (so a just-purchased upgrade is picked up); otherwise uses the cached value.
204
+ async function currentPlan({ refresh = false } = {}) {
205
+ const cfg = getConfig();
206
+ if (refresh && cfg.apiKey) {
207
+ const v = await verifyToken(cfg.apiKey);
208
+ if (!v.networkError && v.status !== 401) {
209
+ setConfig({ plan: v.plan || "free", verifiedAt: Date.now() });
210
+ return v.plan || "free";
211
+ }
212
+ }
213
+ return cfg.plan || "free";
214
+ }
215
+
216
+ function authWall() {
217
+ return [
218
+ "You need a (free) Safeey account to use the scanner.",
219
+ "",
220
+ " Run safeey login to sign in or sign up in your browser.",
221
+ "",
222
+ "Safeey stays free; signing in just lets us know who's using it.",
223
+ ].join("\n");
224
+ }
225
+
226
+ // --- helpers ----------------------------------------------------------------
227
+
228
+ function validEmail(e) { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e); }
229
+ function ok(message) { return { ok: true, message }; }
230
+ function fail(message) { return { ok: false, message }; }
231
+
232
+ module.exports = { register, login, logout, whoami, requireAuth, verifyToken, currentPlan, isPaid };
@@ -0,0 +1,42 @@
1
+ // src/baseline.js — "show only findings that are new since a snapshot".
2
+ // On a large existing codebase you don't want the first scan to dump 500
3
+ // findings; you want to accept today's state as the baseline and be alerted only
4
+ // to *new* problems. `--update-baseline` writes the snapshot; `--baseline`
5
+ // filters against it.
6
+ //
7
+ // Fingerprint note: we key on ruleId + file + a normalized line. Line numbers do
8
+ // shift as code moves, so a moved-but-unchanged finding can re-surface as "new".
9
+ // That's the standard limitation of line-based baselines; a content-hash
10
+ // fingerprint would be more stable and is a fair future improvement.
11
+
12
+ const fs = require("fs");
13
+
14
+ function fingerprint(f) {
15
+ return `${f.ruleId}|${f.file}|${f.line}|${f.column || 0}`;
16
+ }
17
+
18
+ function loadBaseline(path) {
19
+ try {
20
+ const data = JSON.parse(fs.readFileSync(path, "utf8"));
21
+ return new Set(Array.isArray(data.fingerprints) ? data.fingerprints : []);
22
+ } catch {
23
+ return null; // missing/invalid — caller decides what to do
24
+ }
25
+ }
26
+
27
+ function writeBaseline(path, findings) {
28
+ const payload = {
29
+ version: 1,
30
+ generatedAt: new Date().toISOString(),
31
+ fingerprints: [...new Set(findings.map(fingerprint))].sort(),
32
+ };
33
+ fs.writeFileSync(path, JSON.stringify(payload, null, 2) + "\n");
34
+ return payload.fingerprints.length;
35
+ }
36
+
37
+ // Keep only findings not present in the baseline set.
38
+ function filterNew(findings, baselineSet) {
39
+ return findings.filter((f) => !baselineSet.has(fingerprint(f)));
40
+ }
41
+
42
+ module.exports = { fingerprint, loadBaseline, writeBaseline, filterNew };
package/src/config.js CHANGED
@@ -14,6 +14,7 @@ function read() {
14
14
  function getConfig() {
15
15
  const file = read();
16
16
  return {
17
+ ...file,
17
18
  apiKey: process.env.SAFEEY_API_KEY || file.apiKey || null,
18
19
  apiUrl: process.env.SAFEEY_API_URL || file.apiUrl || DEFAULT_API_URL,
19
20
  };
@@ -0,0 +1,84 @@
1
+ // src/engine/githistory.js — scan a repo's git history for secrets.
2
+ //
3
+ // A secret deleted from the current tree is still sitting in history and is
4
+ // still compromised — anyone with the repo can recover it. The normal scan only
5
+ // sees the working tree; this walks every historical blob. Opt-in (`--history`)
6
+ // because it's slower and only meaningful inside a git repo.
7
+
8
+ const { execFileSync } = require("child_process");
9
+ const { analyzeSecrets } = require("./secrets");
10
+
11
+ function git(root, args, opts = {}) {
12
+ return execFileSync("git", ["-C", root, ...args], {
13
+ encoding: opts.encoding ?? "utf8",
14
+ maxBuffer: opts.maxBuffer ?? 128 * 1024 * 1024,
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ });
17
+ }
18
+
19
+ const TEXTISH = /\.(js|jsx|ts|tsx|mjs|cjs|json|env|ya?ml|py|rb|go|php|java|txt|sh|config|ini|properties|xml|tf)$/i;
20
+ const ENVISH = /(^|\/)\.env(\.|$)/i;
21
+
22
+ function analyzeGitHistory(root, { maxBlobs = 3000 } = {}) {
23
+ try {
24
+ if (git(root, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
25
+ return { findings: [], scannedBlobs: 0, error: "not a git repository" };
26
+ }
27
+ } catch {
28
+ return { findings: [], scannedBlobs: 0, error: "git unavailable" };
29
+ }
30
+
31
+ let listing;
32
+ try { listing = git(root, ["rev-list", "--all", "--objects"]); }
33
+ catch (e) { return { findings: [], scannedBlobs: 0, error: e.message }; }
34
+
35
+ // "<sha> <path>" lines; entries without a path are commits/trees — skip.
36
+ const blobs = new Map(); // sha -> path (first seen)
37
+ for (const line of listing.split("\n")) {
38
+ const sp = line.indexOf(" ");
39
+ if (sp === -1) continue;
40
+ const sha = line.slice(0, sp);
41
+ const p = line.slice(sp + 1).trim();
42
+ if (!p) continue;
43
+ if (!TEXTISH.test(p) && !ENVISH.test(p) && !/(^|\/)Dockerfile$/i.test(p)) continue;
44
+ if (!blobs.has(sha)) blobs.set(sha, p);
45
+ }
46
+
47
+ // Which blobs are still in HEAD? (best-effort; older git may lack --format)
48
+ const currentShas = new Set();
49
+ try {
50
+ for (const s of git(root, ["ls-tree", "-r", "HEAD", "--format=%(objectname)"]).split("\n")) {
51
+ if (s.trim()) currentShas.add(s.trim());
52
+ }
53
+ } catch { /* skip */ }
54
+
55
+ const findings = [];
56
+ const seen = new Set();
57
+ let scanned = 0;
58
+ for (const [sha, p] of blobs) {
59
+ if (scanned >= maxBlobs) break;
60
+ let content;
61
+ try { content = git(root, ["cat-file", "blob", sha]); } catch { continue; }
62
+ if (content.length > 1_000_000) continue;
63
+ scanned++;
64
+ for (const h of analyzeSecrets(content, p)) {
65
+ const key = `${p}|${h.ruleId}`;
66
+ if (seen.has(key)) continue;
67
+ seen.add(key);
68
+ const inCurrent = currentShas.has(sha);
69
+ findings.push({
70
+ ...h,
71
+ ruleId: `history/${h.ruleId}`,
72
+ title: `${h.title} in git history`,
73
+ line: 0,
74
+ column: 0,
75
+ confidence: "high",
76
+ message: `${h.title} found in git history at ${p} (blob ${sha.slice(0, 10)})${inCurrent ? "" : " — no longer in the current tree, but still recoverable from history"}. A committed secret stays compromised even after it's deleted.`,
77
+ fix: "Rotate this credential now. Then purge it from history with git filter-repo or BFG and force-push — deleting the file in a later commit does not remove it from history.",
78
+ });
79
+ }
80
+ }
81
+ return { findings, scannedBlobs: scanned, error: null };
82
+ }
83
+
84
+ module.exports = { analyzeGitHistory };