start-command 0.39.1 → 0.40.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.
@@ -1,267 +1,229 @@
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
+ * Since 0.12.1 the library masks only the scheme and token of an
12
+ * `Authorization: <scheme> <value>` header, so a logged one-line JSON prompt
13
+ * keeps the rest of its line (issue #249, sensitive-data-sanitizer#45); the
14
+ * pre-pass that did this here is gone (issue #253).
15
+ */
16
+
2
17
  const fs = require('fs');
3
18
  const os = require('os');
4
19
  const path = require('path');
20
+ const { spawnSync } = require('child_process');
5
21
 
6
22
  const BLOCK_SIZE = 64 * 1024;
7
23
  const MARKER = '[REDACTED]';
8
- const SECRET_ENV_NAME =
9
- /(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)/i;
10
-
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
- ];
24
+ const PACKAGE = '@link-foundation/sensitive-data-sanitizer';
25
+ const COMMAND = 'sensitive-data-sanitizer';
26
+ const OVERRIDE = 'START_SENSITIVE_DATA_SANITIZER';
27
+ const OPTIONS = ['--credentials-only', '--env-secrets', '--gh-config'];
55
28
 
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
29
+ /** Value-free failure: `code` is a fixed ERR_* name, never log content. */
30
+ class SanitizationError extends Error {
31
+ constructor(code, subject = 'Log') {
32
+ super(
33
+ `${subject} sanitization failed (${code}); ${subject === 'Log' ? 'upload' : 'publication'} blocked.`
78
34
  );
79
- this.pending = '';
80
- this.continuation = null;
81
- this.redactions = 0;
35
+ this.code = code;
82
36
  }
37
+ }
83
38
 
84
- get pendingBytes() {
85
- return this.pending.length;
39
+ function isFile(filePath) {
40
+ try {
41
+ return fs.statSync(filePath).isFile();
42
+ } catch {
43
+ return false;
86
44
  }
45
+ }
87
46
 
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
- }
104
- }
105
- }
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)
111
- ) {
112
- matches.push({ index, end: index + secret.length });
113
- }
47
+ function findBundledCli() {
48
+ // Walk up node_modules directories the way require() does; the package
49
+ // exports only ESM, so require.resolve() cannot locate its files.
50
+ const relative = path.join(PACKAGE, 'bin', `${COMMAND}.js`);
51
+ let directory = __dirname;
52
+ for (;;) {
53
+ const candidate = path.join(directory, 'node_modules', relative);
54
+ if (isFile(candidate)) {
55
+ return candidate;
114
56
  }
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 });
133
- }
57
+ const parent = path.dirname(directory);
58
+ if (parent === directory) {
59
+ return null;
134
60
  }
135
- return merged;
61
+ directory = parent;
136
62
  }
