start-command 0.39.0 → 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.
@@ -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) {