start-command 0.36.0 → 0.37.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/CHANGELOG.md +8 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/bin/cli.js +5 -0
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +20 -1
- package/src/lib/command-builder.js +3 -0
- package/src/lib/docker-cleanup.js +12 -5
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- package/src/lib/docker-runtime-args.js +8 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +8 -1
- package/src/lib/execution-resume.js +184 -37
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +20 -4
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +4 -3
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/usage.js +5 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +4 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +10 -8
- package/test/failure-handler.js +11 -0
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-176.js +7 -0
- package/test/regression-187.js +9 -1
- package/test/regression-191.js +9 -1
- package/test/regression-193.js +11 -1
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +54 -0
- package/test/status-query.js +97 -15
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
* addressing one logical session across restarts.
|
|
8
8
|
*
|
|
9
9
|
* Three strategies, chosen from the probed session state:
|
|
10
|
-
* - DOCKER_START: the container still exists
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* - DOCKER_START: the container still exists. Newly managed containers
|
|
11
|
+
* accept replacement commands through a small handoff
|
|
12
|
+
* script, then restart in place without a snapshot.
|
|
13
|
+
* - DOCKER_SNAPSHOT: a legacy container needs a replacement command, or
|
|
14
|
+
* immutable labels need changing. After disk preflight,
|
|
15
|
+
* its filesystem is committed under a host-wide lock and
|
|
16
|
+
* a derived container runs the command.
|
|
17
17
|
* - RELAUNCH: nothing is left of the session, so the command is
|
|
18
18
|
* launched again through the stored isolation options.
|
|
19
19
|
*/
|
|
@@ -44,6 +44,16 @@ const {
|
|
|
44
44
|
} = require('./launch-persistence');
|
|
45
45
|
const { SessionState, probeSession } = require('./session-probe');
|
|
46
46
|
const { getDockerNetworks } = require('./docker-network-lifecycle');
|
|
47
|
+
const {
|
|
48
|
+
writeCommandHandoff,
|
|
49
|
+
buildCommandHandoffArgs,
|
|
50
|
+
commandHandoffPath,
|
|
51
|
+
} = require('./docker-command-handoff');
|
|
52
|
+
const {
|
|
53
|
+
acquireSnapshotLock,
|
|
54
|
+
preflightSnapshot,
|
|
55
|
+
SNAPSHOT_IMAGE_LABEL,
|
|
56
|
+
} = require('./docker-snapshot-safety');
|
|
47
57
|
const {
|
|
48
58
|
ResumeAllAction,
|
|
49
59
|
resumeAllExecutions,
|
|
@@ -94,6 +104,11 @@ function buildLaunchOptions(record) {
|
|
|
94
104
|
const opts = record.options || {};
|
|
95
105
|
return {
|
|
96
106
|
...opts,
|
|
107
|
+
labels: opts.labels || [],
|
|
108
|
+
sessionId: record.uuid,
|
|
109
|
+
rootSession:
|
|
110
|
+
opts.rootSession || opts.sessionNameHistory?.[0] || opts.sessionName,
|
|
111
|
+
resumeCount: (Number(opts.resumeCount) || 0) + 1,
|
|
97
112
|
image: opts.image || null,
|
|
98
113
|
session: opts.sessionName,
|
|
99
114
|
detached: true,
|
|
@@ -214,12 +229,13 @@ function buildResumePlan(
|
|
|
214
229
|
const attempt = (Number(opts.resumeCount) || 0) + 1;
|
|
215
230
|
|
|
216
231
|
if (backend === 'docker' && probe.state === SessionState.STOPPED) {
|
|
217
|
-
if (!newCommand) {
|
|
232
|
+
if (!opts.forceSnapshot && (!newCommand || opts.commandHandoff)) {
|
|
218
233
|
return {
|
|
219
234
|
mode: ResumeMode.DOCKER_START,
|
|
220
235
|
backend,
|
|
221
236
|
sessionName,
|
|
222
237
|
command,
|
|
238
|
+
...(newCommand ? { handoffCommand: command } : {}),
|
|
223
239
|
attempt,
|
|
224
240
|
steps: [
|
|
225
241
|
{
|
|
@@ -228,7 +244,9 @@ function buildResumePlan(
|
|
|
228
244
|
description: `Start stopped container ${sessionName}`,
|
|
229
245
|
},
|
|
230
246
|
],
|
|
231
|
-
message:
|
|
247
|
+
message: newCommand
|
|
248
|
+
? `Resumed new command in the same detached docker container: ${sessionName} (no filesystem snapshot)`
|
|
249
|
+
: `Resumed detached docker container: ${sessionName}`,
|
|
232
250
|
};
|
|
233
251
|
}
|
|
234
252
|
|
|
@@ -261,12 +279,24 @@ function buildResumePlan(
|
|
|
261
279
|
containerArgs: [
|
|
262
280
|
'--name',
|
|
263
281
|
newSessionName,
|
|
282
|
+
'--label',
|
|
283
|
+
`${SNAPSHOT_IMAGE_LABEL}=${snapshotImage}`,
|
|
264
284
|
...(opts.user ? ['--user', opts.user] : []),
|
|
265
|
-
...buildDockerRuntimeArgs({
|
|
285
|
+
...buildDockerRuntimeArgs({
|
|
286
|
+
...opts,
|
|
287
|
+
resourceLimits,
|
|
288
|
+
session: newSessionName,
|
|
289
|
+
containerName: newSessionName,
|
|
290
|
+
sessionId: record.uuid,
|
|
291
|
+
rootSession:
|
|
292
|
+
opts.rootSession || opts.sessionNameHistory?.[0] || sessionName,
|
|
293
|
+
resumeCount: attempt,
|
|
294
|
+
}),
|
|
266
295
|
snapshotImage,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
296
|
+
...buildCommandHandoffArgs(
|
|
297
|
+
['sh', '-c', command],
|
|
298
|
+
commandHandoffPath(newSessionName)
|
|
299
|
+
),
|
|
270
300
|
],
|
|
271
301
|
extraNetworks: getDockerNetworks(opts).slice(1),
|
|
272
302
|
newSessionName,
|
|
@@ -360,6 +390,10 @@ function applyResumeToRecord(
|
|
|
360
390
|
archiveAttempt(record);
|
|
361
391
|
record.attempt = attempt;
|
|
362
392
|
const options = { ...(record.options || {}) };
|
|
393
|
+
options.rootSession =
|
|
394
|
+
options.rootSession ||
|
|
395
|
+
options.sessionNameHistory?.[0] ||
|
|
396
|
+
options.sessionName;
|
|
363
397
|
options.resumeCount = plan.attempt;
|
|
364
398
|
options.resumedAt = attempt.startedAt;
|
|
365
399
|
// A resume is a new deliberate start: launch-time recovery applies again.
|
|
@@ -378,6 +412,12 @@ function applyResumeToRecord(
|
|
|
378
412
|
}
|
|
379
413
|
if (plan.snapshotImage) {
|
|
380
414
|
options.image = plan.snapshotImage;
|
|
415
|
+
options.snapshotImage = plan.snapshotImage;
|
|
416
|
+
// Every new detached container, including a snapshot successor, supports handoff.
|
|
417
|
+
options.commandHandoff = true;
|
|
418
|
+
}
|
|
419
|
+
if (plan.mode === ResumeMode.RELAUNCH && plan.backend === 'docker') {
|
|
420
|
+
options.commandHandoff = true;
|
|
381
421
|
}
|
|
382
422
|
if (containerId) {
|
|
383
423
|
options.containerId = containerId;
|
|
@@ -445,12 +485,34 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
445
485
|
} catch (error) {
|
|
446
486
|
return { success: false, error: error.message };
|
|
447
487
|
}
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
488
|
+
const requestedLabels = deps.resourceOptions?.labels || [];
|
|
489
|
+
const labels = [...(opts.labels || [])];
|
|
490
|
+
for (const label of requestedLabels) {
|
|
491
|
+
const key = label.split('=')[0];
|
|
492
|
+
const existing = labels.findIndex((value) => value.split('=')[0] === key);
|
|
493
|
+
if (existing >= 0) {
|
|
494
|
+
labels[existing] = label;
|
|
495
|
+
} else {
|
|
496
|
+
labels.push(label);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (requestedLabels.length && opts.isolated !== 'docker') {
|
|
500
|
+
return {
|
|
501
|
+
success: false,
|
|
502
|
+
error: 'Docker labels require a Docker execution when resuming.',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const labelsChanged =
|
|
506
|
+
JSON.stringify(labels) !== JSON.stringify(opts.labels || []);
|
|
507
|
+
const planningRecord = {
|
|
508
|
+
...record,
|
|
509
|
+
options: {
|
|
510
|
+
...opts,
|
|
511
|
+
...(resources ? { resourceLimits: resources.resourceLimits } : {}),
|
|
512
|
+
...(requestedLabels.length ? { labels } : {}),
|
|
513
|
+
...(labelsChanged ? { forceSnapshot: true } : {}),
|
|
514
|
+
},
|
|
515
|
+
};
|
|
454
516
|
const plan = buildResumePlan(
|
|
455
517
|
planningRecord,
|
|
456
518
|
deps.command || null,
|
|
@@ -485,6 +547,9 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
485
547
|
};
|
|
486
548
|
appendLifecycle(lifecycleRecord, 'resume-started');
|
|
487
549
|
const updated = applyResumeToRecord(record, plan, null, attempt);
|
|
550
|
+
if (requestedLabels.length) {
|
|
551
|
+
updated.options.labels = labels;
|
|
552
|
+
}
|
|
488
553
|
if (resources) {
|
|
489
554
|
updated.options.resolvedLimits = resources.resolvedLimits;
|
|
490
555
|
updated.options.resourceLimitSpecs = resources.resourceLimitSpecs;
|
|
@@ -540,26 +605,108 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
540
605
|
}
|
|
541
606
|
containerId = launchResult.containerId || null;
|
|
542
607
|
} else {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
608
|
+
let snapshotLock;
|
|
609
|
+
let snapshotCreated = false;
|
|
610
|
+
let derivedCreated = false;
|
|
611
|
+
try {
|
|
612
|
+
if (plan.mode === ResumeMode.DOCKER_SNAPSHOT) {
|
|
613
|
+
snapshotLock = acquireSnapshotLock(deps.snapshotOptions);
|
|
614
|
+
const preflight = preflightSnapshot(
|
|
615
|
+
plan.sessionName,
|
|
616
|
+
runner,
|
|
617
|
+
deps.snapshotOptions
|
|
618
|
+
);
|
|
619
|
+
plan.message += `\n${preflight.message}`;
|
|
620
|
+
if (record.logPath) {
|
|
621
|
+
appendLogFile(record.logPath, `${preflight.message}\n`);
|
|
622
|
+
}
|
|
549
623
|
}
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
624
|
+
if (plan.handoffCommand) {
|
|
625
|
+
const copied = writeCommandHandoff(
|
|
626
|
+
plan.sessionName,
|
|
627
|
+
plan.handoffCommand,
|
|
628
|
+
runner,
|
|
629
|
+
opts
|
|
630
|
+
);
|
|
631
|
+
if (!copied.success) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
(copied.stderr || '').trim() ||
|
|
634
|
+
copied.error ||
|
|
635
|
+
'Could not copy replacement command into the stopped container'
|
|
636
|
+
);
|
|
637
|
+
}
|
|
561
638
|
}
|
|
562
|
-
|
|
639
|
+
for (const step of plan.steps) {
|
|
640
|
+
let result;
|
|
641
|
+
try {
|
|
642
|
+
result = runner(step.command, step.args);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
result = { success: false, error: error.message };
|
|
645
|
+
}
|
|
646
|
+
if (!result.success) {
|
|
647
|
+
const detail =
|
|
648
|
+
(result.stderr || '').trim() ||
|
|
649
|
+
result.error ||
|
|
650
|
+
`exit code ${result.status}`;
|
|
651
|
+
throw new Error(detail);
|
|
652
|
+
}
|
|
653
|
+
if (step.args[0] === 'commit') {
|
|
654
|
+
snapshotCreated = true;
|
|
655
|
+
}
|
|
656
|
+
if (step.args[0] === 'create') {
|
|
657
|
+
derivedCreated = true;
|
|
658
|
+
}
|
|
659
|
+
// commit returns an image ID; cp/update/network commands are not IDs.
|
|
660
|
+
if (['run', 'create'].includes(step.args[0])) {
|
|
661
|
+
containerId = (result.stdout || '').trim() || containerId;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (plan.mode === ResumeMode.DOCKER_SNAPSHOT && deps.removeOriginal) {
|
|
665
|
+
const running = runner(getDockerCommand(), [
|
|
666
|
+
'inspect',
|
|
667
|
+
'-f',
|
|
668
|
+
'{{.State.Running}}',
|
|
669
|
+
plan.newSessionName,
|
|
670
|
+
]);
|
|
671
|
+
if (running.success && String(running.stdout).trim() === 'true') {
|
|
672
|
+
const removed = runner(getDockerCommand(), ['rm', plan.sessionName]);
|
|
673
|
+
const note = removed.success
|
|
674
|
+
? `Stopped original container removed: ${plan.sessionName}`
|
|
675
|
+
: `Original container retained: ${(removed.stderr || removed.error || 'docker rm failed').trim()}`;
|
|
676
|
+
plan.message += `\n${note}`;
|
|
677
|
+
if (record.logPath) {
|
|
678
|
+
appendLogFile(record.logPath, `${note}\n`);
|
|
679
|
+
}
|
|
680
|
+
} else {
|
|
681
|
+
plan.message +=
|
|
682
|
+
'\nOriginal container retained: the snapshot container is not running.';
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (derivedCreated) {
|
|
687
|
+
runner(getDockerCommand(), ['rm', plan.newSessionName]);
|
|
688
|
+
}
|
|
689
|
+
if (snapshotCreated) {
|
|
690
|
+
runner(getDockerCommand(), ['rmi', plan.snapshotImage]);
|
|
691
|
+
}
|
|
692
|
+
if (plan.handoffCommand) {
|
|
693
|
+
// A failed start must not leave a replacement hidden behind the old record.
|
|
694
|
+
try {
|
|
695
|
+
writeCommandHandoff(plan.sessionName, previous.command, runner, opts);
|
|
696
|
+
} catch {
|
|
697
|
+
/* Next explicit command can still repair the handoff. */
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
rollbackLaunch(store, previous);
|
|
701
|
+
appendLifecycle(lifecycleRecord, 'launch-failed', {
|
|
702
|
+
error: error.message,
|
|
703
|
+
});
|
|
704
|
+
return {
|
|
705
|
+
success: false,
|
|
706
|
+
error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${error.message}`,
|
|
707
|
+
};
|
|
708
|
+
} finally {
|
|
709
|
+
snapshotLock?.release();
|
|
563
710
|
}
|
|
564
711
|
|
|
565
712
|
const limitsLine = buildResourceLimitsStatusLine(plan.resourceLimits);
|
|
@@ -9,6 +9,7 @@ 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
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Handle command failure - detect repository, upload log, create issue
|
|
@@ -55,7 +56,7 @@ function handleFailure(config, cmdName, fullCommand, exitCode, logPath) {
|
|
|
55
56
|
console.log('Log upload disabled via START_DISABLE_LOG_UPLOAD');
|
|
56
57
|
}
|
|
57
58
|
} else if (isGhUploadLogAvailable()) {
|
|
58
|
-
logUrl = uploadLog(logPath);
|
|
59
|
+
logUrl = uploadLog(logPath, { verbose: config.verbose });
|
|
59
60
|
if (logUrl) {
|
|
60
61
|
console.log(`Log uploaded: ${logUrl}`);
|
|
61
62
|
}
|
|
@@ -273,9 +274,11 @@ function isGhUploadLogAvailable() {
|
|
|
273
274
|
* @param {string} logPath - Path to the log file
|
|
274
275
|
* @returns {string|null} URL of the uploaded log or null
|
|
275
276
|
*/
|
|
276
|
-
function uploadLog(logPath) {
|
|
277
|
+
function uploadLog(logPath, options = {}) {
|
|
278
|
+
let prepared;
|
|
277
279
|
try {
|
|
278
|
-
|
|
280
|
+
prepared = sanitizeLogToTemp(logPath, { verbose: options.verbose });
|
|
281
|
+
const result = execFileSync('gh-upload-log', [prepared.path, '--private'], {
|
|
279
282
|
encoding: 'utf8',
|
|
280
283
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
281
284
|
});
|
|
@@ -293,9 +296,13 @@ function uploadLog(logPath) {
|
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
return null;
|
|
296
|
-
} catch
|
|
297
|
-
console.log(
|
|
299
|
+
} catch {
|
|
300
|
+
console.log('Warning: Log preparation or upload failed; upload blocked.');
|
|
298
301
|
return null;
|
|
302
|
+
} finally {
|
|
303
|
+
if (prepared) {
|
|
304
|
+
prepared.cleanup();
|
|
305
|
+
}
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
308
|
|
|
@@ -327,6 +334,9 @@ function canCreateIssue(owner, repo) {
|
|
|
327
334
|
*/
|
|
328
335
|
function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
|
|
329
336
|
try {
|
|
337
|
+
fullCommand = new StreamingSanitizer()
|
|
338
|
+
.push(Buffer.from(fullCommand), true)
|
|
339
|
+
.toString();
|
|
330
340
|
const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
|
|
331
341
|
|
|
332
342
|
// Get runtime information
|
package/src/lib/isolation.js
CHANGED
|
@@ -565,6 +565,14 @@ function runInDocker(command, options = {}) {
|
|
|
565
565
|
}
|
|
566
566
|
}
|
|
567
567
|
const containerName = options.session || generateSessionName('docker');
|
|
568
|
+
const attribution = {
|
|
569
|
+
...options,
|
|
570
|
+
containerName,
|
|
571
|
+
sessionId:
|
|
572
|
+
options.sessionId ||
|
|
573
|
+
options.executionId ||
|
|
574
|
+
require('crypto').randomUUID(),
|
|
575
|
+
};
|
|
568
576
|
const containerExistedBeforeLaunch =
|
|
569
577
|
readDockerContainerStatus(containerName) !== null;
|
|
570
578
|
const cleanupPolicy = getDockerContainerCleanupPolicy(options);
|
|
@@ -604,7 +612,7 @@ function runInDocker(command, options = {}) {
|
|
|
604
612
|
dockerArgs.push('--user', options.user);
|
|
605
613
|
}
|
|
606
614
|
|
|
607
|
-
dockerArgs.push(...buildDockerRuntimeArgs(
|
|
615
|
+
dockerArgs.push(...buildDockerRuntimeArgs(attribution));
|
|
608
616
|
|
|
609
617
|
const effectiveCommand = options.keepAlive
|
|
610
618
|
? `${command}; exec ${shellToUse}`
|
|
@@ -614,12 +622,12 @@ function runInDocker(command, options = {}) {
|
|
|
614
622
|
: [shellToUse];
|
|
615
623
|
const mainCmdArgs = isBareShell
|
|
616
624
|
? toShellWords(command)
|
|
617
|
-
: isShellInvocationWithArgs(
|
|
625
|
+
: isShellInvocationWithArgs(effectiveCommand)
|
|
618
626
|
? buildShellWithArgsCmdArgs(effectiveCommand)
|
|
619
627
|
: [...shellArgs, '-c', effectiveCommand];
|
|
620
628
|
// A recovery command is selected by a marker that `docker cp` drops
|
|
621
629
|
// into the container before it is started again (issue #176).
|
|
622
|
-
const
|
|
630
|
+
const recoveryArgs = options.recoveryCommand
|
|
623
631
|
? buildRecoverySelectorArgs(mainCmdArgs, {
|
|
624
632
|
shell: shellToUse,
|
|
625
633
|
shellFlag: shellInteractiveFlag,
|
|
@@ -628,6 +636,14 @@ function runInDocker(command, options = {}) {
|
|
|
628
636
|
: options.recoveryCommand,
|
|
629
637
|
})
|
|
630
638
|
: mainCmdArgs;
|
|
639
|
+
const {
|
|
640
|
+
buildCommandHandoffArgs,
|
|
641
|
+
commandHandoffPath,
|
|
642
|
+
} = require('./docker-command-handoff');
|
|
643
|
+
const cmdArgs = buildCommandHandoffArgs(
|
|
644
|
+
recoveryArgs,
|
|
645
|
+
commandHandoffPath(containerName)
|
|
646
|
+
);
|
|
631
647
|
dockerArgs.push(options.image, ...cmdArgs);
|
|
632
648
|
|
|
633
649
|
if (DEBUG) {
|
|
@@ -717,7 +733,7 @@ function runInDocker(command, options = {}) {
|
|
|
717
733
|
if (options.user) {
|
|
718
734
|
dockerArgs.push('--user', options.user);
|
|
719
735
|
}
|
|
720
|
-
dockerArgs.push(...buildDockerRuntimeArgs(
|
|
736
|
+
dockerArgs.push(...buildDockerRuntimeArgs(attribution));
|
|
721
737
|
if (DEBUG) {
|
|
722
738
|
console.log(`[DEBUG] shell: ${shellToUse}`);
|
|
723
739
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** Bounded byte-wise sanitization for logs prepared for publication. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const BLOCK_SIZE = 64 * 1024;
|
|
7
|
+
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
|
+
];
|
|
55
|
+
|
|
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;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get pendingBytes() {
|
|
85
|
+
return this.pending.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
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
|
+
}
|
|
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 });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
|
|
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;
|
|
160
|
+
}
|
|
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;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
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');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
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';
|
|
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.');
|
|
189
|
+
}
|
|
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);
|
|
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;
|
|
219
|
+
}
|
|
220
|
+
bytes += length;
|
|
221
|
+
stage = 'scan';
|
|
222
|
+
write(sanitizer.push(buffer.subarray(0, length)));
|
|
223
|
+
}
|
|
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
|
+
if (options.verbose) {
|
|
232
|
+
console.log(
|
|
233
|
+
`Prepared private sanitized log: ${bytes} bytes scanned, ${sanitizer.redactions} redactions.`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
ready = true;
|
|
237
|
+
return {
|
|
238
|
+
path: target,
|
|
239
|
+
cleanup: () => fs.rmSync(directory, { recursive: true, force: true }),
|
|
240
|
+
};
|
|
241
|
+
} catch {
|
|
242
|
+
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
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { StreamingSanitizer, sanitizeLogToTemp };
|