63
+ }
137
64
 
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;
151
- }
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;
65
+ /**
66
+ * Resolve the library CLI: START_SENSITIVE_DATA_SANITIZER, then the bundled
67
+ * dependency run by the current runtime, then sensitive-data-sanitizer on PATH.
68
+ * @returns {{command: string, args: string[]}|null}
69
+ */
70
+ function resolveSanitizerCommand(env = process.env) {
71
+ if (env[OVERRIDE]) {
72
+ return { command: env[OVERRIDE], args: [] };
73
+ }
74
+ const bundled = findBundledCli();
75
+ if (bundled) {
76
+ return { command: process.execPath, args: [bundled] };
77
+ }
78
+ const { resolveCommand } = require('./log-uploader');
79
+ const command = resolveCommand(COMMAND);
80
+ return command ? { command, args: [] } : null;
81
+ }
82
+
83
+ function runSanitizer(args, options, subject) {
84
+ const env = options.env || process.env;
85
+ const resolved = resolveSanitizerCommand(env);
86
+ if (!resolved) {
87
+ throw new SanitizationError('ERR_UNAVAILABLE', subject);
88
+ }
89
+ const result = spawnSync(resolved.command, [...resolved.args, ...args], {
90
+ env,
91
+ input: options.input,
92
+ stdio: options.stdio || ['pipe', 'pipe', 'pipe'],
93
+ maxBuffer: 64 * 1024 * 1024,
94
+ shell:
95
+ process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command),
96
+ windowsHide: true,
97
+ });
98
+ if (result.error) {
99
+ throw new SanitizationError('ERR_UNAVAILABLE', subject);
100
+ }
101
+ if (result.status !== 0) {
102
+ // The library prints value-free messages; only their ERR_* code is kept.
103
+ const code = String(result.stderr || '').match(/\bERR_[A-Z_]+\b/);
104
+ throw new SanitizationError(code ? code[0] : 'ERR_SANITIZER', subject);
105
+ }
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Sanitize a short text, such as the failing command quoted in an issue.
111
+ * @returns {string}
112
+ */
113
+ function sanitizeText(text, options = {}) {
114
+ const result = runSanitizer(
115
+ ['redact', '-', ...OPTIONS],
116
+ { ...options, input: text },
117
+ 'Text'
118
+ );
119
+ return result.stdout.toString();
120
+ }
121
+
122
+ function countMarkers(filePath) {
123
+ const descriptor = fs.openSync(filePath, 'r');
124
+ const buffer = Buffer.alloc(BLOCK_SIZE);
125
+ let count = 0;
126
+ let carry = '';
127
+ try {
128
+ for (;;) {
129
+ const length = fs.readSync(descriptor, buffer, 0, BLOCK_SIZE, null);
130
+ const text = carry + buffer.toString('latin1', 0, length);
131
+ let index = text.indexOf(MARKER);
132
+ let next = 0;
133
+ while (index !== -1) {
134
+ count++;
135
+ next = index + MARKER.length;
136
+ index = text.indexOf(MARKER, next);
160
137
  }
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;
138
+ if (length === 0) {
139
+ return count;
167
140
  }
141
+ carry = text.slice(Math.max(next, text.length - MARKER.length + 1));
168
142
  }
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');
143
+ } finally {
144
+ fs.closeSync(descriptor);
173
145
  }
174
146
  }
175
147
 
