start-command 0.39.1 → 0.39.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.39.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 328fa0c: Sanitize `--upload-log` logs and auto-reported issue text with `@link-foundation/sensitive-data-sanitizer` (#250) instead of start-command's own rules, which removed content that is not secret (#249). Before, `Authorization:` was masked to the end of the line, which dropped the rest of one-line JSON request bodies. Every URL with a port was masked, and `sk-` matched inside words such as `disk-baseline-`. Now only credentials are masked (`--credentials-only`): values of credential-named environment variables, the gh `hosts.yml` tokens, and `Authorization` schemes with their token. The rest of the line, account details and URLs with ports stay. The log streams through the library CLI from stdin into a private `0600` copy, and invalid UTF-8 bytes are kept. Any failure blocks the upload with a value-free code such as `Log sanitization failed (ERR_BINARY); upload blocked.` `START_SENSITIVE_DATA_SANITIZER` selects another CLI, and `--no-sanitize` is unchanged.
8
+
3
9
  ## 0.39.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -41,7 +41,8 @@ checked examples.
41
41
 
42
42
  `-h` also prints usage. Labels persist across Docker resumes. New detached
43
43
  containers support copy-free command handoff; legacy snapshots require capacity
44
- preflight. Log uploads are private and sanitized; manual `--no-sanitize` opts out.
44
+ preflight. Log uploads are private and sanitized with the bundled
45
+ `@link-foundation/sensitive-data-sanitizer`; manual `--no-sanitize` opts out.
45
46
 
46
47
  Snapshot resumes take `--snapshot-wait`, `--snapshot-disk-wait`,
47
48
  `--remove-original-after`, `--snapshot-size-hint` and `--snapshot-prepare`, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.39.1",
3
+ "version": "0.39.2",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -68,6 +68,7 @@
68
68
  ]
69
69
  },
70
70
  "dependencies": {
71
+ "@link-foundation/sensitive-data-sanitizer": "^0.12.0",
71
72
  "command-stream": "^0.9.4",
72
73
  "lino-objects-codec": "^0.4.0"
73
74
  },
@@ -9,7 +9,11 @@ const os = require('os');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { getTimestamp } = require('./isolation');
12
- const { sanitizeLogToTemp, StreamingSanitizer } = require('./log-sanitizer');
12
+ const {
13
+ SanitizationError,
14
+ sanitizeLogToTemp,
15
+ sanitizeText,
16
+ } = require('./log-sanitizer');
13
17
 
14
18
  /**
15
19
  * Handle command failure - detect repository, upload log, create issue
@@ -296,8 +300,12 @@ function uploadLog(logPath, options = {}) {
296
300
  }
297
301
 
298
302
  return null;
299
- } catch {
300
- console.log('Warning: Log preparation or upload failed; upload blocked.');
303
+ } catch (error) {
304
+ console.log(
305
+ error instanceof SanitizationError
306
+ ? `Warning: ${error.message}`
307
+ : 'Warning: Log preparation or upload failed; upload blocked.'
308
+ );
301
309
  return null;
302
310
  } finally {
303
311
  if (prepared) {
@@ -334,9 +342,7 @@ function canCreateIssue(owner, repo) {
334
342
  */
335
343
  function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
