Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/concise-usage-errors-redacted-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@diginodes/stellarforge-cli': patch
---

Report unknown commands as `unknown command` with a suggestion and the available command list, map subcommand usage errors (such as unknown options) to the validation exit code `2` with a one-line `--help` hint, and redact secret-like values (Stellar secret keys, seed phrases, tokens, passwords) from supervised subprocess output, including values split across output chunks.
43 changes: 38 additions & 5 deletions src/commands/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,54 @@ import { createDoctorCommand } from './doctor.js';
import { createNewCommand } from './new.js';
import { createTestCommand } from './test.js';

// Commander implements `unknownCommand()` publicly but omits it from typings.
interface UnknownCommandReporter {
unknownCommand(): never;
}

function applyUsageErrorHandling(command: Command, remediation: string): void {
command
.exitOverride()
.showHelpAfterError(remediation)
.showSuggestionAfterError(true);
}

export function createRootCommand(version = resolveCliVersion()): Command {
return new Command()
const program = new Command()
.name('stellarforge')
.description(
'Open infrastructure for building production-ready Stellar applications.',
)
.usage('[options] [command]')
.version(version)
.exitOverride()
.showHelpAfterError()
.showSuggestionAfterError(true)
.action(() => undefined)
// Operands reach the action so unknown commands are reported as such
// rather than as excess arguments.
.allowExcessArguments()
.action((_options: unknown, command: Command) => {
if (command.args.length > 0) {
(command as unknown as UnknownCommandReporter).unknownCommand();
}
})
.addCommand(createDoctorCommand())
.addCommand(createNewCommand())
.addCommand(createDevCommand())
.addCommand(createTestCommand())
.addCommand(createDeployCommand());

const commandNames = program.commands.map((command) => command.name());
applyUsageErrorHandling(
program,
`Available commands: ${commandNames.join(', ')}.\nRun 'stellarforge --help' for usage.`,
);

// `addCommand` does not inherit parent settings, so subcommand usage errors
// must be configured explicitly to share the root's exit-code mapping.
for (const command of program.commands) {
applyUsageErrorHandling(
command,
`Run 'stellarforge ${command.name()} --help' for usage.`,
);
}

return program;
}
66 changes: 54 additions & 12 deletions src/dev/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { spawn } from 'node:child_process';
import { SubprocessCliError, ValidationCliError } from '../errors/errors.js';
import { redactSecretLikeText } from '../output/redaction.js';
import { TerminalOutput } from '../output/terminal.js';
import { buildSafeChildEnvironment } from './environment.js';
import type { DevProcessSpec } from './project.js';

interface ManagedStream {
on(event: 'data', listener: (chunk: Buffer | string) => void): unknown;
on(event: 'end', listener: () => void): unknown;
}

export interface ManagedChildProcess {
Expand Down Expand Up @@ -48,24 +50,54 @@ function defaultSpawnProcess(
}) as unknown as ManagedChildProcess;
}

