Skip to content

Commit eccc59e

Browse files
authored
Improve credential lifecycle and log hygiene (#1054)
Keep credentials out of the extension logs, make logout trustworthy, and disclose what a support bundle collects before creating one. - Stop logging shell command lines and output; header command parse errors reference the offending line by number only. - Redact sensitive HTTP headers case-insensitively (authorization, cookies, API keys, and any header produced by `coder.headerCommand`) and OAuth credential fields in request and response bodies, including serialized ones on error paths. Bound the size of log lines. - Revoke OAuth tokens at the server during logout, before local state is cleared. - Warn with retry guidance when some stored credentials cannot be removed during logout, instead of showing a success message. - Delete legacy file-based credentials after migrating them to secret storage, instead of leaving plaintext copies behind. - Show a confirmation dialog disclosing what a support bundle collects before gathering anything.
1 parent fd217d7 commit eccc59e

30 files changed

Lines changed: 824 additions & 207 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
`coder.disableNotifications`; suppressed announcements highlight the status
1515
bar item instead). A new **Coder: View Announcements** command opens the full
1616
messages in a markdown preview.
17+
- Ask for confirmation before creating a support bundle, with a summary of the
18+
data it collects.
1719

1820
### Changed
1921

@@ -22,6 +24,12 @@
2224
workspaces are fetched and the view loads faster. Deployments too old to
2325
support the new filter now show a message explaining why instead of an
2426
empty list.
27+
- Logging out now revokes the OAuth tokens at the server and warns when locally
28+
stored credentials could not be fully removed.
29+
- Redact more sensitive data from HTTP logs: authorization and cookie headers
30+
regardless of casing, OAuth credential fields in request and response bodies,
31+
and headers produced by `coder.headerCommand`. Shell command output and the
32+
header command's output no longer appear in logs or error messages.
2533

2634
### Fixed
2735

@@ -41,6 +49,8 @@
4149
keeps workspace/folder `settings.json` from overriding them (the original
4250
SEC-200 goal) while fixing #1032, where a `machine`-scoped value could
4351
revert to its default in a remote window.
52+
- Delete the legacy file-based credentials after migrating them to secret
53+
storage, instead of leaving plaintext copies behind.
4454

4555
## [v1.15.2](https://github.com/coder/vscode-coder/releases/tag/v1.15.2) 2026-06-30
4656

