Skip to content

Commit ff05824

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(cli,sdk): reduce sensitive diagnostic data
Reduces sensitive values retained in CLI and SDK diagnostics. Build command logs redact build arguments, environment pulls secure destination permissions before writing, Git metadata removes URL credentials, and retry telemetry records only a small safe set of URL and response details without changing requests or retry behavior. Mono-RevId: e6854bf5379682e208a49fcfa7477d8b799dc849
1 parent 478422f commit ff05824

10 files changed

Lines changed: 273 additions & 26 deletions

File tree

‎.changeset/quiet-credentials.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"trigger.dev": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Reduce sensitive values in CLI and SDK diagnostics, secure files created by `trigger env pull`, and remove credentials from collected Git remote metadata.

‎packages/cli-v3/src/commands/env.ts‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { tryCatch } from "@trigger.dev/core";
33
import chalk from "chalk";
44
import Table from "cli-table3";
55
import type { Command } from "commander";
6-
import { writeFile } from "node:fs/promises";
76
import { resolve } from "node:path";
87
import { z } from "zod";
98
import {
@@ -15,6 +14,7 @@ import {
1514
import { loadConfig } from "../config.js";
1615
import { printInitialBanner } from "../utilities/initialBanner.js";
1716
import { logger } from "../utilities/logger.js";
17+
import { writePrivateEnvFile } from "../utilities/privateEnvFile.js";
1818
import { getProjectClient } from "../utilities/session.js";
1919
import { spinner } from "../utilities/windows.js";
2020
import { login } from "./login.js";
@@ -385,13 +385,17 @@ async function _envPullCommand(options: z.infer<typeof EnvPullOptions>) {
385385

386386
const outputPath = resolve(process.cwd(), options.output);
387387

388-
const [error] = await tryCatch(writeFile(outputPath, "", { flag: "wx", mode: 0o600 }));
388+
const envContent = userVariables
389+
.map(([key, value]) => `${key}=${serializeDotenvValue(value)}`)
390+
.join("\n");
389391

390-
if (error && "code" in error && error.code !== "EEXIST") {
391-
throw error;
392+
const [createError] = await tryCatch(writePrivateEnvFile(outputPath, envContent + "\n", "wx"));
393+
394+
if (createError && (!isNodeError(createError) || createError.code !== "EEXIST")) {
395+
throw createError;
392396
}
393397

394-
if (error && "code" in error && error.code === "EEXIST" && !options.force) {
398+
if (createError && !options.force) {
395399
const shouldOverwrite = await confirm({
396400
message: `File ${options.output} already exists. Overwrite?`,
397401
initialValue: false,
@@ -403,14 +407,10 @@ async function _envPullCommand(options: z.infer<typeof EnvPullOptions>) {
403407
}
404408
}
405409

406-
const envContent = userVariables
407-
.map(([key, value]) => `${key}=${serializeDotenvValue(value)}`)
408-
.join("\n");
409-
410410
$spinner.start(`Writing to ${options.output}`);
411-
const [writeError] = await tryCatch(
412-
writeFile(outputPath, envContent + "\n", { encoding: "utf-8", mode: 0o600 })
413-
);
411+
const [writeError] = createError
412+
? await tryCatch(writePrivateEnvFile(outputPath, envContent + "\n", "w"))
413+
: [undefined];
414414

415415
if (writeError) {
416416
$spinner.stop(`Failed to write to ${options.output}`);
@@ -431,6 +431,10 @@ async function _envPullCommand(options: z.infer<typeof EnvPullOptions>) {
431431
outro(`Project: ${projectRef} | Environment: ${envInfo}`);
432432
}
433433

434+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
435+
return error instanceof Error && "code" in error;
436+
}
437+
434438
const serializeDotenvValue = (v: unknown): string => {
435439
if (v == null || v === undefined) return "";
436440

‎packages/cli-v3/src/deploy/buildImage.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,28 @@ import {
88
BUILD_IMAGE,
99
DEFAULT_PACKAGES,
1010
TOOLCHAIN_PACKAGES,
11+
formatCommandForDiagnostics,
1112
generateContainerfile,
1213
} from "./buildImage.js";
1314

15+
describe("formatCommandForDiagnostics", () => {
16+
it("redacts build argument values without changing the executable arguments", () => {
17+
const args = ["build", "--build-arg", "PUBLIC=value", "--build-arg", "SECRET=very-secret", "."];
18+
19+
expect(formatCommandForDiagnostics(args)).toBe(
20+
"build --build-arg PUBLIC=[REDACTED] --build-arg SECRET=[REDACTED] ."
21+
);
22+
expect(args).toEqual([
23+
"build",
24+
"--build-arg",
25+
"PUBLIC=value",
26+
"--build-arg",
27+
"SECRET=very-secret",
28+
".",
29+
]);
30+
});
31+
});
32+
1433
const images: Array<[BuildRuntime, string, string]> = [
1534
[
1635
"node-24",

‎packages/cli-v3/src/deploy/buildImage.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise<BuildI
245245
outputOptions.join(","),
246246
].filter(Boolean) as string[];
247247

248-
logger.debug(`depot ${args.join(" ")}`, { cwd: options.cwd });
248+
logger.debug(`depot ${formatCommandForDiagnostics(args)}`, { cwd: options.cwd });
249249

250250
// Step 4: Build and push the image
251251
const childProcess = depot(args, {
@@ -599,7 +599,7 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
599599
".", // The build context
600600
].filter(Boolean) as string[];
601601

602-
logger.debug(`docker ${args.join(" ")}`, { cwd: options.cwd });
602+
logger.debug(`docker ${formatCommandForDiagnostics(args)}`, { cwd: options.cwd });
603603

604604
const buildProcess = x("docker", args, {
605605
nodeOptions: {
@@ -685,6 +685,20 @@ function extractLogs(outputs: string[]) {
685685
return cleanedOutputs.map((line) => line.trim()).join("\n");
686686
}
687687

688+
export function formatCommandForDiagnostics(args: string[]): string {
689+
return args
690+
.map((arg, index) => {
691+
if (args[index - 1] !== "--build-arg") {
692+
return arg;
693+
}
694+
695+
const separatorIndex = arg.indexOf("=");
696+
const key = separatorIndex === -1 ? arg : arg.slice(0, separatorIndex);
697+
return `${key}=[REDACTED]`;
698+
})
699+
.join(" ");
700+
}
701+
688702
export type GenerateContainerfileOptions = {
689703
runtime: BuildRuntime;
690704
build: BuildManifest["build"];
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "vitest";
2+
import { sanitizeGitRemoteUrl } from "./gitMeta.js";
3+
4+
describe("sanitizeGitRemoteUrl", () => {
5+
it.each([
6+
["https://token@example.com/org/repo.git", "https://example.com/org/repo.git"],
7+
["https://user:password@example.com/org/repo.git", "https://example.com/org/repo.git"],
8+
["ssh://git@example.com/org/repo.git", "ssh://example.com/org/repo.git"],
9+
["https:/user:secret@example.com/org/repo.git", "https://example.com/org/repo.git"],
10+
["https:user:secret@example.com/org/repo.git", "https://example.com/org/repo.git"],
11+
["https:\\user:secret@example.com\\org\\repo.git", "https://example.com/org/repo.git"],
12+
[" https:/user:secret@example.com/org/repo.git ", "https://example.com/org/repo.git"],
13+
])("strips userinfo from URL and scheme-looking remotes", (remote, expected) => {
14+
expect(sanitizeGitRemoteUrl(remote)).toBe(expected);
15+
});
16+
17+
it.each([
18+
"git@example.com:org/repo.git",
19+
"example.com:org/repo.git",
20+
"/workspace/repo",
21+
"../repo",
22+
"./repo",
23+
"repo",
24+
"C:\\workspace\\repo",
25+
"file:///workspace/repo.git",
26+
])("preserves scp-style and local remotes", (remote) => {
27+
expect(sanitizeGitRemoteUrl(remote)).toBe(remote);
28+
});
29+
30+
it.each([
31+
"https://user:secret@ invalid/repo.git",
32+
"https:/user:secret@ invalid/repo.git",
33+
" https:/user:secret@ invalid/repo.git ",
34+
])("omits malformed URL-style remotes", (remote) => {
35+
expect(sanitizeGitRemoteUrl(remote)).toBeUndefined();
36+
});
37+
});

‎packages/cli-v3/src/utilities/gitMeta.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export async function createGitMeta(directory: string): Promise<GitMeta | undefi
3535

3636
return {
3737
source: "local",
38-
remoteUrl: remoteUrl ?? undefined,
38+
remoteUrl: sanitizeGitRemoteUrl(remoteUrl ?? undefined),
3939
commitAuthorName: commit.author.name,
4040
commitMessage: commit.subject,
4141
commitRef: commit.branch,
@@ -102,6 +102,27 @@ async function getOriginUrl(configPath: string): Promise<string | null> {
102102
return null;
103103
}
104104

105+
export function sanitizeGitRemoteUrl(remoteUrl: string | undefined): string | undefined {
106+
if (remoteUrl === undefined) {
107+
return;
108+
}
109+
110+
try {
111+
const url = new URL(remoteUrl);
112+
if (!url.username && !url.password) {
113+
return remoteUrl;
114+
}
115+
116+
url.username = "";
117+
url.password = "";
118+
return url.href;
119+
} catch {
120+
return remoteUrl.includes("://") || /^[a-z][a-z\d+.-]*:/i.test(remoteUrl.trim())
121+
? undefined
122+
: remoteUrl;
123+
}
124+
}
125+
105126
function errorToString(err: unknown): string {
106127
return err instanceof Error ? err.message : String(err);
107128
}
@@ -170,7 +191,7 @@ async function getGitHubActionsMeta(): Promise<GitMeta> {
170191
return {
171192
provider: "github",
172193
source: "github_actions",
173-
remoteUrl,
194+
remoteUrl: sanitizeGitRemoteUrl(remoteUrl),
174195
commitSha,
175196
commitRef,
176197
// In CI, the workspace is always clean
@@ -197,7 +218,7 @@ function getGitHubAppMeta(): GitMeta {
197218
return {
198219
provider: "github",
199220
source: "trigger_github_app",
200-
remoteUrl: process.env.GITHUB_REPOSITORY_URL,
221+
remoteUrl: sanitizeGitRemoteUrl(process.env.GITHUB_REPOSITORY_URL),
201222
commitSha: process.env.GITHUB_HEAD_COMMIT_SHA,
202223
commitRef: process.env.GITHUB_REF?.replace(/^refs\/(heads|tags)\//, ""),
203224
commitMessage: process.env.GITHUB_HEAD_COMMIT_MESSAGE,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { chmod, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { describe, expect, it } from "vitest";
5+
import { writePrivateEnvFile } from "./privateEnvFile.js";
6+
7+
describe("writePrivateEnvFile", () => {
8+
it("creates private files", async () => {
9+
const directory = await mkdtemp(join(tmpdir(), "trigger-env-"));
10+
const outputPath = join(directory, ".env.local");
11+
12+
await writePrivateEnvFile(outputPath, "SECRET=value\n", "wx");
13+
14+
expect(await readFile(outputPath, "utf8")).toBe("SECRET=value\n");
15+
expect((await stat(outputPath)).mode & 0o777).toBe(0o600);
16+
});
17+
18+
it("creates private files in overwrite mode when the path is absent", async () => {
19+
const directory = await mkdtemp(join(tmpdir(), "trigger-env-"));
20+
const outputPath = join(directory, ".env.local");
21+
22+
await writePrivateEnvFile(outputPath, "SECRET=value\n", "w");
23+
24+
expect(await readFile(outputPath, "utf8")).toBe("SECRET=value\n");
25+
expect((await stat(outputPath)).mode & 0o777).toBe(0o600);
26+
});
27+
28+
it("does not replace an existing file in exclusive mode", async () => {
29+
const directory = await mkdtemp(join(tmpdir(), "trigger-env-"));
30+
const outputPath = join(directory, ".env.local");
31+
await writeFile(outputPath, "old=value\n");
32+
33+
await expect(writePrivateEnvFile(outputPath, "SECRET=value\n", "wx")).rejects.toMatchObject({
34+
code: "EEXIST",
35+
});
36+
37+
expect(await readFile(outputPath, "utf8")).toBe("old=value\n");
38+
});
39+
40+
it("tightens an existing file before replacing its contents", async () => {
41+
const directory = await mkdtemp(join(tmpdir(), "trigger-env-"));
42+
const outputPath = join(directory, ".env.local");
43+
await writeFile(outputPath, "old=value\n", { mode: 0o644 });
44+
await chmod(outputPath, 0o644);
45+
46+
await writePrivateEnvFile(outputPath, "SECRET=value\n", "w");
47+
48+
expect(await readFile(outputPath, "utf8")).toBe("SECRET=value\n");
49+
expect((await stat(outputPath)).mode & 0o777).toBe(0o600);
50+
});
51+
52+
it("preserves symlinks while tightening and replacing their target", async () => {
53+
const directory = await mkdtemp(join(tmpdir(), "trigger-env-"));
54+
const targetPath = join(directory, "target.env");
55+
const outputPath = join(directory, ".env.local");
56+
await writeFile(targetPath, "old=value\n", { mode: 0o644 });
57+
await chmod(targetPath, 0o644);
58+
await symlink(targetPath, outputPath);
59+
60+
await writePrivateEnvFile(outputPath, "SECRET=value\n", "w");
61+
62+
expect(await readFile(targetPath, "utf8")).toBe("SECRET=value\n");
63+
expect((await stat(targetPath)).mode & 0o777).toBe(0o600);
64+
});
65+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { constants } from "node:fs";
2+
import { open } from "node:fs/promises";
3+
4+
export async function writePrivateEnvFile(
5+
outputPath: string,
6+
content: string,
7+
flags: "wx" | "w"
8+
): Promise<void> {
9+
const openFlags = flags === "wx" ? flags : constants.O_WRONLY | constants.O_CREAT;
10+
const file = await open(outputPath, openFlags, 0o600);
11+
12+
try {
13+
await file.chmod(0o600);
14+
await file.truncate(0);
15+
await file.writeFile(content, { encoding: "utf8" });
16+
} finally {
17+
await file.close();
18+
}
19+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { SEMATTRS_HTTP_URL } from "@opentelemetry/semantic-conventions";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
createAttributesFromHeaders,
5+
createFetchAttributes,
6+
createFetchResponseAttributes,
7+
} from "./retry.js";
8+
9+
describe("retry fetch telemetry", () => {
10+
it("omits URL credentials, query parameters, and fragments", () => {
11+
const attributes = createFetchAttributes(
12+
"https://user:password@example.com/path?token=secret#fragment"
13+
);
14+
15+
expect(attributes[SEMATTRS_HTTP_URL]).toBe("https://example.com/path");
16+
});
17+
18+
it("records only explicitly allowed response headers", () => {
19+
const attributes = createAttributesFromHeaders(
20+
new Headers({
21+
authorization: "secret",
22+
"content-type": "application/json",
23+
"retry-after": "10",
24+
"set-cookie": "session=secret",
25+
"x-api-key": "secret",
26+
"x-ratelimit-remaining": "9",
27+
})
28+
);
29+
30+
expect(attributes).toEqual({
31+
"http.response.header.content-type": "application/json",
32+
"http.response.header.retry-after": "10",
33+
});
34+
});
35+
36+
it("keeps status and content length response diagnostics", () => {
37+
const attributes = createFetchResponseAttributes(
38+
new Response(null, {
39+
status: 429,
40+
statusText: "Too Many Requests",
41+
headers: {
42+
"content-length": "123",
43+
"set-cookie": "session=secret",
44+
},
45+
})
46+
);
47+
48+
expect(attributes).toMatchObject({
49+
"http.status_code": 429,
50+
"http.status_text": "Too Many Requests",
51+
"http.response_content_length": "123",
52+
"http.response.header.content-length": "123",
53+
});
54+
expect(attributes).not.toHaveProperty("http.response.header.set-cookie");
55+
});
56+
});

0 commit comments

Comments
 (0)