176
- /** Returns a private copy plus cleanup; every preparation error blocks upload. */
148
+ /**
149
+ * Create a private sanitized copy of a log. The source is never modified;
150
+ * any failure removes the copy and throws a value-free SanitizationError.
151
+ * @returns {{path: string, cleanup: Function}}
152
+ */
177
153
  function sanitizeLogToTemp(sourcePath, options = {}) {
178
154
  let directory;
179
- let source;
180
- let destination;
181
- let ready = false;
182
- let stage = 'environment';
183
- try {
184
- const sanitizer = new StreamingSanitizer(options.env || process.env);
185
- stage = 'source';
186
- source = fs.openSync(sourcePath, 'r');
187
- if (!fs.fstatSync(source).isFile()) {
188
- throw new Error('Source must be a regular file.');
155
+ let code = 'ERR_PRIVATE_FILE';
156
+ const cleanup = () => {
157
+ if (directory) {
158
+ fs.rmSync(directory, { recursive: true, force: true });
189
159
  }
190
- stage = 'private-file';
160
+ };
161
+ try {
191
162
  directory = fs.mkdtempSync(path.join(os.tmpdir(), 'start-sanitized-'));
192
- fs.chmodSync(directory, 0o700);
163
+ if (process.platform !== 'win32') {
164
+ fs.chmodSync(directory, 0o700);
165
+ }
193
166
  const target = path.join(directory, 'execution.log');
194
- destination = fs.openSync(target, 'wx', 0o600);
195
- fs.fchmodSync(destination, 0o600);
196
- const buffer = Buffer.alloc(BLOCK_SIZE);
197
- 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
- for (;;) {
215
- stage = 'read';
216
- const length = fs.readSync(source, buffer, 0, buffer.length, null);
217
- if (!length) {
218
- break;
167
+ code = 'ERR_SOURCE';
168
+ const input = fs.openSync(sourcePath, 'r');
169
+ let bytes;
170
+ try {
171
+ const source = fs.fstatSync(input);
172
+ if (!source.isFile()) {
173
+ throw new SanitizationError('ERR_SOURCE');
219
174
  }
220
- bytes += length;
221
- stage = 'scan';
222
- write(sanitizer.push(buffer.subarray(0, length)));
175
+ bytes = source.size;
176
+ const size = Math.max(1, bytes);
177
+ code = 'ERR_PRIVATE_FILE';
178
+ runSanitizer(
179
+ [
180
+ 'redact',
181
+ '-',
182
+ '--stream',
183
+ // A NUL byte in a terminal transcript must not block the upload.
184
+ '--allow-binary',
185
+ ...OPTIONS,
186
+ '--max-bytes',
187
+ String(size),
188
+ '--output',
189
+ target,
190
+ ],
191
+ { env: options.env, stdio: [input, 'ignore', 'pipe'] }
192
+ );
193
+ } finally {
194
+ fs.closeSync(input);
195
+ }
196
+ code = 'ERR_OUTPUT';
197
+ const status = fs.lstatSync(target);
198
+ if (
199
+ !status.isFile() ||
200
+ (process.platform !== 'win32' && (status.mode & 0o077) !== 0)
201
+ ) {
202
+ throw new SanitizationError('ERR_OUTPUT');
223
203
  }
224
- write(sanitizer.push(Buffer.alloc(0), true));
225
- stage = 'sync';
226
- fs.fsyncSync(destination);
227
- fs.closeSync(destination);
228
- destination = undefined;
229
- fs.closeSync(source);
230
- source = undefined;
231
204
  if (options.verbose) {
205
+ const redactions = countMarkers(target) - countMarkers(sourcePath);
232
206
  console.log(
233
- `Prepared private sanitized log: ${bytes} bytes scanned, ${sanitizer.redactions} redactions.`
207
+ `Prepared private sanitized log: ${bytes} bytes scanned, ${Math.max(0, redactions)} redactions.`
234
208
  );
235
209
  }
236
- ready = true;
237
- return {
238
- path: target,
239
- cleanup: () => fs.rmSync(directory, { recursive: true, force: true }),
240
- };
241
- } catch {
210
+ return { path: target, cleanup };
211
+ } catch (error) {
212
+ cleanup();
213
+ // Static messages keep paths and system error details out of the output.
214
+ const failure =
215
+ error instanceof SanitizationError ? error : new SanitizationError(code);
242
216
  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
- }
217
+ console.error(failure.message);
263
218
  }
219
+ throw failure;
264
220
  }
265
221
  }
266
222
 
267
- module.exports = { StreamingSanitizer, sanitizeLogToTemp };
223
+ module.exports = {
224
+ BLOCK_SIZE,
225
+ SanitizationError,
226
+ resolveSanitizerCommand,
227
+ sanitizeLogToTemp,
228
+ sanitizeText,
229
+ };
@@ -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) {
@@ -504,6 +504,24 @@ describe('CI workflow invariants', () => {
504
504
  assert.match(lint.body, /cargo metadata --locked/);
505
505
  });
506
506
 
507
+ it('gates pull requests on every dependency being its latest release', () => {
508
+ // Obsolete dependencies, sensitive-data-sanitizer among them, stayed in
509
+ // use unnoticed (issue #253).
510
+ const jsJobs = parseJobs(readWorkflow('js.yml'));
511
+ const freshness = jsJobs.find((job) => job.name === 'dependency-freshness');
512
+ assert.ok(freshness, 'js.yml has no dependency-freshness job');
513
+ assert.match(
514
+ freshness.body,
515
+ /scripts\/check-dependency-freshness\.mjs --lockfiles\n/
516
+ );
517
+ const status = jsJobs.find((job) => job.name === 'pipeline-status');
518
+ assert.ok(parseNeeds(status.body).includes('dependency-freshness'));
519
+ const lint = parseJobs(readWorkflow('rust.yml')).find(
520
+ (job) => job.name === 'lint'
521
+ );
522
+ assert.match(lint.body, /cargo update --dry-run/);
523
+ });
524
+
507
525
  it('reports a failed Rust test as a failed test, not low coverage', () => {
508
526
  // Without pipefail a failing test reached the threshold check as an empty
509
527
  // value: "Coverage % is below the 50% threshold" (issue #234).
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Issue #253: command-stream 2.x returns completed `stdout` and `stderr` as
3
+ * readable stream snapshots instead of strings. `--use-command-stream` must
4
+ * still write the command's output to its log file.
5
+ */
6
+ const { describe, it, expect } = require('bun:test');
7
+ const { spawnSync } = require('child_process');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { pathToFileURL } = require('url');
12
+
13
+ const CLI = path.join(__dirname, '../src/bin/cli.js');
14
+
15
+ describe('--use-command-stream log (#253)', () => {
16
+ it('loads execution without compatibility and terminal modules', () => {
17
+ const wrapper = pathToFileURL(
18
+ path.join(__dirname, '../src/lib/command-stream.js')
19
+ ).href;
20
+ const result = spawnSync(
21
+ 'node',
22
+ [
23
+ '--input-type=module',
24
+ '-e',
25
+ `import { registerHooks } from 'node:module';
26
+ import assert from 'node:assert/strict';
27
+ const extras = [];
28
+ registerHooks({ load(url, context, next) {
29
+ if (/command-stream\\/src\\/(shelljs|terminal|rust-repl)/.test(url)) extras.push(url);
30
+ return next(url, context);
31
+ }});
32
+ const { default: wrapper } = await import(${JSON.stringify(wrapper)});
33
+ const core = await wrapper.getCommandStream();
34
+ assert.equal(typeof core.$, 'function');
35
+ assert.equal(typeof core.ProcessRunner, 'function');
36
+ assert.deepEqual(extras, []);`,
37
+ ],
38
+ { encoding: 'utf8', timeout: 30000 }
39
+ );
40
+ expect(result.error).toBeUndefined();
41
+ expect(result.stderr).toBe('');
42
+ expect(result.status).toBe(0);
43
+ });
44
+
45
+ it.skipIf(process.platform === 'win32')(
46
+ 'writes stdout and stderr to the log file',
47
+ () => {
48
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-253-'));
49
+ try {
50
+ const result = spawnSync(
51
+ process.execPath,
52
+ [
53
+ CLI,
54
+ '--use-command-stream',
55
+ '--',
56
+ 'sh',
57
+ '-c',
58
+ 'echo out-253; echo err-253 >&2',
59
+ ],
60
+ {
61
+ env: {
62
+ ...process.env,
63
+ START_APP_FOLDER: path.join(root, 'app'),
64
+ START_LOG_DIR: path.join(root, 'logs'),
65
+ START_DISABLE_AUTO_ISSUE: '1',
66
+ START_DISABLE_LOG_UPLOAD: '1',
67
+ },
68
+ encoding: 'utf8',
69
+ timeout: 30000,
70
+ }
71
+ );
72
+ expect(result.status).toBe(0);
73
+ expect(result.stderr).not.toContain('Could not append log file');
74
+ const logDir = path.join(root, 'logs', 'direct');
75
+ const [logName] = fs.readdirSync(logDir);
76
+ const log = fs.readFileSync(path.join(logDir, logName), 'utf8');
77
+ expect(log).toContain('out-253\n');
78
+ expect(log).toContain('err-253\n');
79
+ } finally {
80
+ fs.rmSync(root, { recursive: true, force: true });
81
+ }
82
+ },
83
+ 40000
84
+ );
85
+ });