src/command/exec.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,14 @@ export async function execCommand(
3333
options?: ExecCommandOptions,
3434
): Promise<ExecCommandResult> {
3535
const title = options?.title ?? "Command";
36-
logger.debug(`Executing ${title}: ${command}`);
36+
// The command string and its output can carry credentials, so log neither.
37+
logger.debug(`Executing ${title}`);
3738

3839
try {
3940
const result = await util.promisify(cp.exec)(command, {
4041
env: options?.env,
4142
});
4243
logger.debug(`${title} completed successfully`);
43-
if (result.stdout) {
44-
logger.debug(`${title} stdout:`, result.stdout);
45-
}
46-
if (result.stderr) {
47-
logger.debug(`${title} stderr:`, result.stderr);
48-
}
4944
return {
5045
success: true,
5146
stdout: result.stdout,
@@ -54,12 +49,6 @@ export async function execCommand(
5449
} catch (error) {
5550
if (isExecException(error)) {
5651
logger.warn(`${title} failed with exit code ${error.code}`);
57-
if (error.stdout) {
58-
logger.warn(`${title} stdout:`, error.stdout);
59-
}
60-
if (error.stderr) {
61-
logger.warn(`${title} stderr:`, error.stderr);
62-
}
6352
return {
6453
success: false,
6554
stdout: error.stdout,
@@ -68,7 +57,7 @@ export async function execCommand(
6857
};
6958
}
7059

71-
logger.warn(`${title} failed:`, error);
60+
logger.warn(`${title} failed to execute`);
7261
return { success: false };
7362
}
7463
}

src/commands.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,11 @@ export class Commands {
434434

435435
const { agentName, client, workspaceId, remoteAuthority } = resolved;
436436

437+
if (!(await this.confirmSupportBundleCollection())) {
438+
telemetry.abort("prompt");
439+
return;
440+
}
441+
437442
const outputUri = await this.promptSupportBundlePath();
438443
if (!outputUri) {
439444
telemetry.abort("save_dialog");
@@ -523,6 +528,27 @@ export class Commands {
523528
});
524529
}
525530

531+
/** Modal disclosure of what a support bundle collects; the CLI's own prompt is suppressed. */
532+
private async confirmSupportBundleCollection(): Promise<boolean> {
533+
const detail = [
534+
"A support bundle may contain sensitive information. It collects:",
535+
"",
536+
"\u2022 Deployment and workspace diagnostics",
537+
"\u2022 Coder extension and connection logs from recent VS Code windows",
538+
"\u2022 Remote SSH extension logs",
539+
"\u2022 Locally recorded telemetry",
540+
"\u2022 Coder extension settings",
541+
"",
542+
"Review the bundle before sharing it.",
543+
].join("\n");
544+
const choice = await vscode.window.showInformationMessage(
545+
"Create a support bundle?",
546+
{ modal: true, detail },
547+
"Continue",
548+
);
549+
return choice === "Continue";
550+
}
551+
526552
public async exportTelemetry(): Promise<void> {
527553
await this.diagnosticTelemetry.trace("export_telemetry", (telemetry) =>
528554
this.runExportTelemetry(telemetry),
@@ -596,8 +622,14 @@ export class Commands {
596622
await this.deploymentManager.clearDeployment("logout");
597623

598624
if (deployment) {
599-
await this.cliManager.clearCredentials(deployment.url);
625+
const cleared = await this.cliManager.clearCredentials(deployment.url);
600626
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
627+
if (!cleared) {
628+
vscode.window.showWarningMessage(
629+
'You\'ve been logged out of Coder, but some credentials could not be removed. Log out again to retry, or run "coder logout" in a terminal.',
630+
);
631+
return { success: false, reason: "cleanup_incomplete" };
632+
}
601633
}
602634

603635
this.showLogoutMessage();

src/core/cliCredentialManager.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -171,39 +171,42 @@ export class CliCredentialManager {
171171

172172
/**
173173
* Delete credentials for a deployment. Removes the default-dir files and
174-
* logs out of the active store (keyring or file via --global-config), both
175-
* best-effort. Throws AbortError when the signal is aborted.
174+
* logs out of the active store (keyring or file via --global-config).
175+
* Returns whether every store was cleared instead of throwing, except
176+
* for AbortError when the signal is aborted.
176177
*/
177178
public deleteToken(
178179
url: string,
179180
configs: Pick<WorkspaceConfiguration, "get">,
180181
options?: { signal?: AbortSignal },
181-
): Promise<void> {
182+
): Promise<boolean> {
182183
return this.credentialTelemetry.traceClear(configs, async (span) => {
183-
await Promise.all([
184+
const [filesCleared, cliCleared] = await Promise.all([
184185
this.deleteCredentialFiles(url),
185186
this.cliLogout(url, configs, { signal: options?.signal, span }),
186187
]);
188+
return filesCleared && cliCleared;
187189
});
188190
}
189191

190192
/**
191193
* Log out via `coder logout`, keyring or file (--global-config). Records
192-
* failures on the span instead of throwing (except on abort).
194+
* failures on the span instead of throwing (except on abort) and returns
195+
* whether the logout succeeded.
193196
*/
194197
private async cliLogout(
195198
url: string,
196199
configs: Pick<WorkspaceConfiguration, "get">,
197200
{ signal, span }: { signal?: AbortSignal; span: Span },
198-
): Promise<void> {
201+
): Promise<boolean> {
199202
let transport: CliTransport;
200203
try {
201204
transport = await this.resolveWriteTransport(url, configs);
202205
} catch (error) {
203206
this.logger.warn("Could not resolve CLI binary for logout:", error);
204207
span.setProperty("error.type", "binary");
205208
span.markError();
206-
return;
209+
return false;
207210
}
208211
const args = [
209212
...this.credentialGlobalFlags(transport, url, configs),
@@ -215,13 +218,15 @@ export class CliCredentialManager {
215218
try {
216219
await this.execWithTimeout(transport.binPath, args, { signal });
217220
this.logger.info("Deleted token via CLI for", url);
221+
return true;
218222
} catch (error) {
219223
if (isAbortError(error)) {
220224
throw error;
221225
}
222226
this.logger.warn("Failed to delete token via CLI:", error);
223227
span.setProperty("error.type", "cli");
224228
span.markError();
229+
return false;
225230
}
226231
}
227232

@@ -311,21 +316,27 @@ export class CliCredentialManager {
311316
}
312317

313318
/**
314-
* Delete URL and token files. Best-effort: never throws.
319+
* Delete URL and token files. Returns whether all removals succeeded;
320+
* never throws.
315321
*/
316-
private async deleteCredentialFiles(url: string): Promise<void> {
322+
private async deleteCredentialFiles(url: string): Promise<boolean> {
317323
const safeHostname = toSafeHost(url);
318324
const paths = [
319325
this.pathResolver.getSessionTokenPath(safeHostname),
320326
this.pathResolver.getUrlPath(safeHostname),
321327
];
322-
await Promise.all(
328+
const results = await Promise.all(
323329
paths.map((p) =>
324-
fs.rm(p, { force: true }).catch((error) => {
325-
this.logger.warn("Failed to remove credential file", p, error);
326-
}),
330+
fs.rm(p, { force: true }).then(
331+
() => true,
332+
(error) => {
333+
this.logger.warn("Failed to remove credential file", p, error);
334+
return false;
335+
},
336+
),
327337
),
328338
);
339+
return results.every(Boolean);
329340
}
330341
}
331342

src/core/cliManager.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,9 +1061,10 @@ export class CliManager {
10611061

10621062
/**
10631063
* Remove credentials for a deployment. Clears both file-based credentials
1064-
* and keyring entries (via `coder logout`). All cleanup is best-effort.
1064+
* and keyring entries (via `coder logout`). Never throws; returns whether
1065+
* every store was cleared.
10651066
*/
1066-
public async clearCredentials(url: string): Promise<void> {
1067+
public async clearCredentials(url: string): Promise<boolean> {
10671068
const configs = vscode.workspace.getConfiguration();
10681069
const result = await withOptionalProgress(
10691070
({ signal }) =>
@@ -1076,13 +1077,14 @@ export class CliManager {
10761077
},
10771078
);
10781079
if (result.ok) {
1079-
return;
1080+
return result.value;
10801081
}
10811082
if (result.cancelled) {
10821083
this.output.info("Credential removal cancelled by user");
10831084
} else {
10841085
this.output.warn("Failed to remove credentials:", result.error);
10851086
}
1087+
return false;
10861088
}
10871089

10881090
private handleStoreError(error: unknown): void {

src/deployment/deploymentManager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ export class DeploymentManager implements vscode.Disposable {
201201
"Clearing deployment",
202202
this.#sessionStore.current.deployment?.safeHostname,
203203
);
204+
if (reason === "logout") {
205+
// Best-effort server-side revocation before local state is cleared.
206+
await this.oauthSessionManager.revokeTokens();
207+
}
204208
const wasAuthenticated = this.isAuthenticated();
205209
this.#authListenerDisposable?.dispose();
206210
this.#authListenerDisposable = undefined;

src/headers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@ export async function getHeaders(
3838
return headers;
3939
}
4040
const lines = result.stdout.replace(/\r?\n$/, "").split(/\r?\n/);
41-
for (const line of lines) {
41+
for (const [index, line] of lines.entries()) {
4242
const [key, value] = line.split(/=(.*)/);
4343
// Header names cannot be blank or contain whitespace and the Coder CLI
4444
// requires that there be an equals sign (the value can be blank though).
4545
if (key.length === 0 || key.includes(" ") || value === undefined) {
46+
// The output can carry credentials; reference the line by number only.
4647
throw new Error(
47-
`Malformed line from header command: [${line}] (out: ${result.stdout})`,
48+
`Malformed line ${index + 1} from header command output`,
4849
);
4950
}
5051
headers[key] = value;

src/instrumentation/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export type AuthLoginOutcome =
1717
| { success: true; method: LoginMethod }
1818
| { success: false; method?: LoginMethod; reason: LoginPromptReason };
1919
export type AuthLogoutOutcome =
20-
{ success: true } | { success: false; reason: "not_authenticated" };
20+
| { success: true }
21+
| { success: false; reason: "not_authenticated" | "cleanup_incomplete" };
2122

2223
interface AuthLoginTrace {
2324
setMethod: (method: LoginMethod) => void;

src/instrumentation/credentials.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,25 +27,26 @@ export class CredentialTelemetry {
2727
return this.trace("auth.credential.store", configs, fn);
2828
}
2929

30-
public traceClear(
30+
public traceClear<T>(
3131
configs: Pick<WorkspaceConfiguration, "get">,
32-
fn: (span: Span) => Promise<void>,
33-
): Promise<void> {
32+
fn: (span: Span) => Promise<T>,
33+
): Promise<T> {
3434
return this.trace("auth.credential.clear", configs, fn);
3535
}
3636

37-
private async trace(
37+
private async trace<T>(
3838
eventName: CredentialEvent,
3939
configs: Pick<WorkspaceConfiguration, "get">,
40-
fn: (span: Span) => Promise<void>,
41-
): Promise<void> {
40+
fn: (span: Span) => Promise<T>,
41+
): Promise<T> {
4242
const keyringEnabled = isKeyringEnabled(configs);
4343
let aborted: Error | undefined;
44+
let result: T | undefined;
4445
await this.telemetry.trace(
4546
eventName,
4647
async (span) => {
4748
try {
48-
await fn(span);
49+
result = await fn(span);
4950
} catch (error) {
5051
if (isAbortError(error)) {
5152
span.markAborted();
@@ -64,6 +65,7 @@ export class CredentialTelemetry {
6465
if (aborted) {
6566
throw aborted;
6667
}
68+
return result as T;
6769
}
6870
}
6971

0 commit comments

Comments
 (0)