mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
feat(dx): gstack-error-capture runs a CLI with no terminal and prints a redacted record
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2a17a595bf
commit
e3678aabcc
4 files changed
+812
-1
No files matched your search
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
import { dxErrorCaptureMain } from '../lib/dx-error-capture';
|
||||
|
||||
process.exit(await dxErrorCaptureMain(process.argv.slice(2)));
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* dx-error-capture — run one target CLI under containment and print a redacted
|
||||
* record of how it failed, for /devex-review Step 3 (bin/gstack-error-capture).
|
||||
*
|
||||
* Supervision is runQaDeadlineCommand (lib/qa-deadline.ts): detached process
|
||||
* group, stdin ignored, whole-group kill, signal forwarding, Windows job
|
||||
* containment and its own deadline timer. This module owns the scrubbed
|
||||
* environment, the private temp HOME/cwd, fail-closed redaction and the record.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { redactFindingSpans, scan } from './redact-engine';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
import { runQaDeadlineCommand, startQaDeadline } from './qa-deadline';
|
||||
|
||||
type Stream = 'stdout' | 'stderr';
|
||||
type Recovery = 'retry' | 'fix_and_retry' | 'do_first' | 'ask_human';
|
||||
const STREAMS: Stream[] = ['stdout', 'stderr'];
|
||||
const SCAN_CAP = 1024 * 1024;
|
||||
const DEFAULT_DEADLINE = '20';
|
||||
const DEFAULT_MAX_BYTES = 64 * 1024;
|
||||
const STALE_MS = 24 * 60 * 60 * 1000;
|
||||
const PASS_MARK = '<REDACTED-passed_env.value>';
|
||||
const MIN_MASKED_VALUE = 4;
|
||||
const PROG = 'gstack-error-capture';
|
||||
const USAGE = `${PROG} [--deadline <s>] [--max-bytes <n>] [--pass-env NAME,...] [--copy-config <path>] [--cwd <dir>] [--approved] [--launch-check] -- <cmd> [args]`;
|
||||
|
||||
export interface DxCaptureCode { exit: 0 | 1 | 2 | 3; recovery: Recovery; summary: string; fix: string }
|
||||
|
||||
export const DX_CAPTURE_CODES = {
|
||||
DX_CAPTURE_USAGE: { exit: 2, recovery: 'fix_and_retry', summary: 'Arguments are malformed: unknown flag, missing value, no `--`, or no command after it.', fix: USAGE },
|
||||
DX_CAPTURE_BAD_DEADLINE: { exit: 2, recovery: 'fix_and_retry', summary: '--deadline is not a positive number of seconds (at most 3600, up to 3 decimals).', fix: '--deadline 20' },
|
||||
DX_CAPTURE_BAD_MAX_BYTES: { exit: 2, recovery: 'fix_and_retry', summary: `--max-bytes is not an integer from 1 to ${SCAN_CAP}.`, fix: `--max-bytes ${DEFAULT_MAX_BYTES}` },
|
||||
DX_CAPTURE_BAD_PASS_ENV: { exit: 2, recovery: 'fix_and_retry', summary: '--pass-env names an invalid or unset variable, or one the containment owns (PATH, HOME, XDG_CONFIG_HOME, TERM, USERPROFILE).', fix: '--pass-env NAME with NAME set in the calling environment' },
|
||||
DX_CAPTURE_BAD_CWD: { exit: 2, recovery: 'fix_and_retry', summary: '--cwd is not an existing directory.', fix: '--cwd <existing directory> --approved' },
|
||||
DX_CAPTURE_APPROVAL_REQUIRED: { exit: 3, recovery: 'ask_human', summary: '--pass-env, --copy-config or --cwd was given without --approved; they hand the target credentials or real files.', fix: 'ask the user to approve the named variables, files or directory, then add --approved' },
|
||||
DX_CAPTURE_CONFIG_REFUSED: { exit: 3, recovery: 'fix_and_retry', summary: '--copy-config names a missing file, a symlink, a non-regular file, a path outside HOME, a duplicate, or a file over 1 MiB.', fix: '--copy-config <regular file under $HOME, no symlinks>' },
|
||||
DX_CAPTURE_NOT_FOUND: { exit: 1, recovery: 'do_first', summary: 'The command does not resolve to an executable file; the record carries launch "not_found" and nothing ran.', fix: 'install the command or pass its absolute path after --' },
|
||||
DX_CAPTURE_TEMP_DIR: { exit: 1, recovery: 'do_first', summary: 'The private 0700 containment directory under the gstack state root could not be created.', fix: 'make $GSTACK_STATE_ROOT/error-capture a writable directory you own (not a symlink), then rerun' },
|
||||
DX_CAPTURE_CONTAINMENT_UNAVAILABLE: { exit: 1, recovery: 'ask_human', summary: 'Process-group or Windows job containment is unavailable on this platform; nothing ran.', fix: 'run the capture on Linux, macOS or Windows with job objects available' },
|
||||
DX_CAPTURE_SUPERVISOR_FAILED: { exit: 1, recovery: 'retry', summary: 'The deadline supervisor failed before reporting a result.', fix: 'rerun the same command once; if it repeats, run bin/gstack-qa-deadline --help to check the supervisor' },
|
||||
DX_CAPTURE_INTERRUPTED: { exit: 1, recovery: 'retry', summary: 'The helper received SIGINT, SIGTERM or SIGHUP; the target group was killed and no record was printed.', fix: 'rerun the same command without interrupting it' },
|
||||
DX_CAPTURE_REDACTION_FAILED: { exit: 1, recovery: 'ask_human', summary: 'The finished record still matched a secret check, so nothing was printed.', fix: 'do not rerun with more --pass-env; report the command name to the gstack maintainers' },
|
||||
DX_CAPTURE_INTERNAL: { exit: 1, recovery: 'ask_human', summary: 'Unexpected helper failure; the line names the error class and its first stack frame.', fix: 'rerun once; if it repeats, report the printed frame to the gstack maintainers' },
|
||||
} satisfies Record<string, DxCaptureCode>;
|
||||
|
||||
type DxCaptureCodeName = keyof typeof DX_CAPTURE_CODES;
|
||||
|
||||
class CaptureError extends Error {
|
||||
constructor(readonly code: DxCaptureCodeName, readonly what: string, readonly fix: string = DX_CAPTURE_CODES[code].fix) { super(what); }
|
||||
}
|
||||
|
||||
export const DX_CAPTURE_HELP = `${PROG}: run one CLI with no terminal and print a redacted JSON record of what it did.
|
||||
|
||||
Usage: ${USAGE}
|
||||
|
||||
Flags:
|
||||
--deadline <s> Kill the whole process group after s seconds (default ${DEFAULT_DEADLINE}, max 3600).
|
||||
--max-bytes <n> Publish at most n bytes per stream after redaction (default ${DEFAULT_MAX_BYTES}).
|
||||
--pass-env A,B Copy these variables into the target env (needs --approved). Values of
|
||||
${MIN_MASKED_VALUE}+ characters are masked literally, plus URL-encoded and base64 forms.
|
||||
--copy-config <p> Copy a regular file under $HOME into the temp HOME at the same relative path
|
||||
(repeatable, needs --approved; symlinks and non-regular files are refused).
|
||||
--cwd <dir> Run in this directory instead of a temp one (needs --approved).
|
||||
--approved The user approved the --pass-env, --copy-config and --cwd values given.
|
||||
--launch-check Run "<cmd> --version" in the same containment and report launch ok|failed.
|
||||
--help Print this help.
|
||||
|
||||
Containment: stdin is /dev/null, no controlling terminal, own process group, temp cwd and temp
|
||||
HOME/XDG_CONFIG_HOME in a 0700 directory under the gstack state root (removed on exit), env limited
|
||||
to PATH, LANG, LC_*, TMPDIR, TERM=dumb and version-manager shims (NVM_DIR, NVM_BIN, MISE_*, VOLTA_HOME,
|
||||
ASDF_*, PYENV_ROOT, RBENV_ROOT; prefixed names that look like credentials are dropped).
|
||||
|
||||
Record (one JSON object on stdout): version, argv (redacted), exit_code (null when killed or signalled),
|
||||
signal, timed_out (the helper's own timer only), deadline_s, terminal {verdict: blocking |
|
||||
refused_with_alternative | refused_no_alternative | none | unknown, evidence}, launch (ok | failed |
|
||||
not_found), stdout, stderr (redacted text, "withheld", or null when nothing ran), withheld_reason
|
||||
{stdout, stderr}, secret_findings {count, classes} (HIGH tier and passed_env.value), other_masked
|
||||
{count, classes} (MEDIUM and LOW), scan_complete, truncated {stdout, stderr}, passed_env (names),
|
||||
copied_config (paths relative to HOME), containment {cwd, home, env}.
|
||||
|
||||
Exit codes: 0 record printed, 1 helper failure, 2 usage, 3 refused.
|
||||
Error lines: "<what>; fix: <command or flag> (<CODE>)" on stderr. Codes:
|
||||
${Object.entries(DX_CAPTURE_CODES).map(([code, row]) => ` ${code} (exit ${row.exit}, ${row.recovery}): ${row.summary}`).join('\n')}
|
||||
`;
|
||||
|
||||
interface Options {
|
||||
help: boolean; deadline: string; maxBytes: number; passEnv: string[]; copyConfig: string[];
|
||||
cwd?: string; approved: boolean; launchCheck: boolean; command: string; args: string[];
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const options: Options = { help: false, deadline: DEFAULT_DEADLINE, maxBytes: DEFAULT_MAX_BYTES, passEnv: [], copyConfig: [], approved: false, launchCheck: false, command: '', args: [] };
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { ...options, help: true };
|
||||
const bare = !!argv[0] && !argv[0].startsWith('-');
|
||||
const split = bare ? -1 : argv.indexOf('--');
|
||||
if (!bare && split === -1) throw new CaptureError('DX_CAPTURE_USAGE', 'missing `--` before the command to capture');
|
||||
const flags = bare ? [] : argv.slice(0, split);
|
||||
[options.command = '', ...options.args] = argv.slice(split + 1);
|
||||
if (!options.command) throw new CaptureError('DX_CAPTURE_USAGE', 'no command after `--`');
|
||||
for (let i = 0; i < flags.length; i++) {
|
||||
const flag = flags[i];
|
||||
if (flag === '--approved') { options.approved = true; continue; }
|
||||
if (flag === '--launch-check') { options.launchCheck = true; continue; }
|
||||
if (!['--deadline', '--max-bytes', '--pass-env', '--copy-config', '--cwd'].includes(flag)) {
|
||||
throw new CaptureError('DX_CAPTURE_USAGE', `unknown flag ${JSON.stringify(flag)}`);
|
||||
}
|
||||
const value = flags[++i];
|
||||
if (value === undefined) throw new CaptureError('DX_CAPTURE_USAGE', `${flag} needs a value`);
|
||||
if (flag === '--deadline') {
|
||||
const ms = /^(?:0|[1-9]\d*)(?:\.\d{1,3})?$/.test(value) ? Number(value) * 1000 : NaN;
|
||||
if (!(ms >= 1 && ms <= 3_600_000)) throw new CaptureError('DX_CAPTURE_BAD_DEADLINE', `--deadline ${JSON.stringify(value)} is not 0.001-3600 seconds`);
|
||||
options.deadline = value;
|
||||
} else if (flag === '--max-bytes') {
|
||||
const bytes = /^[1-9]\d*$/.test(value) ? Number(value) : NaN;
|
||||
if (!(bytes <= SCAN_CAP)) throw new CaptureError('DX_CAPTURE_BAD_MAX_BYTES', `--max-bytes ${JSON.stringify(value)} is not an integer from 1 to ${SCAN_CAP}`);
|
||||
options.maxBytes = bytes;
|
||||
} else if (flag === '--pass-env') options.passEnv.push(...value.split(',').filter(Boolean));
|
||||
else if (flag === '--copy-config') options.copyConfig.push(value);
|
||||
else options.cwd = value;
|
||||
}
|
||||
if (options.launchCheck && options.args.length) {
|
||||
throw new CaptureError('DX_CAPTURE_USAGE', '--launch-check takes only the command; it supplies --version itself', `${PROG} --launch-check -- ${options.command}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
const OWNED_ENV = new Set(['PATH', 'HOME', 'XDG_CONFIG_HOME', 'TERM', 'USERPROFILE']);
|
||||
const SHIM_NAMES = new Set(['PATH', 'LANG', 'TMPDIR', 'NVM_DIR', 'NVM_BIN', 'VOLTA_HOME', 'PYENV_ROOT', 'RBENV_ROOT']);
|
||||
const SHIM_PREFIXES = ['LC_', 'MISE_', 'ASDF_'];
|
||||
const WINDOWS_NAMES = new Set(['SYSTEMROOT', 'COMSPEC', 'PATHEXT', 'WINDIR']);
|
||||
const CREDENTIAL_NAME = /TOKEN|SECRET|PASSW|KEY|AUTH|CREDENTIAL|COOKIE|SESSION/i;
|
||||
|
||||
function checkPassEnv(names: string[]): void {
|
||||
for (const name of names) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || OWNED_ENV.has(name.toUpperCase())) {
|
||||
throw new CaptureError('DX_CAPTURE_BAD_PASS_ENV', `--pass-env name ${JSON.stringify(name)} is invalid or owned by the containment`);
|
||||
}
|
||||
if (process.env[name] === undefined) {
|
||||
throw new CaptureError('DX_CAPTURE_BAD_PASS_ENV', `--pass-env ${name} is not set in the calling environment`, `export ${name} first, or drop it from --pass-env`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function childEnvironment(home: string, passEnv: string[]): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue;
|
||||
const upper = name.toUpperCase();
|
||||
const shim = SHIM_NAMES.has(upper) || (process.platform === 'win32' && WINDOWS_NAMES.has(upper))
|
||||
|| (SHIM_PREFIXES.some(prefix => upper.startsWith(prefix)) && !CREDENTIAL_NAME.test(upper));
|
||||
if (shim) env[name] = value;
|
||||
}
|
||||
for (const name of passEnv) env[name] = process.env[name] as string;
|
||||
env.TERM = 'dumb';
|
||||
env.HOME = home;
|
||||
env.XDG_CONFIG_HOME = path.join(home, '.config');
|
||||
if (process.platform === 'win32') env.USERPROFILE = home;
|
||||
return env;
|
||||
}
|
||||
|
||||
function resolveBinary(command: string): string | null {
|
||||
const explicit = command.includes('/') || (process.platform === 'win32' && command.includes('\\'));
|
||||
const extensions = process.platform === 'win32' ? ['', ...(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')] : [''];
|
||||
const dirs = explicit ? [''] : (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
||||
for (const dir of dirs) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = path.resolve(dir, command + extension);
|
||||
try {
|
||||
if (!fs.statSync(candidate).isFile()) continue;
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return candidate;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function alive(pid: number): boolean {
|
||||
try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; }
|
||||
}
|
||||
|
||||
function prepareContainment(): { root: string; home: string; cwd: string } {
|
||||
try {
|
||||
const base = path.join(path.resolve(resolveStateRoot()), 'error-capture');
|
||||
fs.mkdirSync(base, { recursive: true, mode: 0o700 });
|
||||
const stat = fs.lstatSync(base);
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('not a directory');
|
||||
if (process.platform !== 'win32' && stat.uid !== process.getuid?.()) throw new Error('not owned');
|
||||
fs.chmodSync(base, 0o700);
|
||||
const real = fs.realpathSync(base);
|
||||
for (const entry of fs.readdirSync(real)) {
|
||||
const match = /^run-(\d+)-/.exec(entry);
|
||||
if (!match) continue;
|
||||
const dir = path.join(real, entry);
|
||||
try {
|
||||
if (!alive(Number(match[1])) || Date.now() - fs.lstatSync(dir).mtimeMs > STALE_MS) fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
const root = fs.mkdtempSync(path.join(real, `run-${process.pid}-`));
|
||||
fs.chmodSync(root, 0o700);
|
||||
const home = path.join(root, 'home'), cwd = path.join(root, 'cwd');
|
||||
fs.mkdirSync(path.join(home, '.config'), { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(cwd, { mode: 0o700 });
|
||||
return { root, home, cwd };
|
||||
} catch {
|
||||
throw new CaptureError('DX_CAPTURE_TEMP_DIR', 'cannot create the private containment directory under the gstack state root');
|
||||
}
|
||||
}
|
||||
|
||||
function copyConfig(files: string[], home: string): string[] {
|
||||
const homeRaw = path.resolve(os.homedir());
|
||||
const homeReal = fs.realpathSync(homeRaw);
|
||||
return files.map(file => {
|
||||
const refuse = (why: string) => new CaptureError('DX_CAPTURE_CONFIG_REFUSED', `--copy-config ${JSON.stringify(file)} ${why}`);
|
||||
const absolute = path.resolve(file);
|
||||
const relative = [homeRaw, homeReal].map(root => path.relative(root, absolute))
|
||||
.find(rel => rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
if (!relative) throw refuse('is not under $HOME');
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
const stat = fs.lstatSync(absolute);
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) throw refuse('is a symlink or not a regular file');
|
||||
if (fs.realpathSync(absolute) !== path.join(homeReal, relative)) throw refuse('has a symlinked parent directory');
|
||||
fd = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
||||
const opened = fs.fstatSync(fd);
|
||||
if (!opened.isFile() || opened.size > SCAN_CAP) throw refuse('is not a regular file of at most 1 MiB');
|
||||
const bytes = fs.readFileSync(fd);
|
||||
const target = path.join(home, relative);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, bytes, { mode: 0o600, flag: 'wx' });
|
||||
fs.chmodSync(target, 0o600);
|
||||
return relative.split(path.sep).join('/');
|
||||
} catch (error) {
|
||||
if (error instanceof CaptureError) throw error;
|
||||
throw refuse('cannot be read or was named twice');
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[PX^_][^\x1b]*\x1b\\|\x1b[@-_]|\x9b[0-?]*[ -/]*[@-~]/g;
|
||||
|
||||
function sanitize(text: string): string {
|
||||
return text.replace(ANSI, '').replace(/\r\n/g, '\n')
|
||||
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
||||
}
|
||||
|
||||
function exactUtf8(bytes: Buffer): string | null {
|
||||
const text = bytes.toString('utf8');
|
||||
return Buffer.from(text, 'utf8').equals(bytes) ? text : null;
|
||||
}
|
||||
|
||||
interface Tally { secret: Map<string, number>; other: Map<string, number> }
|
||||
const bump = (map: Map<string, number>, id: string, by = 1) => map.set(id, (map.get(id) ?? 0) + by);
|
||||
|
||||
/** Sanitize, mask pass-env values literally, then mask every finding span. Null means withhold. */
|
||||
function clean(text: string, literals: string[], tally?: Tally, maxBytes = 4 * SCAN_CAP): string | null {
|
||||
let value = sanitize(text);
|
||||
for (const literal of literals) {
|
||||
const parts = value.split(literal);
|
||||
if (parts.length > 1 && tally) bump(tally.secret, 'passed_env.value', parts.length - 1);
|
||||
value = parts.join(PASS_MARK);
|
||||
}
|
||||
const result = scan(value, { maxBytes });
|
||||
if (result.oversize) return null;
|
||||
if (tally) for (const finding of result.findings) bump(finding.tier === 'HIGH' ? tally.secret : tally.other, finding.id);
|
||||
return redactFindingSpans(value, { maxBytes });
|
||||
}
|
||||
|
||||
function literalForms(names: string[]): string[] {
|
||||
const forms = new Set<string>();
|
||||
for (const name of names) {
|
||||
const value = process.env[name] ?? '';
|
||||
if (value.length < MIN_MASKED_VALUE) continue;
|
||||
const base64 = Buffer.from(value, 'utf8').toString('base64');
|
||||
for (const form of [value, encodeURIComponent(value), base64, base64.replace(/=+$/, ''), Buffer.from(value, 'utf8').toString('base64url')]) forms.add(form);
|
||||
}
|
||||
return [...forms].sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
function truncate(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
const bytes = Buffer.from(text, 'utf8');
|
||||
if (bytes.length <= maxBytes) return { text, truncated: false };
|
||||
let end = maxBytes;
|
||||
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
|
||||
let head = bytes.subarray(0, end).toString('utf8');
|
||||
if (!/\s/.test(String.fromCharCode(bytes[end]))) head = head.replace(/\S+$/, '');
|
||||
return { text: head, truncated: true };
|
||||
}
|
||||
|
||||
interface Chunk { stream: Stream; bytes: Buffer }
|
||||
interface Released {
|
||||
text: Record<Stream, string>; withheld: Record<Stream, string | null>; truncated: Record<Stream, boolean>;
|
||||
tally: Tally; scanComplete: boolean;
|
||||
}
|
||||
|
||||
/** Full-buffer redaction first, then every assembled view re-scanned (the lib/cso/process.ts spliceRedaction invariant), then truncation. */
|
||||
function release(chunks: Chunk[], overflow: Record<Stream, boolean>, literals: string[], maxBytes: number): Released {
|
||||
const withheld: Record<Stream, string | null> = { stdout: null, stderr: null };
|
||||
const tally: Tally = { secret: new Map(), other: new Map() };
|
||||
let scanComplete = true;
|
||||
for (const stream of STREAMS) {
|
||||
const text = overflow[stream] ? null : exactUtf8(Buffer.concat(chunks.filter(chunk => chunk.stream === stream).map(chunk => chunk.bytes)));
|
||||
if (text === null) withheld[stream] = overflow[stream] ? 'scan_cap_exceeded' : 'invalid_utf8';
|
||||
else if (clean(text, literals, tally) === null) withheld[stream] = 'redaction_unlocatable';
|
||||
if (withheld[stream]) scanComplete = false;
|
||||
}
|
||||
const runs: Array<{ stream: Stream; parts: Buffer[] }> = [];
|
||||
for (const chunk of chunks.filter(item => withheld[item.stream] === null)) {
|
||||
const last = runs.at(-1);
|
||||
if (last?.stream === chunk.stream) last.parts.push(chunk.bytes);
|
||||
else runs.push({ stream: chunk.stream, parts: [chunk.bytes] });
|
||||
}
|
||||
const joined: Record<Stream, string> = { stdout: '', stderr: '' };
|
||||
let chronological = '';
|
||||
let failure: string | null = null;
|
||||
for (const run of runs) {
|
||||
const raw = exactUtf8(Buffer.concat(run.parts));
|
||||
const text = raw === null ? null : clean(raw, literals);
|
||||
if (text === null) { failure = raw === null ? 'interleave_split' : 'redaction_unlocatable'; break; }
|
||||
joined[run.stream] += text;
|
||||
chronological += text;
|
||||
}
|
||||
const views = [joined.stdout, joined.stderr, joined.stdout + joined.stderr, joined.stderr + joined.stdout, chronological];
|
||||
if (!failure && views.some(view => clean(view, literals, undefined, 8 * SCAN_CAP) !== view)) failure = 'cross_stream_finding';
|
||||
const text: Record<Stream, string> = { stdout: 'withheld', stderr: 'withheld' };
|
||||
const truncated: Record<Stream, boolean> = { stdout: false, stderr: false };
|
||||
for (const stream of STREAMS) {
|
||||
if (withheld[stream] === null && failure) withheld[stream] = failure;
|
||||
if (withheld[stream] !== null) continue;
|
||||
const cut = truncate(joined[stream], maxBytes);
|
||||
if (cut.truncated && clean(cut.text, literals) !== cut.text) { withheld[stream] = 'truncation_finding'; continue; }
|
||||
text[stream] = cut.text;
|
||||
truncated[stream] = cut.truncated;
|
||||
}
|
||||
return { text, withheld, truncated, tally, scanComplete };
|
||||
}
|
||||
|
||||
const TTY_FAILURES: Array<[string, RegExp]> = [
|
||||
['ENXIO', /\bENXIO\b/], ['not a tty', /not a tty/i], ['not a terminal', /not a terminal/i],
|
||||
['Inappropriate ioctl', /inappropriate ioctl/i], ['No such device or address', /no such device or address/i],
|
||||
['Device not configured', /device not configured/i],
|
||||
];
|
||||
const ALTERNATIVES: Array<[string, RegExp]> = [
|
||||
['--yes', /(?<![\w-])--yes\b/], ['--non-interactive', /--non-?interactive\b/i], ['--no-input', /--no-input\b/],
|
||||
['--no-interaction', /--no-interaction\b/], ['--assume-yes', /--assume-yes\b/], ['--batch', /(?<![\w-])--batch\b/],
|
||||
['-y', /(?<![\w-])-y\b/], ['CI=', /\bCI=/], ['*NONINTERACTIVE=', /\b[A-Z0-9_]*NON_?INTERACTIVE[A-Z0-9_]*=/],
|
||||
['DEBIAN_FRONTEND=noninteractive', /\bDEBIAN_FRONTEND=noninteractive\b/],
|
||||
];
|
||||
const EOF_FAILURES: Array<[string, RegExp]> = [['EOF', /\bEOF\b/], ['end of file', /end of (?:file|input)/i], ['stdin closed', /stdin (?:is )?closed/i]];
|
||||
const firstMatch = (patterns: Array<[string, RegExp]>, text: string) => patterns.find(([, pattern]) => pattern.test(text))?.[0];
|
||||
|
||||
export type TerminalVerdict = 'blocking' | 'refused_with_alternative' | 'refused_no_alternative' | 'none' | 'unknown';
|
||||
interface TerminalInput { exitCode: number | null; timedOut: boolean; launched: boolean; quietMs: number; outputBytes: number; deadlineS: number }
|
||||
|
||||
function classifyTerminal(stderr: string, run: TerminalInput): { verdict: TerminalVerdict; evidence: string } {
|
||||
if (!run.launched) return { verdict: 'unknown', evidence: 'command did not start' };
|
||||
if (run.timedOut) {
|
||||
if (run.outputBytes === 0) return { verdict: 'blocking', evidence: `killed at the ${run.deadlineS}s deadline with no output` };
|
||||
if (run.quietMs >= Math.min(5000, run.deadlineS * 500)) {
|
||||
return { verdict: 'blocking', evidence: `killed at the ${run.deadlineS}s deadline after ${(run.quietMs / 1000).toFixed(1)}s without output` };
|
||||
}
|
||||
return { verdict: 'unknown', evidence: `output was still progressing at the ${run.deadlineS}s deadline` };
|
||||
}
|
||||
const tty = firstMatch(TTY_FAILURES, stderr);
|
||||
if (tty && run.exitCode !== 0) {
|
||||
const alternative = firstMatch(ALTERNATIVES, stderr);
|
||||
return alternative
|
||||
? { verdict: 'refused_with_alternative', evidence: `stderr matched "${tty}"; alternative "${alternative}"` }
|
||||
: { verdict: 'refused_no_alternative', evidence: `stderr matched "${tty}"; no non-interactive flag or env var named` };
|
||||
}
|
||||
if (tty) return { verdict: 'unknown', evidence: `exit 0 but stderr matched "${tty}"` };
|
||||
const eof = run.exitCode !== 0 ? firstMatch(EOF_FAILURES, stderr) : undefined;
|
||||
if (eof) return { verdict: 'unknown', evidence: `stderr matched "${eof}"; cannot attribute it to a terminal` };
|
||||
return { verdict: 'none', evidence: 'no terminal-failure pattern' };
|
||||
}
|
||||
|
||||
interface Run {
|
||||
chunks: Chunk[]; overflow: Record<Stream, boolean>; exitCode: number; signal: NodeJS.Signals | null;
|
||||
completed: boolean; timedOut: boolean; quietMs: number; outputBytes: number;
|
||||
}
|
||||
|
||||
async function supervise(deadlineFile: string, binary: string, args: string[], cwd: string, env: Record<string, string>): Promise<Run> {
|
||||
const chunks: Chunk[] = [];
|
||||
const size: Record<Stream, number> = { stdout: 0, stderr: 0 };
|
||||
const overflow: Record<Stream, boolean> = { stdout: false, stderr: false };
|
||||
let lastOutputAt = Date.now(), timedOut = false, interrupted: string | undefined;
|
||||
let completion: { exitCode: number; signal: NodeJS.Signals | null; completed: boolean } | undefined;
|
||||
const handlers = (['SIGINT', 'SIGTERM', 'SIGHUP'] as const).map(name => [name, () => { interrupted ??= name; }] as const);
|
||||
for (const [name, handler] of handlers) process.once(name, handler);
|
||||
try {
|
||||
await runQaDeadlineCommand(deadlineFile, binary, args, (_stream, receipt) => {
|
||||
if (receipt.event === 'finished') timedOut = receipt.timedOut === true;
|
||||
}, {
|
||||
write: (stream, chunk) => {
|
||||
lastOutputAt = Date.now();
|
||||
size[stream] += chunk.length;
|
||||
if (size[stream] > SCAN_CAP) overflow[stream] = true;
|
||||
else chunks.push({ stream, bytes: Buffer.from(chunk) });
|
||||
},
|
||||
complete: result => { completion = result; },
|
||||
spawn: { cwd, env },
|
||||
});
|
||||
} catch (error) {
|
||||
if (/containment/i.test((error as Error).message)) {
|
||||
throw new CaptureError('DX_CAPTURE_CONTAINMENT_UNAVAILABLE', `${process.platform} process containment is unavailable; nothing ran`);
|
||||
}
|
||||
throw new CaptureError('DX_CAPTURE_SUPERVISOR_FAILED', 'the deadline supervisor failed before reporting a result');
|
||||
} finally {
|
||||
for (const [name, handler] of handlers) process.off(name, handler);
|
||||
}
|
||||
if (interrupted) throw new CaptureError('DX_CAPTURE_INTERRUPTED', `capture interrupted by ${interrupted}; the target group was killed`);
|
||||
if (!completion) throw new CaptureError('DX_CAPTURE_SUPERVISOR_FAILED', 'the deadline supervisor reported no completion');
|
||||
return { chunks, overflow, ...(completion as NonNullable<typeof completion>), timedOut, quietMs: Date.now() - lastOutputAt, outputBytes: size.stdout + size.stderr };
|
||||
}
|
||||
|
||||
const tallyOut = (map: Map<string, number>) => ({ count: [...map.values()].reduce((sum, n) => sum + n, 0), classes: [...map.keys()].sort() });
|
||||
|
||||
async function capture(options: Options): Promise<{ record: Record<string, unknown>; literals: string[] }> {
|
||||
const literals = literalForms(options.passEnv);
|
||||
const argvIn = options.launchCheck ? [options.command, '--version'] : [options.command, ...options.args];
|
||||
const base = { version: 1, argv: argvIn.map(arg => clean(arg, literals) ?? 'withheld') };
|
||||
const deadlineS = Number(options.deadline);
|
||||
const binary = resolveBinary(options.command);
|
||||
if (!binary) {
|
||||
return { literals, record: { ...base, exit_code: null, signal: null, timed_out: false, deadline_s: deadlineS, terminal: { verdict: 'unknown', evidence: 'command not found' },
|
||||
launch: 'not_found', stdout: null, stderr: null, withheld_reason: { stdout: null, stderr: null }, secret_findings: { count: 0, classes: [] },
|
||||
other_masked: { count: 0, classes: [] }, scan_complete: true, truncated: { stdout: false, stderr: false }, passed_env: options.passEnv, copied_config: [], containment: null } };
|
||||
}
|
||||
if (!['linux', 'darwin', 'win32'].includes(process.platform)) {
|
||||
throw new CaptureError('DX_CAPTURE_CONTAINMENT_UNAVAILABLE', `${process.platform} process containment is unavailable; nothing ran`);
|
||||
}
|
||||
const box = prepareContainment();
|
||||
try {
|
||||
const copied = copyConfig(options.copyConfig, box.home).map(rel => clean(rel, literals) ?? 'withheld');
|
||||
const env = childEnvironment(box.home, options.passEnv);
|
||||
const deadlineFile = path.join(box.root, 'deadline.json');
|
||||
try { startQaDeadline(deadlineFile, options.deadline); } catch {
|
||||
throw new CaptureError('DX_CAPTURE_TEMP_DIR', 'cannot write the deadline receipt in the containment directory');
|
||||
}
|
||||
const run = await supervise(deadlineFile, binary, argvIn.slice(1), options.cwd ? path.resolve(options.cwd) : box.cwd, env);
|
||||
const launched = !(run.exitCode === 127 && !run.completed && !run.timedOut);
|
||||
const exitCode = run.timedOut || run.signal ? null : run.exitCode;
|
||||
const released = release(run.chunks, run.overflow, literals, options.maxBytes);
|
||||
const stderrText = sanitize(Buffer.concat(run.chunks.filter(chunk => chunk.stream === 'stderr').map(chunk => chunk.bytes)).toString('utf8'));
|
||||
const terminal = classifyTerminal(stderrText, { exitCode, timedOut: run.timedOut, launched, quietMs: run.quietMs, outputBytes: run.outputBytes, deadlineS });
|
||||
return { literals, record: { ...base, exit_code: exitCode, signal: run.signal, timed_out: run.timedOut, deadline_s: deadlineS, terminal,
|
||||
launch: !launched || (options.launchCheck && exitCode !== 0) ? 'failed' : 'ok',
|
||||
stdout: released.text.stdout, stderr: released.text.stderr, withheld_reason: released.withheld,
|
||||
secret_findings: tallyOut(released.tally.secret), other_masked: tallyOut(released.tally.other), scan_complete: released.scanComplete,
|
||||
truncated: released.truncated, passed_env: options.passEnv, copied_config: copied,
|
||||
containment: { cwd: options.cwd ? 'approved' : 'temp', home: 'temp', env: Object.keys(env).sort() } } };
|
||||
} finally {
|
||||
fs.rmSync(box.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const writeTo = (stream: NodeJS.WriteStream, text: string) => new Promise<void>(resolve => { stream.write(text, () => resolve()); });
|
||||
const errorLine = (what: string, fix: string, code: string) => `${PROG}: ${what}; fix: ${fix} (${code})\n`;
|
||||
|
||||
export async function dxErrorCaptureMain(argv: string[]): Promise<number> {
|
||||
try {
|
||||
const options = parseArgs(argv);
|
||||
if (options.help) { await writeTo(process.stdout, DX_CAPTURE_HELP); return 0; }
|
||||
const gated = [options.passEnv.length && '--pass-env', options.copyConfig.length && '--copy-config', options.cwd && '--cwd'].filter(Boolean);
|
||||
if (gated.length && !options.approved) {
|
||||
throw new CaptureError('DX_CAPTURE_APPROVAL_REQUIRED', `${gated.join(', ')} hands the target credentials or real files and needs the user's approval`,
|
||||
`ask the user to approve ${gated.join(' and ')} as given, then rerun with --approved`);
|
||||
}
|
||||
checkPassEnv(options.passEnv);
|
||||
if (options.cwd && !fs.statSync(options.cwd, { throwIfNoEntry: false })?.isDirectory()) {
|
||||
throw new CaptureError('DX_CAPTURE_BAD_CWD', `--cwd ${JSON.stringify(options.cwd)} is not an existing directory`);
|
||||
}
|
||||
const { record, literals } = await capture(options);
|
||||
const json = JSON.stringify(record);
|
||||
if (literals.some(literal => json.includes(literal)) || scan(json, { maxBytes: 2 * json.length + 1 }).findings.some(finding => finding.tier === 'HIGH')) {
|
||||
throw new CaptureError('DX_CAPTURE_REDACTION_FAILED', 'the finished record still matched a secret check; nothing was printed');
|
||||
}
|
||||
await writeTo(process.stdout, json + '\n');
|
||||
if (record.launch !== 'not_found') return 0;
|
||||
await writeTo(process.stderr, errorLine(`${JSON.stringify(path.basename(options.command))} does not resolve to an executable file; nothing ran`,
|
||||
DX_CAPTURE_CODES.DX_CAPTURE_NOT_FOUND.fix, 'DX_CAPTURE_NOT_FOUND'));
|
||||
return 1;
|
||||
} catch (error) {
|
||||
if (error instanceof CaptureError) {
|
||||
await writeTo(process.stderr, errorLine(error.what, error.fix, error.code));
|
||||
return DX_CAPTURE_CODES[error.code].exit;
|
||||
}
|
||||
const frame = ((error as Error)?.stack ?? '').split('\n').find(line => line.trim().startsWith('at '))?.trim() ?? 'no stack frame';
|
||||
await writeTo(process.stderr, errorLine(`unexpected ${(error as Error)?.name ?? 'error'} ${frame}`, DX_CAPTURE_CODES.DX_CAPTURE_INTERNAL.fix, 'DX_CAPTURE_INTERNAL'));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -116,6 +116,7 @@ export function qaDeadlineStatus(state: QaDeadline) {
|
||||
export interface QaCommandCapture {
|
||||
write(stream: 'stdout' | 'stderr', chunk: Buffer): void;
|
||||
complete(result: QaCommandResult): void;
|
||||
spawn?: { cwd?: string; env?: Record<string, string> };
|
||||
}
|
||||
|
||||
export async function runQaDeadlineCommand(file: string, command: string, args: string[], emit: Emit, capture?: QaCommandCapture): Promise<number> {
|
||||
@@ -142,7 +143,8 @@ export async function runQaDeadlineCommand(file: string, command: string, args:
|
||||
}
|
||||
let outcome: number | undefined;
|
||||
let settled = false;
|
||||
const child = spawn(command, args, { detached: process.platform !== 'win32', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', windowsHide: true });
|
||||
const child = spawn(command, args, { detached: process.platform !== 'win32', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', windowsHide: true,
|
||||
cwd: capture?.spawn?.cwd, env: capture?.spawn?.env });
|
||||
const kill = () => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { DX_CAPTURE_CODES, DX_CAPTURE_HELP } from '../lib/dx-error-capture';
|
||||
|
||||
const BIN = path.resolve(import.meta.dir, '../bin/gstack-error-capture');
|
||||
const ROOT = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'dx-error-capture-'));
|
||||
const STATE = path.join(ROOT, 'state');
|
||||
const FAKE_HOME = path.join(ROOT, 'home');
|
||||
const ALNUM = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const randomAlnum = (length: number) => [...randomBytes(length)].map(byte => ALNUM[byte % ALNUM.length]).join('');
|
||||
const TOKEN = ['gh', 'p_'].join('') + randomAlnum(36);
|
||||
const TOKEN_HALVES = [TOKEN.slice(0, 20), TOKEN.slice(20)];
|
||||
const PASSED_VALUE = 'plain' + randomAlnum(20);
|
||||
const EMAIL = ['alice.smith', 'acme-corp.io'].join('@');
|
||||
const HOME_PATH = ['', 'home', 'alice', 'project', 'settings.json'].join('/');
|
||||
const ERROR_LINE = /^gstack-error-capture: .+; fix: .+ \(DX_CAPTURE_[A-Z_]+\)\n$/;
|
||||
|
||||
const FIXTURES: Record<string, string> = {
|
||||
'tty.ts': "import { openSync } from 'node:fs';\nopenSync('/dev/tty', 'r');\n",
|
||||
'refuse.ts': "console.error('error: stdin is not a tty; pass --yes to skip the confirmation');\nprocess.exit(1);\n",
|
||||
'prompt.ts': "process.stdout.write('Continue? [y/N] ');\nfor await (const _ of process.stdin) {}\nconsole.error('Error: EOF while reading the answer');\nprocess.exit(1);\n",
|
||||
'sleeper.ts': "import { spawn } from 'node:child_process';\nimport { writeFileSync } from 'node:fs';\nconst child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });\nwriteFileSync(process.argv[2], String(child.pid));\nsetInterval(() => {}, 1000);\n",
|
||||
'ignore-term.ts': "process.on('SIGTERM', () => {});\nsetInterval(() => {}, 1000);\n",
|
||||
'exit124.ts': "process.exit(124);\n",
|
||||
'secrets.ts': [
|
||||
"const token = process.argv[2] + process.argv[3];",
|
||||
"const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));",
|
||||
"const write = (stream: NodeJS.WriteStream, text: string) => new Promise(resolve => stream.write(text, resolve));",
|
||||
"await write(process.stdout, 'token ' + token + '\\n');",
|
||||
"await write(process.stdout, JSON.stringify({ outer: { list: [{ secret: token }] } }) + '\\n');",
|
||||
"await write(process.stderr, 'auth failed for ' + token + '\\n');",
|
||||
"await write(process.stderr, token.slice(0, 10) + '\\x1b[0m' + token.slice(10) + '\\n');",
|
||||
"await sleep(50);",
|
||||
"await write(process.stdout, 'split ' + token.slice(0, 15));",
|
||||
"await sleep(50);",
|
||||
"await write(process.stdout, token.slice(15) + ' done\\n');",
|
||||
"process.exit(1);",
|
||||
].join('\n'),
|
||||
'cross.ts': "const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));\nawait new Promise(resolve => process.stdout.write(process.argv[2], resolve));\nawait sleep(50);\nawait new Promise(resolve => process.stderr.write(process.argv[3], resolve));\n",
|
||||
'echo-env.ts': "const value = process.env.DX_TEST_VALUE ?? '';\nconsole.log('value=' + value);\nconsole.error('encoded=' + Buffer.from(value).toString('base64'));\n",
|
||||
'big.ts': "const token = process.argv[2] + process.argv[3];\nprocess.stdout.write('a '.repeat(45) + token + ' ' + 'b'.repeat(200) + '\\n');\n",
|
||||
'bad-utf8.ts': "process.stdout.write(Buffer.from([0x6f, 0x6b, 0xff, 0xfe, 0x0a]));\nprocess.stderr.write('fine\\n');\n",
|
||||
'pii.ts': "console.error('cannot open " + HOME_PATH + " for " + EMAIL + "');\nprocess.exit(2);\n",
|
||||
'exit0.ts': "console.error('Error: could not connect to the database');\nprocess.exit(0);\n",
|
||||
'env.ts': "import { readFileSync, statSync } from 'node:fs';\nconst config = process.env.HOME + '/.config/tool/config.json';\nlet copied = null;\ntry { copied = { text: readFileSync(config, 'utf8'), mode: statSync(config).mode & 0o777 }; } catch {}\nconsole.log(JSON.stringify({ cwd: process.cwd(), home: process.env.HOME, xdg: process.env.XDG_CONFIG_HOME, term: process.env.TERM, keys: Object.keys(process.env).sort(), copied }));\n",
|
||||
};
|
||||
for (const [name, source] of Object.entries(FIXTURES)) fs.writeFileSync(path.join(ROOT, name), source);
|
||||
fs.mkdirSync(path.join(FAKE_HOME, '.config/tool'), { recursive: true });
|
||||
fs.writeFileSync(path.join(FAKE_HOME, '.config/tool/config.json'), '{"color":false}\n');
|
||||
fs.symlinkSync(path.join(FAKE_HOME, '.config/tool/config.json'), path.join(FAKE_HOME, '.linked.json'));
|
||||
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
function helper(args: string[], env: Record<string, string> = {}) {
|
||||
const result = spawnSync(process.execPath, [BIN, ...args], {
|
||||
encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE, ...env },
|
||||
});
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
function capture(flags: string[], fixture: string, args: string[] = [], env: Record<string, string> = {}) {
|
||||
const result = helper([...flags, '--', process.execPath, path.join(ROOT, fixture), ...args], env);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
return { ...result, record: JSON.parse(lines[0]) };
|
||||
}
|
||||
|
||||
function gone(pid: number): boolean {
|
||||
try { process.kill(pid, 0); } catch { return true; }
|
||||
try { return fs.readFileSync('/proc/' + pid + '/stat', 'utf8').replace(/^.*\) /s, '').startsWith('Z'); } catch { return true; }
|
||||
}
|
||||
|
||||
const leaksToken = (text: string) => text.includes(TOKEN) || text.includes(TOKEN.slice(4));
|
||||
|
||||
describe('gstack-error-capture terminal evidence', () => {
|
||||
test('reading /dev/tty without a controlling terminal is refused, not timed out', () => {
|
||||
const { record } = capture([], 'tty.ts');
|
||||
expect(record.timed_out).toBe(false);
|
||||
expect(record.exit_code).not.toBe(0);
|
||||
expect(record.terminal.verdict).toBe('refused_no_alternative');
|
||||
expect(record.launch).toBe('ok');
|
||||
});
|
||||
|
||||
test('a TTY failure naming --yes is refused_with_alternative', () => {
|
||||
const { record } = capture([], 'refuse.ts');
|
||||
expect(record.exit_code).toBe(1);
|
||||
expect(record.terminal).toEqual({ verdict: 'refused_with_alternative', evidence: 'stderr matched "not a tty"; alternative "--yes"' });
|
||||
});
|
||||
|
||||
test('EOF at a prompt with stdin closed is unknown, not blocking', () => {
|
||||
const { record } = capture([], 'prompt.ts');
|
||||
expect(record.timed_out).toBe(false);
|
||||
expect(record.terminal.verdict).toBe('unknown');
|
||||
expect(record.stdout).toBe('Continue? [y/N] ');
|
||||
});
|
||||
|
||||
test('a sleeper past the deadline is killed with its descendant', async () => {
|
||||
const pidFile = path.join(ROOT, 'sleeper.pid');
|
||||
const started = Date.now();
|
||||
const { record } = capture(['--deadline', '2'], 'sleeper.ts', [pidFile]);
|
||||
expect(Date.now() - started).toBeLessThan(15_000);
|
||||
expect(record.timed_out).toBe(true);
|
||||
expect(record.exit_code).toBeNull();
|
||||
expect(record.deadline_s).toBe(2);
|
||||
expect(record.terminal.verdict).toBe('blocking');
|
||||
const pid = Number(fs.readFileSync(pidFile, 'utf8'));
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
for (let i = 0; i < 30 && !gone(pid); i++) await Bun.sleep(100);
|
||||
expect(gone(pid)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test('a target ignoring SIGTERM is still killed at the deadline', () => {
|
||||
const { record } = capture(['--deadline', '1'], 'ignore-term.ts');
|
||||
expect(record.timed_out).toBe(true);
|
||||
expect(record.terminal.verdict).toBe('blocking');
|
||||
}, 30_000);
|
||||
|
||||
test('a target exiting 124 itself is not timed out', () => {
|
||||
const { record } = capture(['--deadline', '10'], 'exit124.ts');
|
||||
expect(record.timed_out).toBe(false);
|
||||
expect(record.exit_code).toBe(124);
|
||||
expect(record.terminal.verdict).toBe('none');
|
||||
});
|
||||
|
||||
test('an error reported with exit 0 is recorded as exit 0', () => {
|
||||
const { record } = capture([], 'exit0.ts');
|
||||
expect(record.exit_code).toBe(0);
|
||||
expect(record.stderr).toContain('could not connect to the database');
|
||||
expect(record.terminal.verdict).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-error-capture redaction', () => {
|
||||
test('a HIGH token on stdout, stderr, nested JSON, behind ANSI and split across writes is masked everywhere', () => {
|
||||
const result = capture([], 'secrets.ts', TOKEN_HALVES);
|
||||
expect(leaksToken(result.stdout)).toBe(false);
|
||||
expect(leaksToken(result.stderr)).toBe(false);
|
||||
const { record } = result;
|
||||
expect(record.stdout).toContain('<REDACTED-github.pat>');
|
||||
expect(record.stdout).toContain('split <REDACTED-github.pat> done');
|
||||
expect(record.stderr).toContain('auth failed for <REDACTED-github.pat>');
|
||||
expect(record.stderr).not.toContain('\u001b');
|
||||
expect(record.secret_findings.classes).toEqual(['github.pat']);
|
||||
expect(record.secret_findings.count).toBe(5);
|
||||
expect(record.scan_complete).toBe(true);
|
||||
expect(record.withheld_reason).toEqual({ stdout: null, stderr: null });
|
||||
});
|
||||
|
||||
test('a token split across stdout and stderr withholds both streams', () => {
|
||||
const result = capture([], 'cross.ts', TOKEN_HALVES);
|
||||
expect(leaksToken(result.stdout)).toBe(false);
|
||||
expect(result.record.stdout).toBe('withheld');
|
||||
expect(result.record.stderr).toBe('withheld');
|
||||
expect(result.record.withheld_reason).toEqual({ stdout: 'cross_stream_finding', stderr: 'cross_stream_finding' });
|
||||
});
|
||||
|
||||
test('an unpatterned --pass-env value is masked literally and in base64 with --approved', () => {
|
||||
const result = capture(['--pass-env', 'DX_TEST_VALUE', '--approved'], 'echo-env.ts', [], { DX_TEST_VALUE: PASSED_VALUE });
|
||||
const encoded = Buffer.from(PASSED_VALUE).toString('base64');
|
||||
for (const text of [result.stdout, result.stderr]) {
|
||||
expect(text).not.toContain(PASSED_VALUE);
|
||||
expect(text).not.toContain(encoded);
|
||||
}
|
||||
expect(result.record.stdout).toBe('value=<REDACTED-passed_env.value>\n');
|
||||
expect(result.record.secret_findings).toEqual({ count: 2, classes: ['passed_env.value'] });
|
||||
expect(result.record.passed_env).toEqual(['DX_TEST_VALUE']);
|
||||
expect(result.record.containment.env).toContain('DX_TEST_VALUE');
|
||||
});
|
||||
|
||||
test('truncation runs after redaction and drops the run cut at --max-bytes', () => {
|
||||
const result = capture(['--max-bytes', '100'], 'big.ts', TOKEN_HALVES);
|
||||
expect(leaksToken(result.stdout)).toBe(false);
|
||||
expect(result.record.truncated).toEqual({ stdout: true, stderr: false });
|
||||
expect(result.record.stdout).toBe('a '.repeat(45));
|
||||
expect(result.record.secret_findings.classes).toEqual(['github.pat']);
|
||||
});
|
||||
|
||||
test('invalid UTF-8 withholds only that stream', () => {
|
||||
const { record } = capture([], 'bad-utf8.ts');
|
||||
expect(record.stdout).toBe('withheld');
|
||||
expect(record.withheld_reason).toEqual({ stdout: 'invalid_utf8', stderr: null });
|
||||
expect(record.stderr).toBe('fine\n');
|
||||
expect(record.scan_complete).toBe(false);
|
||||
});
|
||||
|
||||
test('emails and home paths are masked as other_masked, not secrets', () => {
|
||||
const result = capture([], 'pii.ts');
|
||||
expect(result.stdout).not.toContain(EMAIL);
|
||||
expect(result.stdout).not.toContain(HOME_PATH);
|
||||
expect(result.record.secret_findings).toEqual({ count: 0, classes: [] });
|
||||
expect(result.record.other_masked.classes).toEqual(['internal.user_path', 'pii.email']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-error-capture containment', () => {
|
||||
test('the target gets a temp cwd and HOME, a scrubbed env and TERM=dumb; the run dir is removed', () => {
|
||||
const staleDir = path.join(STATE, 'error-capture', 'run-' + spawnSync(process.execPath, ['-e', ''], { timeout: 10_000 }).pid + '-stale');
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
const { record } = capture([], 'env.ts', [], { DX_UNLISTED_SECRET_NAME: 'x', MISE_GITHUB_TOKEN: 'y', MISE_ENV: 'dev' });
|
||||
const seen = JSON.parse(record.stdout);
|
||||
expect(seen.cwd.startsWith(fs.realpathSync(STATE))).toBe(true);
|
||||
expect(seen.home.startsWith(fs.realpathSync(STATE))).toBe(true);
|
||||
expect(seen.xdg).toBe(seen.home + '/.config');
|
||||
expect(seen.term).toBe('dumb');
|
||||
expect(seen.keys).not.toContain('DX_UNLISTED_SECRET_NAME');
|
||||
expect(seen.keys).not.toContain('MISE_GITHUB_TOKEN');
|
||||
expect(seen.keys).toContain('MISE_ENV');
|
||||
expect(record.containment).toEqual({ cwd: 'temp', home: 'temp', env: seen.keys });
|
||||
expect(fs.readdirSync(path.join(STATE, 'error-capture'))).toEqual([]);
|
||||
expect(fs.statSync(path.join(STATE, 'error-capture')).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('--copy-config copies a file under HOME to the same relative path with mode 0600', () => {
|
||||
const { record } = capture(['--approved', '--copy-config', path.join(FAKE_HOME, '.config/tool/config.json')], 'env.ts', [], { HOME: FAKE_HOME });
|
||||
expect(record.copied_config).toEqual(['.config/tool/config.json']);
|
||||
expect(JSON.parse(record.stdout).copied).toEqual({ text: '{"color":false}\n', mode: 0o600 });
|
||||
});
|
||||
|
||||
test('--copy-config refuses a symlink', () => {
|
||||
const result = helper(['--approved', '--copy-config', path.join(FAKE_HOME, '.linked.json'), '--', process.execPath, '--version'], { HOME: FAKE_HOME });
|
||||
expect(result.status).toBe(3);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toMatch(ERROR_LINE);
|
||||
expect(result.stderr).toContain('(DX_CAPTURE_CONFIG_REFUSED)');
|
||||
});
|
||||
|
||||
test('--launch-check runs <cmd> --version inside the containment', () => {
|
||||
const result = helper(['--launch-check', '--', process.execPath]);
|
||||
expect(result.status).toBe(0);
|
||||
const record = JSON.parse(result.stdout);
|
||||
expect(record.launch).toBe('ok');
|
||||
expect(record.argv).toEqual([process.execPath, '--version']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-error-capture own errors', () => {
|
||||
test('--pass-env without --approved is refused with exit 3', () => {
|
||||
const result = helper(['--pass-env', 'DX_TEST_VALUE', '--', process.execPath, path.join(ROOT, 'echo-env.ts')], { DX_TEST_VALUE: PASSED_VALUE });
|
||||
expect(result.status).toBe(3);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toMatch(ERROR_LINE);
|
||||
expect(result.stderr).toContain('(DX_CAPTURE_APPROVAL_REQUIRED)');
|
||||
expect(result.stderr).not.toContain(PASSED_VALUE);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[['--deadline', '2', process.execPath], 2, 'DX_CAPTURE_USAGE'],
|
||||
[['--bogus', '--', process.execPath], 2, 'DX_CAPTURE_USAGE'],
|
||||
[['--deadline', 'soon', '--', process.execPath], 2, 'DX_CAPTURE_BAD_DEADLINE'],
|
||||
[['--max-bytes', '0', '--', process.execPath], 2, 'DX_CAPTURE_BAD_MAX_BYTES'],
|
||||
[['--approved', '--pass-env', 'DX_SURELY_UNSET_VAR', '--', process.execPath], 2, 'DX_CAPTURE_BAD_PASS_ENV'],
|
||||
[['--approved', '--pass-env', 'HOME', '--', process.execPath], 2, 'DX_CAPTURE_BAD_PASS_ENV'],
|
||||
[['--approved', '--cwd', '/nonexistent-dx-capture-dir', '--', process.execPath], 2, 'DX_CAPTURE_BAD_CWD'],
|
||||
] as Array<[string[], number, string]>)('%p exits %p with %p', (args, status, code) => {
|
||||
const result = helper(args);
|
||||
expect(result.status).toBe(status);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toMatch(ERROR_LINE);
|
||||
expect(result.stderr).toContain('(' + code + ')');
|
||||
});
|
||||
|
||||
test('SIGTERM to the helper kills the target group, prints no record and exits 1', async () => {
|
||||
const pidFile = path.join(ROOT, 'interrupted.pid');
|
||||
const child = spawn(process.execPath, [BIN, '--deadline', '20', '--', process.execPath, path.join(ROOT, 'sleeper.ts'), pidFile], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GSTACK_STATE_ROOT: STATE },
|
||||
});
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout!.on('data', chunk => { stdout += chunk; });
|
||||
child.stderr!.on('data', chunk => { stderr += chunk; });
|
||||
const closed = new Promise<number | null>(resolve => child.once('close', code => resolve(code)));
|
||||
for (let i = 0; i < 100 && !fs.existsSync(pidFile); i++) await Bun.sleep(50);
|
||||
const pid = Number(fs.readFileSync(pidFile, 'utf8'));
|
||||
child.kill('SIGTERM');
|
||||
expect(await closed).toBe(1);
|
||||
expect(stdout).toBe('');
|
||||
expect(stderr).toMatch(ERROR_LINE);
|
||||
expect(stderr).toContain('(DX_CAPTURE_INTERRUPTED)');
|
||||
for (let i = 0; i < 30 && !gone(pid); i++) await Bun.sleep(100);
|
||||
expect(gone(pid)).toBe(true);
|
||||
expect(fs.readdirSync(path.join(STATE, 'error-capture'))).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
test('an unresolvable binary records launch not_found and exits 1', () => {
|
||||
const result = helper(['--', 'dx-capture-no-such-binary']);
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout).launch).toBe('not_found');
|
||||
expect(result.stderr).toMatch(ERROR_LINE);
|
||||
expect(result.stderr).toContain('(DX_CAPTURE_NOT_FOUND)');
|
||||
});
|
||||
|
||||
test('--help documents flags, record fields, exit codes and every code', () => {
|
||||
const result = helper(['--help']);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe(DX_CAPTURE_HELP);
|
||||
for (const word of ['--deadline', '--max-bytes', '--pass-env', '--copy-config', '--cwd', '--approved', '--launch-check',
|
||||
'timed_out', 'terminal', 'secret_findings', 'other_masked', 'scan_complete', 'withheld_reason', 'containment', '3 refused']) {
|
||||
expect(DX_CAPTURE_HELP).toContain(word);
|
||||
}
|
||||
for (const [code, row] of Object.entries(DX_CAPTURE_CODES)) {
|
||||
expect(code.startsWith('DX_CAPTURE_')).toBe(true);
|
||||
expect(DX_CAPTURE_HELP).toContain(code + ' (exit ' + row.exit);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user