function writePrefixedLines(
const MAX_BUFFERED_LINE_LENGTH = 64 * 1024;

interface PrefixedLineWriter {
write(chunk: Buffer | string): void;
flush(): void;
}

// Child output is untrusted: buffer partial lines so secret-like values split
// across chunks are still redacted as a whole before reaching the terminal.
function createPrefixedLineWriter(
output: TerminalOutput,
label: string,
chunk: Buffer | string,
isError: boolean,
): void {
for (const line of String(chunk).split(/\r?\n/)) {
): PrefixedLineWriter {
let pending = '';

const emit = (line: string): void => {
if (line.length === 0) {
continue;
return;
}

const message = `[${label}] ${line}`;
const message = `[${label}] ${redactSecretLikeText(line)}`;
if (isError) {
output.warning(message);
} else {
output.info(message);
}
}
};

return {
write(chunk) {
const lines = `${pending}${String(chunk)}`.split(/\r?\n/);
pending = lines.pop() ?? '';

for (const line of lines) {
emit(line);
}

if (pending.length > MAX_BUFFERED_LINE_LENGTH) {
emit(pending);
pending = '';
}
},
flush() {
emit(pending);
pending = '';
},
};
}

export async function superviseDevProcesses(
Expand Down Expand Up @@ -136,16 +168,26 @@ export async function superviseDevProcesses(
}

children.push(child);
child.stdout.on('data', (chunk) =>
writePrefixedLines(output, spec.label, chunk, false),
);
child.stderr.on('data', (chunk) =>
writePrefixedLines(output, spec.label, chunk, true),
const stdoutWriter = createPrefixedLineWriter(
output,
spec.label,
false,
);
const stderrWriter = createPrefixedLineWriter(output, spec.label, true);
const flushOutput = (): void => {
stdoutWriter.flush();
stderrWriter.flush();
};
child.stdout.on('data', (chunk) => stdoutWriter.write(chunk));
child.stdout.on('end', () => stdoutWriter.flush());
child.stderr.on('data', (chunk) => stderrWriter.write(chunk));
child.stderr.on('end', () => stderrWriter.flush());
child.once('error', (error) => {
flushOutput();
fail(`Development process '${spec.label}' failed to start.`, error);
});
child.once('exit', (code, signal) => {
flushOutput();
remaining -= 1;

if (!shuttingDown && code !== 0) {
Expand Down
1 change: 1 addition & 0 deletions src/output/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { redactSecretLikeText, REDACTED_PLACEHOLDER } from './redaction.js';
export {
sanitizeTerminalText,
TerminalOutput,
Expand Down
20 changes: 20 additions & 0 deletions src/output/redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const REDACTED_PLACEHOLDER = '[REDACTED]';

const STELLAR_SECRET_STRKEY = /\bS[A-Z2-7]{55}\b/g;
const SENSITIVE_ASSIGNMENT =
/((?:secret(?:[ _-]?key)?|private[ _-]?key|seed(?:[ _-]?phrase)?|mnemonic|password|passphrase|(?:api|auth|access)?[_-]?token)["']?\s*[:=]\s*)\S.*$/gi;
const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi;

/**
* Replaces secret-like material in a single line of untrusted text.
*
* Redaction is intentionally conservative: once a sensitive label such as
* `secret:` or `mnemonic=` is seen, the remainder of the line is withheld,
* because multi-word values such as seed phrases cannot be bounded reliably.
*/
export function redactSecretLikeText(line: string): string {
return line
.replace(STELLAR_SECRET_STRKEY, REDACTED_PLACEHOLDER)
.replace(BEARER_CREDENTIAL, `$1${REDACTED_PLACEHOLDER}`)
.replace(SENSITIVE_ASSIGNMENT, `$1${REDACTED_PLACEHOLDER}`);
}
193 changes: 193 additions & 0 deletions tests/deploy-output-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest';
import { createDeployCommand } from '../src/commands/deploy.js';
import type { DevProcessSpec } from '../src/dev/project.js';
import {
EXIT_CODES,
runWithCliErrorBoundary,
SubprocessCliError,
} from '../src/errors/index.js';
import {
createCapturedTerminalOutput,
FakeChildProcess,
SECRET_FIXTURE_LINES,
SECRET_FIXTURE_VALUES,
SECRET_STRKEY_FIXTURE,
} from './helpers/index.js';

const DEPLOY_SPEC: DevProcessSpec = {
label: 'stellar:deploy',
command: 'stellar',
args: [
'contract',
'deploy',
'--source-account',
'deployer',
'--network',
'testnet',
],
cwd: '/workspace',
};
const DEPLOY_ARGS = [
'node',
'deploy',
'--network',
'testnet',
'--source',
'deployer',
];

async function runDeploy(
child: FakeChildProcess,
drive: (child: FakeChildProcess) => Promise<void>,
) {
const captured = createCapturedTerminalOutput();
const command = createDeployCommand({
cwd: () => '/workspace',
output: captured.output,
resolveConfig: (_cwd, overrides) => overrides,
resolvePlan: () => [DEPLOY_SPEC],
spawnProcess: () => child,
});

const exitCode = runWithCliErrorBoundary(async () => {
await command.parseAsync(DEPLOY_ARGS);
}, captured.output);
await drive(child);

return { exitCode: await exitCode, captured };
}

function expectNoSecrets(text: string): void {
for (const secret of SECRET_FIXTURE_VALUES) {
expect(text).not.toContain(secret);
}
}

describe('deploy stdout/stderr secret non-reflection', () => {
it('redacts secret-like stdout and stderr lines on success', async () => {
const { exitCode, captured } = await runDeploy(
new FakeChildProcess(),
(child) =>
child.finish(0, {
stdout: [...SECRET_FIXTURE_LINES.map((line) => `${line}\n`)],
stderr: [...SECRET_FIXTURE_LINES.map((line) => `${line}\n`)],
}),
);

expect(exitCode).toBe(EXIT_CODES.success);
expectNoSecrets(captured.stdoutText());
expectNoSecrets(captured.stderrText());
expect(captured.stdoutText()).toContain(
'[stellar:deploy] signing with [REDACTED] for tx',
);
expect(captured.stderrText()).toContain('[stellar:deploy] Secret key:');
});

it('redacts a secret split across subprocess output chunks', async () => {
const { captured } = await runDeploy(new FakeChildProcess(), (child) =>
child.finish(0, {
stderr: [
`leaked ${SECRET_STRKEY_FIXTURE.slice(0, 20)}`,
`${SECRET_STRKEY_FIXTURE.slice(20, 40)}`,
`${SECRET_STRKEY_FIXTURE.slice(40)} trailing\n`,
],
}),
);

expect(captured.stderrText()).not.toContain(
SECRET_STRKEY_FIXTURE.slice(0, 20),
);
expect(captured.stderrText()).toBe(
'[stellar:deploy] leaked [REDACTED] trailing\n',
);
});

it('redacts an unterminated final line flushed on exit', async () => {
const { captured } = await runDeploy(new FakeChildProcess(), (child) =>
child.finish(0, { stdout: [`Secret key: ${SECRET_STRKEY_FIXTURE}`] }),
);

expect(captured.stdoutText()).toContain(
'[stellar:deploy] Secret key: [REDACTED]',
);
expectNoSecrets(captured.stdoutText());
});

it('keeps an actionable, secret-free failure when deployment fails', async () => {
const { exitCode, captured } = await runDeploy(
new FakeChildProcess(),
(child) =>
child.finish(1, {
stderr: [
'error: transaction simulation failed: insufficient balance\n',
...SECRET_FIXTURE_LINES.map((line) => `${line}\n`),
],
}),
);
const stderr = captured.stderrText();

expect(exitCode).toBe(EXIT_CODES.subprocess);
expectNoSecrets(stderr);
expect(stderr).toContain(
'[stellar:deploy] error: transaction simulation failed: insufficient balance',
);
expect(stderr).toContain(
"Development process 'stellar:deploy' exited unexpectedly with code 1.",
);
});

it('does not surface secret-like spawn error details', async () => {
const child = new FakeChildProcess();
const captured = createCapturedTerminalOutput();
const command = createDeployCommand({
cwd: () => '/workspace',
output: captured.output,
resolveConfig: (_cwd, overrides) => overrides,
resolvePlan: () => [DEPLOY_SPEC],
spawnProcess: () => child,
});
let thrown: unknown;

const running = command.parseAsync(DEPLOY_ARGS).catch((error: unknown) => {
thrown = error;
});
await child.fail(
new Error(`spawn failed: secret=${SECRET_STRKEY_FIXTURE}`),
);
await running;

expect(thrown).toBeInstanceOf(SubprocessCliError);
const exitCode = await runWithCliErrorBoundary(() => {
throw thrown;
}, captured.output);

expect(exitCode).toBe(EXIT_CODES.subprocess);
expectNoSecrets(captured.stdoutText() + captured.stderrText());
expect(captured.stderrText()).toContain(
"Development process 'stellar:deploy' failed to start.",
);
});

it('does not surface secret-like errors thrown while starting the process', async () => {
const captured = createCapturedTerminalOutput();
const command = createDeployCommand({
cwd: () => '/workspace',
output: captured.output,
resolveConfig: (_cwd, overrides) => overrides,
resolvePlan: () => [DEPLOY_SPEC],
spawnProcess: () => {
throw new Error(`ENOENT stellar ${SECRET_STRKEY_FIXTURE}`);
},
});

const exitCode = await runWithCliErrorBoundary(async () => {
await command.parseAsync(DEPLOY_ARGS);
}, captured.output);

expect(exitCode).toBe(EXIT_CODES.subprocess);
expectNoSecrets(captured.stderrText());
expect(captured.stderrText()).toContain(
"Failed to start development process 'stellar:deploy'.",
);
});
});
Loading