@coderook/cli 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/cli.js +45 -1
- package/dist/desktop-app/src/main/secret_patterns.js +371 -0
- package/dist/desktop-app/src/main/staging.js +32 -1
- package/dist/desktop-app/src/main/upload.js +5 -22
- package/dist/desktop-app/src/main/worktree.js +73 -2
- package/package.json +1 -1
|
@@ -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.
|
|
5
|
+
"version": "0.20.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
+
}
|
|
@@ -37,7 +37,38 @@ exports.describePlan = describePlan;
|
|
|
37
37
|
* session. Naming them here means the plan can be inspected before a byte
|
|
38
38
|
* moves.
|
|
39
39
|
*/
|
|
40
|
-
|
|
40
|
+
/*
|
|
41
|
+
Below this a file travels with others in one request rather than alone.
|
|
42
|
+
|
|
43
|
+
Measured rather than guessed, twice. The first number was sixty-four
|
|
44
|
+
kilobytes, taken from a source folder where ninety-six percent of files were
|
|
45
|
+
under it. A four and a half gigabyte game then showed the opposite shape:
|
|
46
|
+
eight hundred files of three hundred kilobytes per section, each just above
|
|
47
|
+
that line and so each paying its own round trip.
|
|
48
|
+
|
|
49
|
+
What the game measured is the reason for the number. Every request costs
|
|
50
|
+
about six hundred and thirty milliseconds before any bytes move, and the
|
|
51
|
+
lane carries roughly 0.79 MB/s. So a three hundred kilobyte file spends
|
|
52
|
+
sixty percent of its time on overhead and a seven megabyte file spends
|
|
53
|
+
seven. The line belongs where the overhead stops dominating, which is a few
|
|
54
|
+
megabytes and not a few kilobytes.
|
|
55
|
+
|
|
56
|
+
Four megabytes was the first attempt and it overshot. Measured on the same
|
|
57
|
+
game twice: four hundred kilobyte files went from 1.80 to 3.24 MB/s when
|
|
58
|
+
batched, while 1.3 megabyte files went from 4.43 down to 3.88 — because
|
|
59
|
+
three concurrent batches move less than six concurrent requests once a file
|
|
60
|
+
is large enough that transfer, not overhead, dominates. The crossover is
|
|
61
|
+
around a megabyte.
|
|
62
|
+
|
|
63
|
+
It lives here, and the uploader imports it, because for a while it did not:
|
|
64
|
+
the uploader had measured its way to a megabyte while this file still said
|
|
65
|
+
sixty-four kilobytes, and sections were therefore planned by one definition
|
|
66
|
+
of "small" and then sent by another. Nothing broke, which is why it lasted —
|
|
67
|
+
the planner simply grouped files it called ordinary and the uploader batched
|
|
68
|
+
them anyway, and a second filter downstream existed to paper over the
|
|
69
|
+
disagreement.
|
|
70
|
+
*/
|
|
71
|
+
exports.PACKABLE_LIMIT = 1024 * 1024;
|
|
41
72
|
exports.CHUNK_THRESHOLD = 16 * 1024 * 1024;
|
|
42
73
|
exports.DIRECT_LIMIT = 95 * 1024 * 1024;
|
|
43
74
|
function bandOf(size) {
|
|
@@ -42,29 +42,12 @@ const CHUNK_THRESHOLD = 16 * 1024 * 1024;
|
|
|
42
42
|
*/
|
|
43
43
|
const UPLOAD_LANES = 6;
|
|
44
44
|
/*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
A four and a half gigabyte game then showed the opposite shape: eight
|
|
50
|
-
hundred files of three hundred kilobytes per section, each just above that
|
|
51
|
-
line and so each paying its own round trip.
|
|
52
|
-
|
|
53
|
-
What the game measured is the reason for the number. Every request costs
|
|
54
|
-
about six hundred and thirty milliseconds before any bytes move, and the
|
|
55
|
-
lane carries roughly 0.79 MB/s. So a three hundred kilobyte file spends
|
|
56
|
-
sixty percent of its time on overhead and a seven megabyte file spends
|
|
57
|
-
seven. The line belongs where the overhead stops dominating, which is a few
|
|
58
|
-
megabytes and not a few kilobytes.
|
|
59
|
-
|
|
60
|
-
Four megabytes was the first attempt and it overshot. Measured on the same
|
|
61
|
-
game twice: four hundred kilobyte files went from 1.80 to 3.24 MB/s when
|
|
62
|
-
batched, while 1.3 megabyte files went from 4.43 down to 3.88 — because
|
|
63
|
-
three concurrent batches move less than six concurrent requests once a file
|
|
64
|
-
is large enough that transfer, not overhead, dominates. The crossover is
|
|
65
|
-
around a megabyte.
|
|
45
|
+
What counts as small enough to travel with others, defined once in
|
|
46
|
+
staging.ts and imported here. The planner groups sections by the same line
|
|
47
|
+
the sender batches on, which for a while they did not — see the note beside
|
|
48
|
+
PACKABLE_LIMIT for what that cost.
|
|
66
49
|
*/
|
|
67
|
-
const BATCH_FILE_LIMIT =
|
|
50
|
+
const BATCH_FILE_LIMIT = staging_js_1.PACKABLE_LIMIT;
|
|
68
51
|
/** How much one batch may carry, and how many objects it may name. */
|
|
69
52
|
const BATCH_BYTES = 24 * 1024 * 1024;
|
|
70
53
|
const BATCH_COUNT = 256;
|
|
@@ -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
|
|
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. */
|