336
344
  try {
337
- fullCommand = new StreamingSanitizer()
338
- .push(Buffer.from(fullCommand), true)
339
- .toString();
345
+ fullCommand = sanitizeText(fullCommand);
340
346
  const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
341
347
 
342
348
  // Get runtime information
@@ -1,267 +1,350 @@
1
- /** Bounded byte-wise sanitization for logs prepared for publication. */
1
+ /**
2
+ * Prepares logs and issue text for publication with
3
+ * @link-foundation/sensitive-data-sanitizer, the sanitizer shared by
4
+ * start-command, gh-upload-log and hive-mind (issue #250).
5
+ *
6
+ * The library masks credentials only (--credentials-only), plus the values of
7
+ * credential-named environment variables (--env-secrets) and the tokens in the
8
+ * local gh hosts.yml (--gh-config). It streams the log from stdin, keeps
9
+ * invalid UTF-8 bytes and writes a new private file atomically (--output).
10
+ *
11
+ * One rule stays here: the library masks an unquoted or single-quoted
12
+ * `Authorization: <scheme> <value>` to the end of the line, which drops the
13
+ * rest of a logged one-line JSON prompt (issue #249). The pre-pass below masks
14
+ * only the scheme and its token first; the library keeps values that already
15
+ * start with [REDACTED]. Remove it with sensitive-data-sanitizer#45.
16
+ */
17
+
2
18
  const fs = require('fs');
3
19
  const os = require('os');
4
20
  const path = require('path');
21
+ const { spawnSync } = require('child_process');
5
22
 
6
23
  const BLOCK_SIZE = 64 * 1024;
7
24
  const MARKER = '[REDACTED]';
8
- const SECRET_ENV_NAME =
9
- /(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)/i;
25
+ const PACKAGE = '@link-foundation/sensitive-data-sanitizer';
26
+ const COMMAND = 'sensitive-data-sanitizer';
27
+ const OVERRIDE = 'START_SENSITIVE_DATA_SANITIZER';
28
+ const OPTIONS = ['--credentials-only', '--env-secrets', '--gh-config'];
10
29
 
11
- // Prefixes intentionally match even truncated tokens. Unbounded values retain a
12
- // continuation state, so a token or header larger than a block cannot leak its
13
- // suffix. Latin-1 provides a reversible byte mapping, including invalid UTF-8.
14
- const RULES = [
15
- {
16
- pattern:
17
- /\b[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{0,512}/g,
18
- validate: (value) => {
19
- try {
20
- return (
21
- typeof JSON.parse(
22
- Buffer.from(value.split('.')[0], 'base64url').toString()
23
- ).alg === 'string'
24
- );
25
- } catch {
26
- return false;
27
- }
28
- },
29
- continuation: /[A-Za-z0-9_.-]/,
30
- },
31
- {
32
- pattern: /[A-Za-z0-9_-]{512,}[A-Za-z0-9_.-]*/g,
33
- continuation: /[A-Za-z0-9_.-]/,
34
- },
35
- {
36
- pattern: /(?:gh[pousr]_|github_pat_|sk-)[A-Za-z0-9_-]*/g,
37
- continuation: /[A-Za-z0-9_-]/,
38
- },
39
- { pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/g },
40
- { pattern: /eyJ[A-Za-z0-9_.-]*/g, continuation: /[A-Za-z0-9_.-]/ },
41
- {
42
- pattern: /(?:Proxy-)?Authorization[ \t]{0,32}:[^\r\n]*/gi,
43
- continuation: /[^\r\n]/,
44
- },
45
- {
46
- pattern:
47
- /(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)[ \t]{0,32}[=:][ \t]*["']?[^\s"';,]*/g,
48
- continuation: /[^\s"';,]/,
49
- },
50
- {
51
- pattern: /(?:https?|git|ssh):\/\/[^\s:/@]{1,256}:[^\s@]*/g,
52
- continuation: /[^\s@]/,
53
- },
54
- ];
30
+ // The longest header and scheme prefix is 122 bytes.
31
+ const HOLDBACK = 160;
32
+ const HEADER =
33
+ /((?:Proxy-)?Authorization[ \t]{0,32}:[ \t]{0,32})(?:Bearer|Basic|Digest|token)[ \t]{1,32}/gi;
34
+ // Shell expressions, placeholders and masked values are not credentials.
35
+ const NOT_A_VALUE = '$<[{';
55
36
 
56
- class StreamingSanitizer {
57
- constructor(env = process.env) {
58
- this.secrets = [
59
- ...new Set(
60
- Object.entries(env)
61
- .filter(
62
- ([name, value]) =>
63
- SECRET_ENV_NAME.test(name) &&
64
- typeof value === 'string' &&
65
- value.length
66
- )
67
- .map(([, value]) => Buffer.from(value).toString('latin1'))
68
- ),
69
- ].sort((a, b) => b.length - a.length);
70
- if (this.secrets.some((value) => value.length > BLOCK_SIZE)) {
71
- throw new Error(
72
- 'Known environment secret is too large for bounded sanitization.'
73
- );
74
- }
75
- this.holdback = this.secrets.reduce(
76
- (size, value) => Math.max(size, value.length),
77
- 2048
78
- );
79
- this.pending = '';
80
- this.continuation = null;
81
- this.redactions = 0;
37
+ function isValueByte(code) {
38
+ return (
39
+ code > 0x20 &&
40
+ code !== 0x22 &&
41
+ code !== 0x27 &&
42
+ code !== 0x2c &&
43
+ code !== 0x5c
44
+ );
45
+ }
46
+
47
+ function valueEnd(text, start) {
48
+ let end = start;
49
+ while (end < text.length && isValueByte(text.charCodeAt(end))) {
50
+ end++;
82
51
  }
52
+ return end;
53
+ }
83
54
 
84
- get pendingBytes() {
85
- return this.pending.length;
55
+ /**
56
+ * Masks `Authorization: <scheme> <token>` as `Authorization: [REDACTED]`.
57
+ * It works on bytes (latin1 maps each byte to one character), so invalid
58
+ * UTF-8 passes through unchanged, and holds at most HOLDBACK pending bytes.
59
+ */
60
+ class AuthorizationHeaderMask {
61
+ constructor() {
62
+ this.pending = '';
63
+ this.inValue = false;
64
+ this.redactions = 0;
86
65
  }
87
66
 
88
- findMatches(input) {
89
- const matches = [];
90
- for (const rule of RULES) {
91
- rule.pattern.lastIndex = 0;
92
- for (
93
- let match = rule.pattern.exec(input);
94
- match;
95
- match = rule.pattern.exec(input)
96
- ) {
97
- if (!rule.validate || rule.validate(match[0])) {
98
- matches.push({
99
- index: match.index,
100
- end: match.index + match[0].length,
101
- continuation: rule.continuation,
102
- });
103
- }
67
+ push(block, final = false) {
68
+ const text = this.pending + block.toString('latin1');
69
+ this.pending = '';
70
+ let output = '';
71
+ let offset = 0;
72
+ if (this.inValue) {
73
+ offset = valueEnd(text, 0);
74
+ if (offset === text.length && !final) {
75
+ return Buffer.alloc(0);
104
76
  }
77
+ this.inValue = false;
105
78
  }
106
- for (const secret of this.secrets) {
107
- for (
108
- let index = input.indexOf(secret);
109
- index >= 0;
110
- index = input.indexOf(secret, index + 1)
79
+ HEADER.lastIndex = offset;
80
+ let match;
81
+ while ((match = HEADER.exec(text))) {
82
+ const valueStart = match.index + match[0].length;
83
+ if (valueStart === text.length && !final) {
84
+ output += text.slice(offset, match.index);
85
+ this.pending = text.slice(match.index);
86
+ return Buffer.from(output, 'latin1');
87
+ }
88
+ if (
89
+ valueStart === text.length ||
90
+ !isValueByte(text.charCodeAt(valueStart)) ||
91
+ NOT_A_VALUE.includes(text[valueStart])
111
92
  ) {
112
- matches.push({ index, end: index + secret.length });
93
+ continue;
113
94
  }
114
- }
115
- matches.sort((a, b) => a.index - b.index || b.end - a.end);
116
- const merged = [];
117
- for (const match of matches) {
118
- const previous = merged[merged.length - 1];
119
- if (previous && match.index <= previous.end) {
120
- if (match.end > previous.end) {
121
- previous.end = match.end;
122
- previous.continuation = match.continuation;
123
- } else if (match.end === previous.end && match.continuation) {
124
- // Nested tokens must not narrow a containing header/URL's suffix.
125
- const retained = previous.continuation;
126
- const additional = match.continuation;
127
- previous.continuation = retained
128
- ? { test: (byte) => retained.test(byte) || additional.test(byte) }
129
- : additional;
130
- }
131
- } else {
132
- merged.push({ ...match });
95
+ output += text.slice(offset, match.index + match[1].length) + MARKER;
96
+ this.redactions++;
97
+ offset = valueEnd(text, valueStart);
98
+ if (offset === text.length && !final) {
99
+ this.inValue = true;
100
+ return Buffer.from(output, 'latin1');
133
101
  }
102
+ HEADER.lastIndex = offset;
134
103
  }
135
- return merged;
104
+ const safeEnd = final
105
+ ? text.length
106
+ : Math.max(offset, text.length - HOLDBACK);
107
+ output += text.slice(offset, safeEnd);
108
+ this.pending = text.slice(safeEnd);
109
+ return Buffer.from(output, 'latin1');
136
110
  }
111
+ }
137
112
 
138
- push(block, final = false) {
139
- let input = this.pending + block.toString('latin1');
140
- this.pending = '';
141
- if (this.continuation) {
142
- let end = 0;
143
- while (end < input.length && this.continuation.test(input[end])) {
144
- end++;
145
- }
146
- input = input.slice(end);
147
- if (!input.length && !final) {
148
- return Buffer.alloc(0);
149
- }
150
- this.continuation = null;
113
+ /** Value-free failure: `code` is a fixed ERR_* name, never log content. */
114
+ class SanitizationError extends Error {
115
+ constructor(code, subject = 'Log') {
116
+ super(
117
+ `${subject} sanitization failed (${code}); ${subject === 'Log' ? 'upload' : 'publication'} blocked.`
118
+ );
119
+ this.code = code;
120
+ }
121
+ }
122
+
123
+ function isFile(filePath) {
124
+ try {
125
+ return fs.statSync(filePath).isFile();
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ function findBundledCli() {
132
+ // Walk up node_modules directories the way require() does; the package
133
+ // exports only ESM, so require.resolve() cannot locate its files.
134
+ const relative = path.join(PACKAGE, 'bin', `${COMMAND}.js`);
135
+ let directory = __dirname;
136
+ for (;;) {
137
+ const candidate = path.join(directory, 'node_modules', relative);
138
+ if (isFile(candidate)) {
139
+ return candidate;
151
140
  }
152
- const safeEnd = final
153
- ? input.length
154
- : Math.max(0, input.length - this.holdback);
155
- let offset = 0;
156
- const output = [];
157
- for (const match of this.findMatches(input)) {
158
- if (match.index >= safeEnd) {
159
- break;
141
+ const parent = path.dirname(directory);
142
+ if (parent === directory) {
143
+ return null;
144
+ }
145
+ directory = parent;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Resolve the library CLI: START_SENSITIVE_DATA_SANITIZER, then the bundled
151
+ * dependency run by the current runtime, then sensitive-data-sanitizer on PATH.
152
+ * @returns {{command: string, args: string[]}|null}
153
+ */
154
+ function resolveSanitizerCommand(env = process.env) {
155
+ if (env[OVERRIDE]) {
156
+ return { command: env[OVERRIDE], args: [] };
157
+ }
158
+ const bundled = findBundledCli();
159
+ if (bundled) {
160
+ return { command: process.execPath, args: [bundled] };
161
+ }
162
+ const { resolveCommand } = require('./log-uploader');
163
+ const command = resolveCommand(COMMAND);
164
+ return command ? { command, args: [] } : null;
165
+ }
166
+
167
+ function runSanitizer(args, options, subject) {
168
+ const env = options.env || process.env;
169
+ const resolved = resolveSanitizerCommand(env);
170
+ if (!resolved) {
171
+ throw new SanitizationError('ERR_UNAVAILABLE', subject);
172
+ }
173
+ const result = spawnSync(resolved.command, [...resolved.args, ...args], {
174
+ env,
175
+ input: options.input,
176
+ stdio: options.stdio || ['pipe', 'pipe', 'pipe'],
177
+ maxBuffer: 64 * 1024 * 1024,
178
+ shell:
179
+ process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command),
180
+ windowsHide: true,
181
+ });
182
+ if (result.error) {
183
+ throw new SanitizationError('ERR_UNAVAILABLE', subject);
184
+ }
185
+ if (result.status !== 0) {
186
+ // The library prints value-free messages; only their ERR_* code is kept.
187
+ const code = String(result.stderr || '').match(/\bERR_[A-Z_]+\b/);
188
+ throw new SanitizationError(code ? code[0] : 'ERR_SANITIZER', subject);
189
+ }
190
+ return result;
191
+ }
192
+
193
+ /**
194
+ * Sanitize a short text, such as the failing command quoted in an issue.
195
+ * @returns {string}
196
+ */
197
+ function sanitizeText(text, options = {}) {
198
+ const input = new AuthorizationHeaderMask().push(Buffer.from(text), true);
199
+ const result = runSanitizer(
200
+ ['redact', '-', ...OPTIONS],
201
+ { ...options, input },
202
+ 'Text'
203
+ );
204
+ return result.stdout.toString();
205
+ }
206
+
207
+ function countMarkers(filePath) {
208
+ const descriptor = fs.openSync(filePath, 'r');
209
+ const buffer = Buffer.alloc(BLOCK_SIZE);
210
+ let count = 0;
211
+ let carry = '';
212
+ try {
213
+ for (;;) {
214
+ const length = fs.readSync(descriptor, buffer, 0, BLOCK_SIZE, null);
215
+ const text = carry + buffer.toString('latin1', 0, length);
216
+ let index = text.indexOf(MARKER);
217
+ let next = 0;
218
+ while (index !== -1) {
219
+ count++;
220
+ next = index + MARKER.length;
221
+ index = text.indexOf(MARKER, next);
160
222
  }
161
- output.push(input.slice(offset, match.index), MARKER);
162
- this.redactions++;
163
- offset = match.end;
164
- if (offset === input.length && match.continuation && !final) {
165
- this.continuation = match.continuation;
166
- break;
223
+ if (length === 0) {
224
+ return count;
167
225
  }
226
+ carry = text.slice(Math.max(next, text.length - MARKER.length + 1));
168
227
  }
169
- const end = Math.max(offset, safeEnd);
170
- output.push(input.slice(offset, end));
171
- this.pending = input.slice(end);
172
- return Buffer.from(output.join(''), 'latin1');
228
+ } finally {
229
+ fs.closeSync(descriptor);
173
230
  }
174
231
  }
175
232
 
176
- /** Returns a private copy plus cleanup; every preparation error blocks upload. */
177
- function sanitizeLogToTemp(sourcePath, options = {}) {
178
- let directory;
179
- let source;
180
- let destination;
181
- let ready = false;
182
- let stage = 'environment';
233
+ function createPrivateFile(filePath) {
234
+ const descriptor = fs.openSync(filePath, 'wx', 0o600);
235
+ if (process.platform !== 'win32') {
236
+ fs.fchmodSync(descriptor, 0o600);
237
+ }
238
+ return descriptor;
239
+ }
240
+
241
+ function maskAuthorizationHeaders(sourcePath, stagePath) {
242
+ const source = fs.openSync(sourcePath, 'r');
243
+ let stage;
183
244
  try {
184
- const sanitizer = new StreamingSanitizer(options.env || process.env);
185
- stage = 'source';
186
- source = fs.openSync(sourcePath, 'r');
187
245
  if (!fs.fstatSync(source).isFile()) {
188
- throw new Error('Source must be a regular file.');
246
+ throw new SanitizationError('ERR_SOURCE');
189
247
  }
190
- stage = 'private-file';
191
- directory = fs.mkdtempSync(path.join(os.tmpdir(), 'start-sanitized-'));
192
- fs.chmodSync(directory, 0o700);
193
- const target = path.join(directory, 'execution.log');
194
- destination = fs.openSync(target, 'wx', 0o600);
195
- fs.fchmodSync(destination, 0o600);
248
+ stage = createPrivateFile(stagePath);
249
+ const mask = new AuthorizationHeaderMask();
196
250
  const buffer = Buffer.alloc(BLOCK_SIZE);
197
251
  let bytes = 0;
198
- const write = (output) => {
199
- stage = 'write';
200
- let offset = 0;
201
- while (offset < output.length) {
202
- const written = fs.writeSync(
203
- destination,
204
- output,
205
- offset,
206
- output.length - offset
207
- );
208
- if (!written) {
209
- throw new Error('No progress writing sanitized log.');
210
- }
211
- offset += written;
212
- }
213
- };
214
252
  for (;;) {
215
- stage = 'read';
216
- const length = fs.readSync(source, buffer, 0, buffer.length, null);
217
- if (!length) {
253
+ const length = fs.readSync(source, buffer, 0, BLOCK_SIZE, null);
254
+ const output = mask.push(buffer.subarray(0, length), length === 0);
255
+ if (output.length) {
256
+ fs.writeSync(stage, output);
257
+ }
258
+ if (length === 0) {
218
259
  break;
219
260
  }
220
261
  bytes += length;
221
- stage = 'scan';
222
- write(sanitizer.push(buffer.subarray(0, length)));
223
262
  }
224
- write(sanitizer.push(Buffer.alloc(0), true));
225
- stage = 'sync';
226
- fs.fsyncSync(destination);
227
- fs.closeSync(destination);
228
- destination = undefined;
263
+ fs.fsyncSync(stage);
264
+ return bytes;
265
+ } finally {
229
266
  fs.closeSync(source);
230
- source = undefined;
267
+ if (stage !== undefined) {
268
+ fs.closeSync(stage);
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Create a private sanitized copy of a log. The source is never modified;
275
+ * any failure removes the copy and throws a value-free SanitizationError.
276
+ * @returns {{path: string, cleanup: Function}}
277
+ */
278
+ function sanitizeLogToTemp(sourcePath, options = {}) {
279
+ let directory;
280
+ let code = 'ERR_PRIVATE_FILE';
281
+ const cleanup = () => {
282
+ if (directory) {
283
+ fs.rmSync(directory, { recursive: true, force: true });
284
+ }
285
+ };
286
+ try {
287
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'start-sanitized-'));
288
+ if (process.platform !== 'win32') {
289
+ fs.chmodSync(directory, 0o700);
290
+ }
291
+ const stagePath = path.join(directory, 'authorization-masked.log');
292
+ const target = path.join(directory, 'execution.log');
293
+ code = 'ERR_SOURCE';
294
+ const bytes = maskAuthorizationHeaders(sourcePath, stagePath);
295
+ code = 'ERR_PRIVATE_FILE';
296
+ const input = fs.openSync(stagePath, 'r');
297
+ try {
298
+ const size = Math.max(1, fs.fstatSync(input).size);
299
+ runSanitizer(
300
+ [
301
+ 'redact',
302
+ '-',
303
+ '--stream',
304
+ ...OPTIONS,
305
+ '--max-bytes',
306
+ String(size),
307
+ '--output',
308
+ target,
309
+ ],
310
+ { env: options.env, stdio: [input, 'ignore', 'pipe'] }
311
+ );
312
+ } finally {
313
+ fs.closeSync(input);
314
+ }
315
+ code = 'ERR_OUTPUT';
316
+ const status = fs.lstatSync(target);
317
+ if (
318
+ !status.isFile() ||
319
+ (process.platform !== 'win32' && (status.mode & 0o077) !== 0)
320
+ ) {
321
+ throw new SanitizationError('ERR_OUTPUT');
322
+ }
231
323
  if (options.verbose) {
324
+ const redactions = countMarkers(target) - countMarkers(sourcePath);
232
325
  console.log(
233
- `Prepared private sanitized log: ${bytes} bytes scanned, ${sanitizer.redactions} redactions.`
326
+ `Prepared private sanitized log: ${bytes} bytes scanned, ${Math.max(0, redactions)} redactions.`
234
327
  );
235
328
  }
236
- ready = true;
237
- return {
238
- path: target,
239
- cleanup: () => fs.rmSync(directory, { recursive: true, force: true }),
240
- };
241
- } catch {
329
+ fs.rmSync(stagePath, { force: true });
330
+ return { path: target, cleanup };
331
+ } catch (error) {
332
+ cleanup();
333
+ // Static messages keep paths and system error details out of the output.
334
+ const failure =
335
+ error instanceof SanitizationError ? error : new SanitizationError(code);
242
336
  if (options.verbose) {
243
- console.log(`Log sanitization failed at stage: ${stage}.`);
244
- }
245
- // Do not echo error messages: paths and exception details can carry secrets.
246
- throw new Error('Log sanitization failed; upload blocked.');
247
- } finally {
248
- for (const fd of [destination, source]) {
249
- if (fd !== undefined) {
250
- try {
251
- fs.closeSync(fd);
252
- } catch {
253
- /* Best effort after failure. */
254
- }
255
- }
256
- }
257
- if (directory && !ready) {
258
- try {
259
- fs.rmSync(directory, { recursive: true, force: true });
260
- } catch {
261
- /* Upload remains blocked. */
262
- }
337
+ console.error(failure.message);
263
338
  }
339
+ throw failure;
264
340
  }
265
341
  }
266
342
 
267
- module.exports = { StreamingSanitizer, sanitizeLogToTemp };
343
+ module.exports = {
344
+ AuthorizationHeaderMask,
345
+ BLOCK_SIZE,
346
+ SanitizationError,
347
+ resolveSanitizerCommand,
348
+ sanitizeLogToTemp,
349
+ sanitizeText,
350
+ };
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
8
  const { spawnSync } = require('child_process');
9
- const { sanitizeLogToTemp } = require('./log-sanitizer');
9
+ const { SanitizationError, sanitizeLogToTemp } = require('./log-sanitizer');
10
10
 
11
11
  function isExecutable(filePath) {
12
12
  try {
@@ -174,11 +174,14 @@ function uploadLogPath(logPath, options = {}) {
174
174
  };
175
175
  }
176
176
  return { success: true, exitCode: 0 };
177
- } catch {
177
+ } catch (error) {
178
178
  return {
179
179
  success: false,
180
180
  exitCode: 1,
181
- error: 'Log preparation or upload failed; upload blocked.',
181
+ error:
182
+ error instanceof SanitizationError
183
+ ? error.message
184
+ : 'Log preparation or upload failed; upload blocked.',
182
185
  };
183
186
  } finally {
184
187
  if (prepared) {
@@ -6,156 +6,238 @@ const os = require('os');
6
6
  const path = require('path');
7
7
  const { execFileSync } = require('child_process');
8
8
  const {
9
+ AuthorizationHeaderMask,
10
+ SanitizationError,
11
+ resolveSanitizerCommand,
9
12
  sanitizeLogToTemp,
10
- StreamingSanitizer,
13
+ sanitizeText,
11
14
  } = require('../src/lib/log-sanitizer');
12
15
 
16
+ const GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-gh-'));
17
+
18
+ // Only what the runtime needs, so the host's own credential-named variables
19
+ // and gh login cannot change the expected output.
20
+ function testEnv(extra = {}) {
21
+ const env = { GH_CONFIG_DIR };
22
+ for (const name of [
23
+ 'PATH',
24
+ 'Path',
25
+ 'SystemRoot',
26
+ 'SYSTEMROOT',
27
+ 'TEMP',
28
+ 'TMP',
29
+ 'TMPDIR',
30
+ 'HOME',
31
+ 'USERPROFILE',
32
+ ]) {
33
+ if (process.env[name] !== undefined) {
34
+ env[name] = process.env[name];
35
+ }
36
+ }
37
+ return { ...env, ...extra };
38
+ }
39
+
40
+ function withLog(content, callback) {
41
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-test-'));
42
+ const source = path.join(dir, 'source.log');
43
+ fs.writeFileSync(source, content);
44
+ try {
45
+ return callback(source, dir);
46
+ } finally {
47
+ fs.rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ }
50
+
51
+ function sanitizeFile(content, env = testEnv()) {
52
+ return withLog(content, (source) => {
53
+ const copy = sanitizeLogToTemp(source, { env });
54
+ try {
55
+ return fs.readFileSync(copy.path);
56
+ } finally {
57
+ copy.cleanup();
58
+ }
59
+ });
60
+ }
61
+
62
+ function mask(input, blockSize) {
63
+ const authorization = new AuthorizationHeaderMask();
64
+ const blocks = [];
65
+ for (let offset = 0; offset < input.length; offset += blockSize) {
66
+ blocks.push(authorization.push(input.subarray(offset, offset + blockSize)));
67
+ assert.ok(authorization.pending.length <= blockSize + 160);
68
+ }
69
+ blocks.push(authorization.push(Buffer.alloc(0), true));
70
+ return Buffer.concat(blocks).toString();
71
+ }
72
+
73
+ // The three lines from issue #249, which the old rules truncated.
74
+ const ISSUE_249_LINES = [
75
+ "Command: gate='/tmp/hive-mind-disk-baseline-0ce9e170-e6d3-494d-91e1-0d820245d34b'",
76
+ '🧠 Formal AI: endpoint http://172.18.0.2:8080/api/openai/v1',
77
+ '{"bodyPreview":"{\\"messages\\":[{\\"content\\":\\"use: curl -L -H \\\\\\"Authorization: token $(gh auth token)\\\\\\" -o <filename> <url>\\\\n...the rest of a 100 KB prompt...\\"}]}"}',
78
+ 'proxy http://myproxy:3128 and http://127.0.0.1:34271/session',
79
+ 'Authorization: [REDACTED] stays as it is',
80
+ 'account konard@example.com https://github.com/konard',
81
+ ];
82
+
13
83
  function credentials() {
14
84
  return [
15
85
  ...'pousr'.split('').map((kind) => `gh${kind}_${'a'.repeat(36)}`),
16
86
  `github_` + `pat_${'b'.repeat(82)}`,
17
87
  `sk-${'c'.repeat(48)}`,
18
88
  `sk-` + `proj-${'d'.repeat(90)}`,
19
- `sk-` + `ant-api03-${'e'.repeat(90)}`,
20
89
  `AK` + `IA${'F'.repeat(16)}`,
21
90
  `AS` + `IA${'G'.repeat(16)}`,
22
91
  'ey' + 'JhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature',
23
- 'Authorization: Bearer arbitrary-credential',
24
- 'Proxy-Authorization: Basic encoded-credential',
25
- 'https://user:password@example.test/repo.git',
26
- 'AWS_SECRET_ACCESS_KEY=arbitrary-aws-secret',
92
+ 'arbitrary-credential',
93
+ 'encoded-credential',
94
+ 'password',
95
+ 'arbitrary-aws-secret',
27
96
  'custom-secret-value',
28
- `${Buffer.from(' \n{"alg":"HS256"}').toString('base64url')}.${Buffer.from('{"sub":"x"}').toString('base64url')}.signature`,
29
97
  ];
30
98
  }
31
99
 
32
- function sanitize(input, blockSize) {
33
- const sanitizer = new StreamingSanitizer({
34
- START_TEST_SECRET: 'custom-secret-value',
100
+ describe('log sanitization', () => {
101
+ it('keeps every line from issue #249 and public account details', () => {
102
+ const input = `${ISSUE_249_LINES.join('\n')}\n`;
103
+ assert.strictEqual(sanitizeFile(input).toString(), input);
35
104
  });
36
- const blocks = [];
37
- for (let offset = 0; offset < input.length; offset += blockSize) {
38
- blocks.push(sanitizer.push(input.subarray(offset, offset + blockSize)));
39
- }
40
- blocks.push(sanitizer.push(Buffer.alloc(0), true));
41
- return Buffer.concat(blocks);
42
- }
43
105
 
44
- describe('log sanitization', () => {
45
- it('redacts required formats and environment values at every chunk boundary', () => {
46
- const secrets = credentials();
47
- for (const size of [1, 7, 31, 256, 1024, 65536]) {
48
- const input = Buffer.from(
49
- `ordinary utf8: café 😀\n${' '.repeat(Math.max(0, size - 43))}${secrets.join('\n')}\n${'padding\n'.repeat(400)}finished\n`
50
- );
51
- const output = sanitize(input, size).toString();
52
- for (const secret of secrets) {
53
- assert.ok(!output.includes(secret));
54
- }
55
- for (const value of [
56
- 'arbitrary-credential',
57
- 'encoded-credential',
58
- 'password',
59
- 'arbitrary-aws-secret',
60
- ]) {
61
- assert.ok(!output.includes(value));
62
- }
63
- assert.ok(output.startsWith('ordinary utf8: café 😀\n'));
64
- assert.ok(output.endsWith('\nfinished\n'));
106
+ it('redacts credentials and credential-named environment values', () => {
107
+ const input = [
108
+ ...credentials().slice(0, 9),
109
+ 'Authorization: Bearer arbitrary-credential',
110
+ 'Proxy-Authorization: Basic encoded-credential',
111
+ 'clone https://user:password@example.test/repo.git',
112
+ 'AWS_SECRET_ACCESS_KEY=arbitrary-aws-secret',
113
+ 'value custom-secret-value end',
114
+ 'ordinary utf8: café 😀',
115
+ '',
116
+ ].join('\n');
117
+ const output = sanitizeFile(
118
+ input,
119
+ testEnv({ START_TEST_SECRET: 'custom-secret-value' })
120
+ ).toString();
121
+ for (const [index, secret] of credentials().entries()) {
122
+ assert.ok(!output.includes(secret), `leaked secret #${index}`);
123
+ }
124
+ for (const kept of [
125
+ 'Authorization: [REDACTED]\n',
126
+ 'Proxy-Authorization: [REDACTED]\n',
127
+ 'clone https://[REDACTED]@example.test/repo.git\n',
128
+ 'value [REDACTED] end\n',
129
+ 'ordinary utf8: café 😀\n',
130
+ ]) {
131
+ assert.ok(output.includes(kept), `missing ${kept}`);
65
132
  }
66
133
  });
67
134
 
68
- it('bounds memory for long tokens and authorization values without releasing suffixes', () => {
69
- for (const prefix of ['gh' + 'p_', 'Authorization: Bearer ']) {
70
- const sanitizer = new StreamingSanitizer({});
71
- let output = sanitizer.push(Buffer.from(prefix));
72
- for (let i = 0; i < 128; i++) {
73
- output = Buffer.concat([
74
- output,
75
- sanitizer.push(Buffer.alloc(16384, 97)),
76
- ]);
77
- assert.ok(sanitizer.pendingBytes <= 65536);
78
- }
79
- output = Buffer.concat([
80
- output,
81
- sanitizer.push(Buffer.from('\nordinary\n'), true),
82
- ]);
83
- assert.strictEqual(output.toString(), '[REDACTED]\nordinary\n');
135
+ it('masks only the Authorization scheme and token, keeping the rest of the line (#249)', () => {
136
+ const cases = [
137
+ [
138
+ `curl -H 'Authorization: Basic dXNlcjpwYXNz' -o out.txt url`,
139
+ `curl -H 'Authorization: [REDACTED]' -o out.txt url`,
140
+ ],
141
+ [
142
+ 'Authorization: Bearer abc rest of line',
143
+ 'Authorization: [REDACTED] rest of line',
144
+ ],
145
+ [
146
+ 'Authorization: token abc, other: keep',
147
+ 'Authorization: [REDACTED], other: keep',
148
+ ],
149
+ [
150
+ '{"h":"Authorization: Bearer abc\\" tail","next":"keep"}',
151
+ '{"h":"Authorization: [REDACTED]\\" tail","next":"keep"}',
152
+ ],
153
+ [
154
+ 'Authorization: token $(gh auth token) and ${GITHUB_TOKEN}',
155
+ 'Authorization: token $(gh auth token) and ${GITHUB_TOKEN}',
156
+ ],
157
+ ['Authorization: Bearer <token>', 'Authorization: Bearer <token>'],
158
+ ];
159
+ const input = cases.map(([line]) => line).join('\n');
160
+ const expected = cases.map(([, line]) => line).join('\n');
161
+ for (const size of [1, 7, 31, 256, 65536]) {
162
+ assert.strictEqual(mask(Buffer.from(input), size), expected);
84
163
  }
164
+ // The library itself masks the scheme before a placeholder; nothing is lost.
165
+ assert.strictEqual(
166
+ sanitizeFile(input).toString(),
167
+ expected.replace('Bearer <token>', '[REDACTED] <token>')
168
+ );
85
169
  });
86
170
 
87
- it('retains full header continuation when an inner token reaches the same block boundary', () => {
88
- const sanitizer = new StreamingSanitizer({});
171
+ it('masks long Authorization tokens across blocks with bounded memory', () => {
89
172
  const input = Buffer.from(
90
- `Authorization: Bearer gh${'p_'}${'a'.repeat(131072)} trailing-private-value\nordinary\n`
173
+ `Authorization: Bearer ${'a'.repeat(200000)} tail\nordinary\n`
91
174
  );
92
- const output = [];
93
- for (let offset = 0; offset < input.length; offset += 65536) {
94
- output.push(sanitizer.push(input.subarray(offset, offset + 65536)));
95
- }
96
- output.push(sanitizer.push(Buffer.alloc(0), true));
97
175
  assert.strictEqual(
98
- Buffer.concat(output).toString(),
99
- '[REDACTED]\nordinary\n'
176
+ mask(input, 65536),
177
+ 'Authorization: [REDACTED] tail\nordinary\n'
178
+ );
179
+ assert.strictEqual(
180
+ sanitizeFile(input).toString(),
181
+ 'Authorization: [REDACTED] tail\nordinary\n'
100
182
  );
101
183
  });
102
184
 
103
- it('redacts AWS assignment values enclosed in either quote style', () => {
104
- const sanitizer = new StreamingSanitizer({});
105
- const output = sanitizer
106
- .push(
107
- Buffer.from(
108
- `AWS_SECRET_ACCESS_KEY="quoted-aws-secret"\nAWS_SESSION_TOKEN='quoted-session-value'\n`
109
- ),
110
- true
111
- )
112
- .toString();
113
- assert.ok(!output.includes('quoted-aws-secret'));
114
- assert.ok(!output.includes('quoted-session-value'));
115
- });
116
-
117
- it('redacts multiline and short known secrets, preserving unrelated bytes', () => {
118
- const sanitizer = new StreamingSanitizer({
119
- GH_TOKEN: 'abc\nxyz',
120
- TEST_PASSWORD: '!',
121
- });
122
- const output = sanitizer
123
- .push(Buffer.from('safe abc\nxyz value !'), true)
124
- .toString();
125
- assert.strictEqual(output, 'safe [REDACTED] value [REDACTED]');
185
+ it('preserves invalid UTF-8 bytes and line endings', () => {
186
+ const input = Buffer.concat([
187
+ Buffer.from('bad '),
188
+ Buffer.from([0xff, 0xfe, 0xc3]),
189
+ Buffer.from(` gh${'p_'}${'x'.repeat(36)}\r\nnext\rend`),
190
+ ]);
191
+ const expected = Buffer.concat([
192
+ Buffer.from('bad '),
193
+ Buffer.from([0xff, 0xfe, 0xc3]),
194
+ Buffer.from(' [REDACTED]\r\nnext\rend'),
195
+ ]);
196
+ assert.deepStrictEqual(sanitizeFile(input), expected);
197
+ assert.deepStrictEqual(sanitizeFile(Buffer.alloc(0)), Buffer.alloc(0));
126
198
  });
127
199
 
128
- it('merges overlapping environment secrets and preserves ordinary domain names', () => {
129
- const sanitizer = new StreamingSanitizer({
130
- GH_TOKEN: 'abcde',
131
- TEST_PASSWORD: 'cdefg',
132
- GITHUB_PAT: 'opaque-known-pat',
133
- });
200
+ it('sanitizes issue text with the same rules', () => {
201
+ const token = `gh${'p_'}${'a'.repeat(36)}`;
134
202
  assert.strictEqual(
135
- sanitizer
136
- .push(
137
- Buffer.from('abcdefg opaque-known-pat example.test.invalid'),
138
- true
139
- )
140
- .toString(),
141
- '[REDACTED] [REDACTED] example.test.invalid'
203
+ sanitizeText(`echo ${token} 😀 'Authorization: token abc' -o x`, {
204
+ env: testEnv(),
205
+ }),
206
+ `echo [REDACTED] 😀 'Authorization: [REDACTED]' -o x`
142
207
  );
143
- const short = new StreamingSanitizer({ GH_TOKEN: 'a' });
144
208
  assert.strictEqual(
145
- short.push(Buffer.alloc(65536, 97), true).toString(),
146
- '[REDACTED]'
209
+ sanitizeText('echo "quoted" $(id) `hostname`', { env: testEnv() }),
210
+ 'echo "quoted" $(id) `hostname`'
211
+ );
212
+ });
213
+
214
+ it('resolves the override first and the bundled library otherwise', () => {
215
+ assert.deepStrictEqual(
216
+ resolveSanitizerCommand({ START_SENSITIVE_DATA_SANITIZER: '/x/sds' }),
217
+ { command: '/x/sds', args: [] }
218
+ );
219
+ const bundled = resolveSanitizerCommand({});
220
+ assert.strictEqual(bundled.command, process.execPath);
221
+ assert.ok(
222
+ bundled.args[0].endsWith(
223
+ path.join(
224
+ 'sensitive-data-sanitizer',
225
+ 'bin',
226
+ 'sensitive-data-sanitizer.js'
227
+ )
228
+ )
147
229
  );
148
230
  });
149
231
 
150
232
  it('creates a private sanitized copy and removes it without changing the source', () => {
151
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-test-'));
152
- const source = path.join(dir, 'source.log');
153
233
  const input = `gh` + `p_${'x'.repeat(36)}`;
154
- fs.writeFileSync(source, input);
155
- try {
156
- const copy = sanitizeLogToTemp(source, { env: {} });
234
+ withLog(input, (source) => {
235
+ const copy = sanitizeLogToTemp(source, { env: testEnv() });
157
236
  assert.notStrictEqual(copy.path, source);
158
237
  assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), '[REDACTED]');
238
+ assert.deepStrictEqual(fs.readdirSync(path.dirname(copy.path)), [
239
+ 'execution.log',
240
+ ]);
159
241
  if (process.platform !== 'win32') {
160
242
  assert.strictEqual(fs.statSync(copy.path).mode & 0o777, 0o600);
161
243
  assert.strictEqual(
@@ -165,23 +247,17 @@ describe('log sanitization', () => {
165
247
  }
166
248
  assert.strictEqual(fs.readFileSync(source, 'utf8'), input);
167
249
  copy.cleanup();
168
- assert.ok(!fs.existsSync(copy.path));
169
- } finally {
170
- fs.rmSync(dir, { recursive: true, force: true });
171
- }
250
+ assert.ok(!fs.existsSync(path.dirname(copy.path)));
251
+ });
172
252
  });
173
253
 
174
- it('enforces private permissions at creation even with a permissive umask', () => {
254
+ it('keeps private permissions even with a permissive umask', () => {
175
255
  if (process.platform === 'win32') {
176
256
  return;
177
257
  }
178
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-creation-'));
179
- const source = path.join(dir, 'source.log');
180
- fs.writeFileSync(source, 'ordinary output');
181
258
  const originalOpen = fs.openSync;
182
259
  const originalUmask = process.umask(0);
183
260
  let creationChecked = false;
184
- let copy;
185
261
  fs.openSync = (...args) => {
186
262
  if (args[1] !== 'wx') {
187
263
  return originalOpen(...args);
@@ -196,26 +272,104 @@ describe('log sanitization', () => {
196
272
  return descriptor;
197
273
  };
198
274
  try {
199
- copy = sanitizeLogToTemp(source, { env: {} });
200
- assert.ok(creationChecked);
201
- assert.strictEqual(fs.readFileSync(copy.path, 'utf8'), 'ordinary output');
275
+ withLog('ordinary output', (source) => {
276
+ const copy = sanitizeLogToTemp(source, { env: testEnv() });
277
+ try {
278
+ assert.ok(creationChecked);
279
+ assert.strictEqual(fs.statSync(copy.path).mode & 0o777, 0o600);
280
+ assert.strictEqual(
281
+ fs.readFileSync(copy.path, 'utf8'),
282
+ 'ordinary output'
283
+ );
284
+ } finally {
285
+ copy.cleanup();
286
+ }
287
+ });
202
288
  } finally {
203
289
  fs.openSync = originalOpen;
204
290
  process.umask(originalUmask);
205
- copy?.cleanup();
206
- fs.rmSync(dir, { recursive: true, force: true });
207
291
  }
208
292
  });
209
293
 
210
- it('fails closed on invalid source and excessive environment secret sizes', () => {
211
- assert.throws(
212
- () => sanitizeLogToTemp('/does-not-exist', { env: {} }),
213
- /sanitization failed/i
214
- );
294
+ it('reports scanned bytes and added redactions in verbose mode only', () => {
295
+ const messages = [];
296
+ const originalLog = console.log;
297
+ console.log = (message) => messages.push(message);
298
+ try {
299
+ withLog(`[REDACTED] gh${'p_'}${'x'.repeat(36)}\n`, (source) => {
300
+ sanitizeLogToTemp(source, { env: testEnv() }).cleanup();
301
+ sanitizeLogToTemp(source, { env: testEnv(), verbose: true }).cleanup();
302
+ });
303
+ } finally {
304
+ console.log = originalLog;
305
+ }
306
+ assert.deepStrictEqual(messages, [
307
+ 'Prepared private sanitized log: 52 bytes scanned, 1 redactions.',
308
+ ]);
309
+ });
310
+
311
+ it('fails closed with value-free codes and leaves no copy behind', () => {
312
+ const before = fs
313
+ .readdirSync(os.tmpdir())
314
+ .filter((name) => name.startsWith('start-sanitized-')).length;
315
+ const attempt = (source, env = testEnv()) => {
316
+ try {
317
+ sanitizeLogToTemp(source, { env }).cleanup();
318
+ } catch (error) {
319
+ assert.ok(error instanceof SanitizationError);
320
+ return error;
321
+ }
322
+ assert.fail('sanitization must fail');
323
+ };
324
+ assert.match(attempt('/does-not-exist').message, /\(ERR_SOURCE\)/);
325
+ assert.match(attempt(os.tmpdir()).message, /\(ERR_SOURCE\)/);
326
+ withLog('secret\0binary', (source, dir) => {
327
+ // The library rejects NUL bytes rather than guessing at binary content.
328
+ assert.strictEqual(attempt(source).code, 'ERR_BINARY');
329
+ assert.strictEqual(
330
+ attempt(
331
+ source,
332
+ testEnv({
333
+ START_SENSITIVE_DATA_SANITIZER: path.join(dir, 'missing'),
334
+ })
335
+ ).code,
336
+ 'ERR_UNAVAILABLE'
337
+ );
338
+ if (process.platform !== 'win32') {
339
+ const failing = path.join(dir, 'failing-sanitizer');
340
+ fs.writeFileSync(
341
+ failing,
342
+ '#!/bin/sh\necho "secret-from-stderr (ERR_WORKER)" >&2\nexit 2\n',
343
+ { mode: 0o755 }
344
+ );
345
+ const error = attempt(
346
+ source,
347
+ testEnv({ START_SENSITIVE_DATA_SANITIZER: failing })
348
+ );
349
+ assert.strictEqual(
350
+ error.message,
351
+ 'Log sanitization failed (ERR_WORKER); upload blocked.'
352
+ );
353
+ // A sanitizer that succeeds without writing the output blocks too.
354
+ fs.writeFileSync(failing, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
355
+ assert.strictEqual(
356
+ attempt(source, testEnv({ START_SENSITIVE_DATA_SANITIZER: failing }))
357
+ .code,
358
+ 'ERR_OUTPUT'
359
+ );
360
+ }
361
+ });
215
362
  assert.throws(
216
- () => new StreamingSanitizer({ GH_TOKEN: 'a'.repeat(65537) }),
217
- /too large/i
363
+ () =>
364
+ sanitizeText('x', {
365
+ env: testEnv({ START_SENSITIVE_DATA_SANITIZER: '/does-not-exist' }),
366
+ }),
367
+ /^Error: Text sanitization failed \(ERR_UNAVAILABLE\); publication blocked\.$/
218
368
  );
369
+ const after = fs
370
+ .readdirSync(os.tmpdir())
371
+ .filter((name) => name.startsWith('start-sanitized-')).length;
372
+ assert.ok(after <= before);
219
373
  });
220
374
 
221
375
  it('blocks both upload paths on read or write failure and does not expose exception text', () => {
@@ -234,7 +388,12 @@ describe('log sanitization', () => {
234
388
  throw new Error('sensitive error details');
235
389
  };
236
390
  try {
237
- assert.strictEqual(uploadLogPath(source).success, false);
391
+ const result = uploadLogPath(source);
392
+ assert.strictEqual(result.success, false);
393
+ assert.strictEqual(
394
+ result.error,
395
+ 'Log sanitization failed (ERR_SOURCE); upload blocked.'
396
+ );
238
397
  assert.strictEqual(uploadLog(source), null);
239
398
  } finally {
240
399
  fs[method] = original;
@@ -245,6 +404,11 @@ describe('log sanitization', () => {
245
404
  (message) => !message.includes('sensitive error details')
246
405
  )
247
406
  );
407
+ assert.ok(
408
+ messages.includes(
409
+ 'Warning: Log sanitization failed (ERR_SOURCE); upload blocked.'
410
+ )
411
+ );
248
412
  } finally {
249
413
  console.log = originalLog;
250
414
  fs.rmSync(dir, { recursive: true, force: true });
@@ -257,7 +421,9 @@ describe('log sanitization', () => {
257
421
  }
258
422
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-sanitize-test-'));
259
423
  const log = path.join(dir, 'source.log');
260
- fs.writeFileSync(log, `gh` + `p_${'x'.repeat(36)}`);
424
+ const line =
425
+ "Command: gate='/tmp/hive-mind-disk-baseline-0ce9e170' http://172.18.0.2:8080/v1";
426
+ fs.writeFileSync(log, `${line} gh` + `p_${'x'.repeat(36)}`);
261
427
  fs.writeFileSync(
262
428
  path.join(dir, 'gh-upload-log'),
263
429
  '#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE_ARGS"\ncat "$1" > "$CAPTURE_LOG"\nls -ld "$1" | cut -c1-10 > "$CAPTURE_MODE"\necho https://gist.github.com/test/id\n',
@@ -270,15 +436,14 @@ describe('log sanitization', () => {
270
436
  ? `require(${JSON.stringify(require.resolve('../src/lib/failure-handler'))}).uploadLog(process.env.SOURCE_LOG)`
271
437
  : `require(${JSON.stringify(require.resolve('../src/lib/log-uploader'))}).uploadLogPath(process.env.SOURCE_LOG, { noSanitize: ${kind === 'optout'} })`;
272
438
  execFileSync(process.execPath, ['-e', script], {
273
- env: {
274
- ...process.env,
439
+ env: testEnv({
275
440
  PATH: `${dir}${path.delimiter}${process.env.PATH}`,
276
441
  SOURCE_LOG: log,
277
442
  CAPTURE_ARGS: path.join(dir, 'args'),
278
443
  CAPTURE_LOG: path.join(dir, 'uploaded'),
279
444
  CAPTURE_MODE: path.join(dir, 'mode'),
280
445
  GH_UPLOAD_PUBLIC: 'true',
281
- },
446
+ }),
282
447
  stdio: 'pipe',
283
448
  });
284
449
  const args = fs
@@ -288,7 +453,9 @@ describe('log sanitization', () => {
288
453
  assert.strictEqual(args[1], '--private');
289
454
  assert.strictEqual(
290
455
  fs.readFileSync(path.join(dir, 'uploaded'), 'utf8'),
291
- kind === 'optout' ? fs.readFileSync(log, 'utf8') : '[REDACTED]'
456
+ kind === 'optout'
457
+ ? fs.readFileSync(log, 'utf8')
458
+ : `${line} [REDACTED]`
292
459
  );
293
460
  if (kind !== 'optout') {
294
461
  assert.notStrictEqual(args[0], log);