@coderook/cli 0.18.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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
5
- "version": "0.18.0",
5
+ "version": "0.19.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -416,6 +416,47 @@ Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
416
416
  }
417
417
  console.log(dim("Sending them anyway, because --allow-secrets was given."));
418
418
  }
419
+ /*
420
+ Keys pasted into files that are not keys.
421
+
422
+ The check above reads names, which finds what a tool wrote and is blind to
423
+ what a person typed. This one reads the files being sent — the case where
424
+ somebody pasted a token into `settings.py` to get something working and it
425
+ stayed there, in a file with an ordinary name that every other check waves
426
+ through.
427
+
428
+ Refused separately from `--allow-secrets`, because the answer is different:
429
+ a `.env` should not be sent and the fix is to leave it out, while a source
430
+ file should be sent and the fix is to take the key out of it.
431
+ */
432
+ const pasted = await (0, worktree_js_1.detectPastedCredentials)(folder, files.map((file) => file.path));
433
+ const stillPasted = pasted.filter((finding) => !exposed.includes(finding.path));
434
+ if (stillPasted.length) {
435
+ console.log(red(`
436
+ ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s have credentials"} inside:`));
437
+ for (const finding of stillPasted.slice(0, 20)) {
438
+ console.log(` ${finding.path}`);
439
+ for (const one of finding.found.slice(0, 3)) {
440
+ console.log(dim(` line ${one.line}: ${one.name}`));
441
+ }
442
+ if (finding.found.length > 3) {
443
+ console.log(dim(` …and ${finding.found.length - 3} more`));
444
+ }
445
+ }
446
+ if (stillPasted.length > 20) {
447
+ console.log(dim(` …and ${stillPasted.length - 20} more`));
448
+ }
449
+ if (!hasFlag(parsed, "allow-secrets")) {
450
+ console.error(`
451
+ Nothing was sent. Move the key into an environment variable, and if` +
452
+ ` it has ever been published, replace it at the service that issued` +
453
+ ` it — a key that has leaked stays leaked.` +
454
+ `
455
+ Pass ${accent("--allow-secrets")} if these are not real keys.`);
456
+ return 1;
457
+ }
458
+ console.log(dim("Sending them anyway, because --allow-secrets was given."));
459
+ }
419
460
  if (hasFlag(parsed, "dry-run", "n")) {
420
461
  console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent:`);
421
462
  for (const file of files)
@@ -1208,7 +1249,10 @@ const SPECS = [
1208
1249
  options: [
1209
1250
  { flags: "-m, --message <text>", description: "what changed, in a sentence" },
1210
1251
  { flags: "-n, --dry-run", description: "show what would be sent, send nothing" },
1211
- { flags: "--allow-secrets", description: "send files that look like credentials" },
1252
+ {
1253
+ flags: "--allow-secrets",
1254
+ description: "send files that look like credentials, and files with keys inside",
1255
+ },
1212
1256
  {
1213
1257
  flags: "--track <name>",
1214
1258
  description: "save onto this line, just this once",
@@ -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,7 @@ exports.fileDiff = fileDiff;
19
19
  exports.projectTree = projectTree;
20
20
  exports.evaluateRules = evaluateRules;
21
21
  exports.detectPrivateDirectories = detectPrivateDirectories;
22
+ exports.detectPastedCredentials = detectPastedCredentials;
22
23
  exports.uploadConcerns = uploadConcerns;
23
24
  exports.detectSecrets = detectSecrets;
24
25
  /** Reading a project folder: changed files, diffs, and rule measurement. */
@@ -29,6 +30,7 @@ const promises_1 = require("node:fs/promises");
29
30
  const node_path_1 = __importDefault(require("node:path"));
30
31
  const node_util_1 = require("node:util");
31
32
  const rules_js_1 = require("./rules.js");
33
+ const secret_patterns_js_1 = require("./secret_patterns.js");
32
34
  const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
33
35
  /** Past this the evaluation reports truncated rather than walking forever. */
34
36
  exports.EVALUATION_FILE_LIMIT = 300_000;
@@ -892,15 +894,84 @@ async function detectPrivateDirectories(root) {
892
894
  * behind is not being published, and raising it would train people to dismiss
893
895
  * the question that matters.
894
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
+ }
895
957
  async function uploadConcerns(root, include) {
896
958
  const chosen = new Set(include);
897
- const [named, folders] = await Promise.all([
959
+ const [named, folders, pasted] = await Promise.all([
898
960
  detectSecrets(root),
899
961
  detectPrivateDirectories(root),
962
+ detectPastedCredentials(root, include),
900
963
  ]);
964
+ const secrets = named.filter((file) => chosen.has(file));
901
965
  return {
902
- secrets: named.filter((file) => chosen.has(file)),
966
+ secrets,
903
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)),
904
975
  };
905
976
  }
906
977
  /** Files the flow must ask about before the first upload. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.18.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",