@coderook/cli 0.17.0 → 0.19.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.
@@ -0,0 +1,371 @@
1
+ "use strict";
2
+ /**
3
+ * Credentials recognised by what they are, not by what they are called.
4
+ *
5
+ * The existing check reads names: `.env`, `id_ed25519`, anything ending
6
+ * `.pem`. That catches the files a tool writes without being asked, which is
7
+ * where most accidental leaks come from — and it is completely blind to the
8
+ * other kind, where somebody pastes a key into `config.py` while getting
9
+ * something working and never takes it out. That file has an ordinary name,
10
+ * sits in an ordinary folder, and every check we had waved it through.
11
+ *
12
+ * So these patterns match the keys themselves. Each one is a format some
13
+ * service publishes and no ordinary text produces: a fixed prefix and a fixed
14
+ * length, not "a long random-looking string", which would flag every hash and
15
+ * teach people to ignore the warning. A check that cries wolf is worse than
16
+ * no check, because it trains the answer.
17
+ *
18
+ * Nothing here ever returns the secret. A finding is a file, a line, and what
19
+ * kind of key it is — enough to go and look, and safe to put in a log, a
20
+ * window, or a bug report.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.CREDENTIAL_PATTERNS = void 0;
24
+ exports.looksLikePlaceholder = looksLikePlaceholder;
25
+ exports.findCredentials = findCredentials;
26
+ exports.worthReading = worthReading;
27
+ exports.looksLikeText = looksLikeText;
28
+ /*
29
+ Ordered most specific first. `sk-ant-…` is also a match for the more general
30
+ `sk-…`, and whichever runs first should be the one that gets to name it —
31
+ the scan below drops later matches that overlap an earlier one, so this
32
+ order is what decides that "an Anthropic API key" beats "an OpenAI API key".
33
+ */
34
+ exports.CREDENTIAL_PATTERNS = [
35
+ {
36
+ id: "private-key",
37
+ name: "a private key",
38
+ /*
39
+ The header alone is not a key. Documentation writes it constantly —
40
+ `-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----` is how
41
+ every example shows the shape — so the match requires real key material
42
+ after it. Escaped newlines count as newlines, because a key embedded in
43
+ JSON or a JavaScript string is exactly how a service-account file
44
+ carries one, and that is the case worth catching.
45
+ */
46
+ match: /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----(?:\s|\\[rn])*[A-Za-z0-9+/=]{40,}/g,
47
+ },
48
+ {
49
+ id: "anthropic",
50
+ name: "an Anthropic API key",
51
+ match: /\bsk-ant-[A-Za-z0-9_-]{24,}/g,
52
+ },
53
+ {
54
+ id: "openai",
55
+ name: "an OpenAI API key",
56
+ match: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/g,
57
+ },
58
+ {
59
+ id: "aws-access-key",
60
+ name: "an AWS access key",
61
+ match: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g,
62
+ },
63
+ {
64
+ id: "azure-storage-key",
65
+ name: "an Azure storage key",
66
+ match: /AccountKey=[A-Za-z0-9+/]{80,}={0,2}/g,
67
+ },
68
+ {
69
+ id: "github-token",
70
+ name: "a GitHub token",
71
+ match: /\bgh[pousr]_[A-Za-z0-9]{36}\b/g,
72
+ },
73
+ {
74
+ id: "github-pat",
75
+ name: "a GitHub fine-grained token",
76
+ match: /\bgithub_pat_[A-Za-z0-9_]{22,}/g,
77
+ },
78
+ {
79
+ id: "gitlab-token",
80
+ name: "a GitLab token",
81
+ match: /\bglpat-[A-Za-z0-9_-]{20,}/g,
82
+ },
83
+ {
84
+ id: "google-api-key",
85
+ name: "a Google API key",
86
+ match: /\bAIza[0-9A-Za-z_-]{35}\b/g,
87
+ },
88
+ {
89
+ id: "slack-token",
90
+ name: "a Slack token",
91
+ match: /\bxox[baprs]-[0-9A-Za-z-]{10,}/g,
92
+ },
93
+ {
94
+ id: "stripe-live-key",
95
+ name: "a live Stripe key",
96
+ match: /\b[sr]k_live_[0-9A-Za-z]{20,}/g,
97
+ },
98
+ {
99
+ id: "sendgrid",
100
+ name: "a SendGrid key",
101
+ match: /\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{30,}/g,
102
+ },
103
+ {
104
+ id: "npm-token",
105
+ name: "an npm token",
106
+ match: /\bnpm_[A-Za-z0-9]{36}\b/g,
107
+ },
108
+ {
109
+ id: "planetscale",
110
+ name: "a PlanetScale credential",
111
+ match: /\bpscale_(?:tkn|pw|oauth)_[A-Za-z0-9_-]{32,}/g,
112
+ },
113
+ {
114
+ id: "huggingface",
115
+ name: "a Hugging Face token",
116
+ match: /\bhf_[A-Za-z0-9]{34,}/g,
117
+ },
118
+ {
119
+ id: "discord-bot-token",
120
+ name: "a Discord bot token",
121
+ match: /\b[MNO][A-Za-z0-9_-]{23,26}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}/g,
122
+ },
123
+ {
124
+ id: "twilio-sid",
125
+ name: "a Twilio account SID",
126
+ match: /\bAC[0-9a-f]{32}\b/g,
127
+ },
128
+ {
129
+ /*
130
+ The one that is a sentence rather than a token. A database URL with the
131
+ password still in it is how a whole database gets handed over, and it
132
+ does not look like a key at all — which is exactly why nothing caught
133
+ it before.
134
+ */
135
+ id: "database-url",
136
+ name: "a database password in a connection string",
137
+ /*
138
+ The host is part of the match on purpose. Documentation is full of
139
+ these, and what distinguishes a real one is not the password — which is
140
+ often literally the word "password" in both — but where it points.
141
+ Matching through the host lets the placeholder check see `localhost`
142
+ and `example.com` and stay quiet.
143
+ */
144
+ match: /\b(?:postgres|postgresql|mysql|mariadb|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/]+:[^\s:@/]+@[^\s/?#"']+/g,
145
+ },
146
+ ];
147
+ /*
148
+ Words that mean "this is the shape, not the secret".
149
+
150
+ Documentation is full of keys, and every one of them is fake. Flagging those
151
+ would put a warning on the README of every project that explains its own
152
+ configuration — and a warning that is usually wrong is a warning people
153
+ learn to click past, including the time it is right.
154
+ */
155
+ const PLACEHOLDER = [
156
+ "example",
157
+ "placeholder",
158
+ "your-",
159
+ "your_",
160
+ "yourkey",
161
+ "youraccount",
162
+ "changeme",
163
+ "change-me",
164
+ "redacted",
165
+ "dummy",
166
+ "sample",
167
+ "insert",
168
+ "replace",
169
+ "notreal",
170
+ "fake",
171
+ "test-key",
172
+ /*
173
+ Where a connection string points, when it points nowhere real. A database
174
+ URL aimed at the machine it is written on is somebody's own setup, not a
175
+ credential anybody else can use.
176
+ */
177
+ "localhost",
178
+ "127.0.0.1",
179
+ "0.0.0.0",
180
+ "host:port",
181
+ ":password@",
182
+ ":pass@",
183
+ ":secret@",
184
+ "xxxx",
185
+ "0000",
186
+ "1234567890",
187
+ "abcdef123456",
188
+ ];
189
+ /** Whether this looks like documentation rather than a live credential. */
190
+ function looksLikePlaceholder(value) {
191
+ const flat = value.toLowerCase();
192
+ if (PLACEHOLDER.some((word) => flat.includes(word)))
193
+ return true;
194
+ /*
195
+ A value the program builds at run time is not a value in the file. Code
196
+ that assembles a connection string from variables —
197
+ `postgresql://${user}:${password}@${host}` — is the ordinary way to do it
198
+ and holds no secret at all, so flagging it would put a warning on the
199
+ correct pattern and none on the wrong one.
200
+ */
201
+ if (/\$\{|\{\{|%\(|%s|\$\(|<[A-Za-z_][A-Za-z0-9_ -]*>/.test(value)) {
202
+ return true;
203
+ }
204
+ /*
205
+ A run of one repeated character is somebody drawing a key rather than
206
+ pasting one. Real keys do not contain `aaaaaaaaaa`.
207
+ */
208
+ if (/(.)\1{7,}/.test(value))
209
+ return true;
210
+ return false;
211
+ }
212
+ /** How much of a match is safe to repeat back. */
213
+ function hint(value) {
214
+ const head = value.slice(0, 7);
215
+ return `${head}…`;
216
+ }
217
+ /**
218
+ * The credentials in this text.
219
+ *
220
+ * Overlapping matches are resolved in favour of whichever pattern is listed
221
+ * first, so a key that fits two formats is named as the more specific one
222
+ * rather than reported twice.
223
+ */
224
+ function findCredentials(text) {
225
+ const claimed = [];
226
+ const found = [];
227
+ /* Line starts, computed once, so a match's line is a lookup not a scan. */
228
+ const starts = [0];
229
+ for (let at = text.indexOf("\n"); at !== -1; at = text.indexOf("\n", at + 1)) {
230
+ starts.push(at + 1);
231
+ }
232
+ const lineOf = (index) => {
233
+ let low = 0;
234
+ let high = starts.length - 1;
235
+ while (low < high) {
236
+ const middle = Math.ceil((low + high) / 2);
237
+ if (starts[middle] <= index)
238
+ low = middle;
239
+ else
240
+ high = middle - 1;
241
+ }
242
+ return low + 1;
243
+ };
244
+ for (const pattern of exports.CREDENTIAL_PATTERNS) {
245
+ // Fresh each time: a /g regex carries lastIndex between calls.
246
+ const expression = new RegExp(pattern.match.source, pattern.match.flags);
247
+ for (const match of text.matchAll(expression)) {
248
+ const at = match.index ?? 0;
249
+ const to = at + match[0].length;
250
+ if (claimed.some(([from, until]) => at < until && to > from))
251
+ continue;
252
+ if (looksLikePlaceholder(match[0]))
253
+ continue;
254
+ claimed.push([at, to]);
255
+ found.push({
256
+ id: pattern.id,
257
+ name: pattern.name,
258
+ line: lineOf(at),
259
+ hint: hint(match[0]),
260
+ });
261
+ }
262
+ }
263
+ return found.sort((left, right) => left.line - right.line);
264
+ }
265
+ /*
266
+ Extensions worth reading. Everything else is either compiled, compressed, or
267
+ media — a credential in a JPEG is not a case worth slowing every upload for,
268
+ and a scan that reads a repository of video is a scan people turn off.
269
+ */
270
+ const READABLE = new Set([
271
+ "",
272
+ ".bash",
273
+ ".bat",
274
+ ".c",
275
+ ".cfg",
276
+ ".clj",
277
+ ".cmd",
278
+ ".conf",
279
+ ".config",
280
+ ".cpp",
281
+ ".cs",
282
+ ".css",
283
+ ".csv",
284
+ ".dart",
285
+ ".dockerfile",
286
+ ".editorconfig",
287
+ ".env",
288
+ ".ex",
289
+ ".exs",
290
+ ".fish",
291
+ ".go",
292
+ ".gradle",
293
+ ".groovy",
294
+ ".h",
295
+ ".hpp",
296
+ ".hs",
297
+ ".htm",
298
+ ".html",
299
+ ".ini",
300
+ ".ipynb",
301
+ ".java",
302
+ ".js",
303
+ ".json",
304
+ ".jsonc",
305
+ ".jsx",
306
+ ".kt",
307
+ ".kts",
308
+ ".less",
309
+ ".lua",
310
+ ".m",
311
+ ".md",
312
+ ".mdx",
313
+ ".mjs",
314
+ ".mts",
315
+ ".php",
316
+ ".pl",
317
+ ".plist",
318
+ ".properties",
319
+ ".ps1",
320
+ ".psm1",
321
+ ".py",
322
+ ".r",
323
+ ".rb",
324
+ ".rs",
325
+ ".sbt",
326
+ ".scala",
327
+ ".scss",
328
+ ".sh",
329
+ ".sql",
330
+ ".svelte",
331
+ ".swift",
332
+ ".tf",
333
+ ".tfvars",
334
+ ".toml",
335
+ ".ts",
336
+ ".tsx",
337
+ ".txt",
338
+ ".vue",
339
+ ".xml",
340
+ ".yaml",
341
+ ".yml",
342
+ ".zsh",
343
+ ]);
344
+ /** Whether a file with this name is worth reading for credentials. */
345
+ function worthReading(name) {
346
+ const lower = name.toLowerCase();
347
+ const dot = lower.lastIndexOf(".");
348
+ /*
349
+ A leading dot is the whole name, not an extension: `.env` and `.npmrc`
350
+ are files called that, and treating `env` as their extension would let
351
+ the wrong ones through and read the wrong ones twice.
352
+ */
353
+ const extension = dot <= 0 ? "" : lower.slice(dot);
354
+ if (READABLE.has(extension))
355
+ return true;
356
+ // Named files with no extension that are nearly always configuration.
357
+ return ["dockerfile", "makefile", "procfile", "rakefile"].includes(lower);
358
+ }
359
+ /** Whether these first bytes are text rather than something compiled. */
360
+ function looksLikeText(bytes) {
361
+ /*
362
+ A NUL byte is the reliable tell. Checking a prefix rather than the whole
363
+ file keeps this cheap, and a file that is text for its first few kilobytes
364
+ and binary after is not a shape that occurs by accident.
365
+ */
366
+ const upTo = Math.min(bytes.byteLength, 8192);
367
+ for (let at = 0; at < upTo; at += 1)
368
+ if (bytes[at] === 0)
369
+ return false;
370
+ return true;
371
+ }
@@ -19,6 +19,8 @@ exports.fileDiff = fileDiff;
19
19
  exports.projectTree = projectTree;
20
20
  exports.evaluateRules = evaluateRules;
21
21
  exports.detectPrivateDirectories = detectPrivateDirectories;
22
+ exports.detectPastedCredentials = detectPastedCredentials;
23
+ exports.uploadConcerns = uploadConcerns;
22
24
  exports.detectSecrets = detectSecrets;
23
25
  /** Reading a project folder: changed files, diffs, and rule measurement. */
24
26
  const node_child_process_1 = require("node:child_process");
@@ -28,6 +30,7 @@ const promises_1 = require("node:fs/promises");
28
30
  const node_path_1 = __importDefault(require("node:path"));
29
31
  const node_util_1 = require("node:util");
30
32
  const rules_js_1 = require("./rules.js");
33
+ const secret_patterns_js_1 = require("./secret_patterns.js");
31
34
  const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
32
35
  /** Past this the evaluation reports truncated rather than walking forever. */
33
36
  exports.EVALUATION_FILE_LIMIT = 300_000;
@@ -878,6 +881,99 @@ async function detectPrivateDirectories(root) {
878
881
  }
879
882
  return found;
880
883
  }
884
+ /**
885
+ * Whether a selection should be questioned before it is sent.
886
+ *
887
+ * Lifted out of the window's message handler so it can be exercised without
888
+ * starting one. The desktop is the path a real leak took, and a rule that
889
+ * only runs inside a GUI is a rule nobody can prove: this returns the two
890
+ * lists and takes no view, leaving the refusing to the caller that has a
891
+ * person to ask.
892
+ *
893
+ * Narrowed to the selection on purpose. A folder the rules already leave
894
+ * behind is not being published, and raising it would train people to dismiss
895
+ * the question that matters.
896
+ */
897
+ /*
898
+ Bounds for the content scan, stated rather than discovered.
899
+
900
+ This reads files, which the name check never did, so it is the one check
901
+ whose cost grows with the project. A person waiting to publish will not wait
902
+ long, and a scan they turn off protects nobody — so it reads what it can
903
+ inside these limits and is honest about stopping.
904
+ */
905
+ const MOST_SCANNED_FILES = 20_000;
906
+ const MOST_SCANNED_BYTES = 256 * 1024 * 1024;
907
+ /** Past this a file is data, not something somebody pasted a key into. */
908
+ const MOST_FILE_BYTES = 2 * 1024 * 1024;
909
+ /** Enough to make the point; a list of five hundred is not read. */
910
+ const MOST_FINDINGS = 40;
911
+ /**
912
+ * Credentials sitting inside files that are not credentials.
913
+ *
914
+ * The name check finds the files a tool wrote — `.env`, a cached login, an
915
+ * SSH key. This finds the other kind, and it is the kind nothing caught: a
916
+ * key pasted into `settings.py` while getting something working, in a file
917
+ * with an ordinary name that every check waved through.
918
+ *
919
+ * Narrowed to the selection, like the rest of the concerns: a file the rules
920
+ * already leave behind is not being published, and raising it would train
921
+ * people to dismiss the question that matters.
922
+ */
923
+ async function detectPastedCredentials(root, include) {
924
+ const found = [];
925
+ let read = 0;
926
+ let bytes = 0;
927
+ for (const relative of include) {
928
+ if (found.length >= MOST_FINDINGS)
929
+ break;
930
+ if (read >= MOST_SCANNED_FILES || bytes >= MOST_SCANNED_BYTES)
931
+ break;
932
+ const name = relative.slice(relative.lastIndexOf("/") + 1);
933
+ if (!(0, secret_patterns_js_1.worthReading)(name))
934
+ continue;
935
+ const full = node_path_1.default.join(root, relative.split("/").join(node_path_1.default.sep));
936
+ let contents;
937
+ try {
938
+ const info = await (0, promises_1.stat)(full);
939
+ if (!info.isFile() || info.size > MOST_FILE_BYTES)
940
+ continue;
941
+ contents = await (0, promises_1.readFile)(full);
942
+ }
943
+ catch {
944
+ /* Unreadable is the scan's problem, not something to report as clean. */
945
+ continue;
946
+ }
947
+ read += 1;
948
+ bytes += contents.byteLength;
949
+ if (!(0, secret_patterns_js_1.looksLikeText)(contents))
950
+ continue;
951
+ const credentials = (0, secret_patterns_js_1.findCredentials)(contents.toString("utf8"));
952
+ if (credentials.length)
953
+ found.push({ path: relative, found: credentials });
954
+ }
955
+ return found;
956
+ }
957
+ async function uploadConcerns(root, include) {
958
+ const chosen = new Set(include);
959
+ const [named, folders, pasted] = await Promise.all([
960
+ detectSecrets(root),
961
+ detectPrivateDirectories(root),
962
+ detectPastedCredentials(root, include),
963
+ ]);
964
+ const secrets = named.filter((file) => chosen.has(file));
965
+ return {
966
+ secrets,
967
+ shielded: folders.filter((finding) => [...chosen].some((file) => file === finding.path || file.startsWith(`${finding.path}/`))),
968
+ /*
969
+ A file already refused by name is not raised twice. Saying "this is a
970
+ credential" and "there is a credential inside it" about the same file
971
+ is two warnings for one problem, and the first one is the actionable
972
+ one.
973
+ */
974
+ pasted: pasted.filter((finding) => !secrets.includes(finding.path)),
975
+ };
976
+ }
881
977
  /** Files the flow must ask about before the first upload. */
882
978
  async function detectSecrets(root) {
883
979
  const found = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",