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 +174 -20
- package/package.json +2 -2
- package/src/auth.js +232 -0
- package/src/baseline.js +42 -0
- package/src/config.js +1 -0
- package/src/engine/githistory.js +84 -0
- package/src/engine/infra.js +46 -0
- package/src/engine/osv.js +173 -0
- package/src/engine/patterns.js +2 -1
- package/src/engine/secrets.js +37 -1
- package/src/engine/suppress.js +50 -0
- package/src/engine/taint.js +182 -0
- package/src/fix.js +116 -0
- package/src/report-sarif.js +67 -0
- package/src/rules/framework.js +102 -0
- package/src/rules/infra.js +120 -0
- package/src/rules/js-rules.js +114 -0
- package/src/scan.js +88 -29
- package/src/spinner.js +97 -0
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
|
|
16
|
-
safeey
|
|
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
|
|
29
|
-
safeey scan ./
|
|
30
|
-
safeey
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
console.log("
|
|
65
|
-
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
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.
|
|
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
|
|
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 };
|
package/src/baseline.js
ADDED
|
@@ -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
|
@@ -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 };
|