fix(api): mask build arguments and preserve secrets on round trips

This commit is contained in:
AbdullahM07
2026-09-08 20:06:27 +03:00
parent 30ae48a4d6
commit bcdc8401f0
12 changed files with 325 additions and 21 deletions
+31
View File
@@ -0,0 +1,31 @@
import { createHmac } from "node:crypto";
import { isMaskedValue } from "@repo/core";
import { env } from "../config/env";
/** Compare stored literal args across a write response and deployment history
* without returning their values or an offline-guessable unkeyed hash. Scope to
* the project, service name and key so writing guesses in another project or
* service cannot be used as a fingerprint oracle. */
export function fingerprintBuildArgs(
projectId: string,
serviceName: string,
args: Record<string, string | null>,
templateKeys?: string[],
): Record<string, string> {
const fingerprints: Record<string, string> = {};
for (const [key, value] of Object.entries(args)) {
// Null inherits a value at build time. Templates also depend on that build's
// environment; fingerprinting the expression would falsely attest a value.
if (
value === null ||
isMaskedValue(value) ||
(templateKeys ? templateKeys.includes(key) : value.includes("$"))
) {
continue;
}
fingerprints[key] = `hmac-sha256:${createHmac("sha256", env.BETTER_AUTH_SECRET)
.update(JSON.stringify(["openship-build-arg-v1", projectId, serviceName, key, value]))
.digest("hex")}`;
}
return fingerprints;
}
+70 -10
View File
@@ -1,8 +1,8 @@
/**
* Compose-service `environment` masking (#336).
* Compose-service `environment` and `buildArgs` masking (#336, #854).
*
* A compose service's `environment` map is BOTH the deploy spec (injected into
* the container) AND display data. It routinely holds secrets (DB passwords, API
* A compose service's env and build-arg maps are BOTH the deploy spec AND
* display data. They routinely hold secrets (DB passwords, API
* tokens), yet — unlike project env vars, which carry an explicit `isSecret`
* flag — it's a flat `Record<string,string>` with no secret marker. So instead
* of a fragile key-name heuristic we mask *every* value on output and offer an
@@ -22,6 +22,7 @@
// The mask sentinel + predicate live in @repo/core so the dashboard's env editor
// shares the exact same string (the reveal/round-trip contract depends on it).
import { ENV_MASK, isMaskedValue } from "@repo/core";
import { fingerprintBuildArgs } from "./build-arg-fingerprint";
export { ENV_MASK, isMaskedValue };
/**
@@ -36,6 +37,32 @@ export function maskEnv(env: Record<string, string> | null | undefined): Record<
return out;
}
/** Null build args inherit from the build environment; empty strings stay empty. */
export function maskBuildArgs(args: Record<string, string | null> | null | undefined) {
return Object.fromEntries(
Object.entries(args ?? {}).map(([key, value]) => [
key,
value === null ? null : maskValue(value),
]),
);
}
/** Whole-map replacement, like buildArgs before masking, with sentinel recovery. */
export function unmaskBuildArgs(
incoming: Record<string, string | null> | null | undefined,
stored: Record<string, string | null> | null | undefined,
): Record<string, string | null> {
const result: Record<string, string | null> = {};
for (const [key, value] of Object.entries(incoming ?? {})) {
if (isMaskedValue(value)) {
if (stored && Object.hasOwn(stored, key)) result[key] = stored[key];
} else {
result[key] = value;
}
}
return result;
}
/**
* An EMPTY value stays empty — there is nothing there to hide, and dots in its
* place are an active lie: the wizard reads "no value" off the empty string to
@@ -128,31 +155,40 @@ export function mergeServiceEnv(
}
/** Whether an env map contains any mask sentinel (i.e. an un-revealed value). */
export function hasMaskedValue(env: Record<string, string> | null | undefined): boolean {
export function hasMaskedValue(env: Record<string, string | null> | null | undefined): boolean {
if (!env) return false;
return Object.values(env).some(isMaskedValue);
}
/**
* Mask the `environment` field of a single compose/deployable service. Returns a
* Mask the env and build-arg fields of a single compose/deployable service. Returns a
* shallow copy — the caller's stored object is left untouched. It also removes
* server-owned interpolation provenance before the service crosses an API
* boundary, even when the service has no runtime environment map.
*/
export function maskServiceEnv<
T extends {
name?: string;
projectId?: string;
buildArgs?: Record<string, string | null> | null;
importedSpec?: unknown;
driftSpec?: unknown;
environment?: Record<string, string> | null;
environmentTemplates?: Record<string, string> | null;
advanced?: {
imageTemplate?: unknown;
environmentTemplateKeys?: string[];
buildArgTemplateKeys?: string[];
[key: string]: unknown;
} | null;
},
>(svc: T | null | undefined): T | null | undefined {
>(svc: T | null | undefined, projectId?: string): T | null | undefined {
if (!svc) return svc;
if (
!svc.environment &&
!svc.buildArgs &&
!svc.importedSpec &&
!svc.driftSpec &&
!svc.environmentTemplates &&
!svc.advanced?.imageTemplate &&
!svc.advanced?.environmentTemplateKeys
@@ -162,7 +198,12 @@ export function maskServiceEnv<
// `environmentTemplates` is transient parser provenance. Its expressions can
// contain literal defaults, so never serialize it even though the persisted
// raw copy is already protected by blanket environment masking.
const { environmentTemplates: _templates, ...publicService } = svc;
const {
environmentTemplates: _templates,
importedSpec: _importedSpec,
driftSpec: _driftSpec,
...publicService
} = svc;
const advanced = svc.advanced ? { ...svc.advanced } : svc.advanced;
if (advanced) {
// Parser provenance is server-owned. Besides preventing a client from
@@ -174,6 +215,17 @@ export function maskServiceEnv<
return {
...publicService,
...(svc.environment ? { environment: maskEnv(svc.environment) } : {}),
...(svc.buildArgs ? { buildArgs: maskBuildArgs(svc.buildArgs) } : {}),
...(svc.buildArgs && (projectId || svc.projectId) && svc.name
? {
buildArgsFingerprints: fingerprintBuildArgs(
(projectId || svc.projectId)!,
svc.name,
svc.buildArgs,
svc.advanced?.buildArgTemplateKeys,
),
}
: {}),
...(advanced !== undefined ? { advanced } : {}),
} as T;
}
@@ -185,10 +237,10 @@ export function maskServicesEnv<
environmentTemplates?: Record<string, string> | null;
advanced?: { environmentTemplateKeys?: string[]; [key: string]: unknown } | null;
},
>(svcs: T[] | null | undefined): T[] {
>(svcs: T[] | null | undefined, projectId?: string): T[] {
if (!svcs) return [];
// Elements are concrete services, so the masked result is never null/undefined.
return svcs.map((s) => maskServiceEnv(s) as T);
return svcs.map((s) => maskServiceEnv(s, projectId) as T);
}
/** The value-bearing fields of a compose `environmentMeta` entry. */
@@ -256,7 +308,7 @@ export function maskScanService<
/**
* Mask the compose-service env carried in a deployment's `meta` snapshot
* (`meta.composeServices[].environment`). Returns a copy — the stored row/meta
* (`meta.composeServices[].environment` and `buildArgs`). Returns a copy — the stored row/meta
* is untouched (rollback/redeploy read the real values back). Apply at the
* CONTROLLER boundary only: `getDeployment` is also used internally and must
* keep plaintext. No-op when there's no `meta.composeServices`.
@@ -279,6 +331,7 @@ export function maskDeploymentEnv<T extends { meta?: unknown } | null | undefine
...meta,
composeServices: maskServicesEnv(
meta.composeServices as { environment?: Record<string, string> | null }[],
(dep as { projectId?: string }).projectId,
),
},
};
@@ -301,6 +354,13 @@ export function maskDriftChanges<T extends { field: string; from: unknown; to: u
to: maskEnv(c.to as Record<string, string> | null),
};
}
if (c.field === "buildArgs") {
return {
...c,
from: maskBuildArgs(c.from as Record<string, string | null> | null),
to: maskBuildArgs(c.to as Record<string, string | null> | null),
};
}
if (c.field === "advanced") {
const maskImageTemplate = (value: unknown): unknown => {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
@@ -184,6 +184,7 @@ export async function getBuildSessionStatus(deploymentId: string) {
// values on the way back in).
composeServices: maskServicesEnv(
(snapshot?.composeServices ?? []).filter((s) => serviceKind(s) === "compose"),
project.id,
),
}
: {};
@@ -59,7 +59,7 @@ import {
} from "./prepare.service";
import { ComposeConfigurationError } from "./compose-configuration-error";
import { getFolderSession } from "../projects/folder/session-store";
import { hasMaskedValue, isMaskedValue, unmaskEnv } from "../../lib/secret-env";
import { hasMaskedValue, isMaskedValue, unmaskEnv, unmaskBuildArgs } from "../../lib/secret-env";
import { assertValidCustomDomains, customHostnamesOf } from "../../lib/custom-domain-guard";
import {
assertBuildMinutesAvailable,
@@ -1676,19 +1676,28 @@ export async function requestBuildAccess(
// captured pre-mask) and the stored service rows — which reconcileComposeSource
// above just refreshed from a git repo's compose, so this also covers a git
// first-deploy. A revealed-and-edited value arrives real and passes through.
if (effectiveServices?.length && effectiveServices.some((s) => hasMaskedValue(s.environment))) {
if (
effectiveServices?.some((s) => hasMaskedValue(s.environment) || hasMaskedValue(s.buildArgs))
) {
const realEnvByName = new Map<string, Record<string, string>>();
const realArgsByName = new Map<string, Record<string, string | null>>();
for (const s of await listProjectComposeServices(project.id)) {
realEnvByName.set(s.name, (s.environment as Record<string, string> | null) ?? {});
realArgsByName.set(s.name, s.buildArgs ?? {});
}
for (const s of uploadSession?.services ?? []) {
if (s.name && s.environment) realEnvByName.set(s.name, s.environment);
if (s.name && s.buildArgs) realArgsByName.set(s.name, s.buildArgs);
}
effectiveServices = effectiveServices.map((s) =>
s.environment && hasMaskedValue(s.environment)
? { ...s, environment: unmaskEnv(s.environment, realEnvByName.get(s.name) ?? null) }
: s,
);
effectiveServices = effectiveServices.map((s) => ({
...s,
...(hasMaskedValue(s.environment) && {
environment: unmaskEnv(s.environment, realEnvByName.get(s.name)),
}),
...(hasMaskedValue(s.buildArgs) && {
buildArgs: unmaskBuildArgs(s.buildArgs, realArgsByName.get(s.name)),
}),
}));
}
const projectDomains = await listProjectRouteRows(project.id);
@@ -41,10 +41,12 @@ import { encrypt, decrypt } from "../../lib/encryption";
import {
ENV_MASK,
hasMaskedValue,
isMaskedValue,
maskDriftChanges,
maskServiceEnv,
mergeServiceEnv,
unmaskEnv,
unmaskBuildArgs,
} from "../../lib/secret-env";
import {
assertNotControlPlane,
@@ -673,7 +675,7 @@ export async function createService(
image: trimOrNull(data.image),
build: trimOrNull(data.build),
dockerfile: trimOrNull(data.dockerfile),
buildArgs: data.buildArgs ?? {},
buildArgs: unmaskBuildArgs(data.buildArgs, null),
ports: data.ports ?? [],
dependsOn: data.dependsOn ?? [],
environment: data.environment ?? {},
@@ -752,6 +754,9 @@ export async function updateService(
patch.environment,
);
}
if ("buildArgs" in patch) {
patch.buildArgs = unmaskBuildArgs(patch.buildArgs, svc.buildArgs);
}
// `advanced` is ONE blob holding independent, separately-owned keys —
// `healthcheck` (edited in the service form), `readiness` (the deploy gate),
@@ -784,7 +789,11 @@ export async function updateService(
// and be expanded on the next deploy.
patch.advanced = mergeAdvanced(
("advanced" in patch ? patch.advanced : svc.advanced) as ComposeAdvanced | null,
{ buildArgTemplateKeys: [] },
{
buildArgTemplateKeys: (
(svc.advanced as ComposeAdvanced | null)?.buildArgTemplateKeys ?? []
).filter((key) => isMaskedValue(data.buildArgs?.[key])),
},
);
}
@@ -1368,6 +1377,7 @@ export async function syncComposeServices(
const storedEnvByName = new Map(
stored.map((s) => [s.name, (s.environment as Record<string, string> | null) ?? {}]),
);
const storedArgsByName = new Map(stored.map((s) => [s.name, s.buildArgs]));
// Import path, but the hostnames are still client-authored — same gate as the
// create/update editors (normalizeRoutingPatch); `syncFromCompose` writes the
@@ -1410,6 +1420,9 @@ export async function syncComposeServices(
return {
...svc,
...(svc.buildArgs && {
buildArgs: unmaskBuildArgs(svc.buildArgs, storedArgsByName.get(svc.name)),
}),
...(environment && { environment }),
...(persistTemplateProvenance && { environmentTemplates }),
};
@@ -1484,7 +1497,7 @@ export async function syncComposeServices(
}
}
return synced.map(maskServiceEnv);
return synced.map((svc) => maskServiceEnv(svc));
}
// ─── Service Deployments (per-deployment state) ──────────────────────────────
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
ENV_MASK,
maskDeploymentEnv,
maskDriftChanges,
maskServiceEnv,
} from "../../src/lib/secret-env";
const service = (value: string) => ({
name: "web",
projectId: "project-1",
buildArgs: { TOKEN: value, INHERITED: null, TEMPLATE: "${TOKEN}", EMPTY: "" },
advanced: { buildArgTemplateKeys: ["TEMPLATE"] },
importedSpec: { buildArgs: { TOKEN: "original-secret" } },
driftSpec: { buildArgs: { TOKEN: "pending-secret" } },
});
describe("build argument response protection (#854)", () => {
it("masks old and new deployments without changing rollback input, and verifies literal rotations", () => {
const old = {
id: "d1",
projectId: "project-1",
meta: { composeServices: [service("old-secret")] },
};
const current = {
id: "d2",
projectId: "project-1",
meta: { composeServices: [service("new-secret")] },
};
const oldResponse = maskDeploymentEnv(old);
const newResponse = maskDeploymentEnv(current);
const saved = maskServiceEnv(service("new-secret")) as any;
const oldPublic = oldResponse.meta.composeServices[0] as any;
const newPublic = newResponse.meta.composeServices[0] as any;
for (const response of [oldPublic, newPublic, saved]) {
expect(response.buildArgs).toEqual({
TOKEN: ENV_MASK,
INHERITED: null,
TEMPLATE: ENV_MASK,
EMPTY: "",
});
expect(Object.keys(response.buildArgsFingerprints).sort()).toEqual(["EMPTY", "TOKEN"]);
expect(response).not.toHaveProperty("importedSpec");
expect(response).not.toHaveProperty("driftSpec");
expect(JSON.stringify(response)).not.toContain("-secret");
}
expect(saved.buildArgsFingerprints.TOKEN).toBe(newPublic.buildArgsFingerprints.TOKEN);
expect(oldPublic.buildArgsFingerprints.TOKEN).not.toBe(newPublic.buildArgsFingerprints.TOKEN);
expect(old.meta.composeServices[0].buildArgs.TOKEN).toBe("old-secret");
expect(current.meta.composeServices[0].buildArgs.TOKEN).toBe("new-secret");
});
it("does not let another project or service reproduce a target's fingerprint", () => {
const original = maskServiceEnv(service("same-value")) as any;
const otherProject = maskServiceEnv({ ...service("same-value"), projectId: "other" }) as any;
const otherService = maskServiceEnv({ ...service("same-value"), name: "other" }) as any;
expect(original.buildArgsFingerprints.TOKEN).not.toBe(otherProject.buildArgsFingerprints.TOKEN);
expect(original.buildArgsFingerprints.TOKEN).not.toBe(otherService.buildArgsFingerprints.TOKEN);
});
it("masks build-arg drift values, including literal defaults inside expressions", () => {
const result = maskDriftChanges([
{
field: "buildArgs",
from: { TOKEN: "old-secret" },
to: { TOKEN: "${TOKEN:-new-secret}" },
},
]);
expect(result).toEqual([
{ field: "buildArgs", from: { TOKEN: ENV_MASK }, to: { TOKEN: ENV_MASK } },
]);
});
});
@@ -2323,6 +2323,41 @@ describe("requestBuildAccess — folder-upload compose services", () => {
);
});
it.each(["upload", "stored"])(
"#854: restores build-arg-only masks from the %s before saving the deploy snapshot",
async (source) => {
const service = {
name: "api",
image: "ghcr.io/acme/api:1",
build: ".",
ports: [],
dependsOn: [],
environment: {},
volumes: [],
buildArgs: { TOKEN: "original-token", INHERITED: null },
};
const uploadSessionId = seedSession({ services: source === "upload" ? [service] : [] });
if (source === "stored") {
repos.service.listByProject.mockResolvedValue([
{ ...service, id: "svc-1", kind: "compose", enabled: true },
]);
}
await requestBuildAccess(ctx, {
projectId: "project-1",
uploadSessionId,
services: [
{ ...service, buildArgs: { TOKEN: ENV_MASK, INHERITED: null, GHOST: ENV_MASK } },
],
} as any);
const meta = repos.deployment.create.mock.calls.at(-1)?.[0].meta as any;
expect(meta.composeServices[0].buildArgs).toEqual({
TOKEN: "original-token",
INHERITED: null,
});
expect(JSON.stringify(meta)).not.toContain(ENV_MASK);
},
);
it("leaves an existing services project's own rows alone", async () => {
const uploadSessionId = seedSession();
repos.service.listByProject.mockResolvedValue([
@@ -48,11 +48,16 @@ import { getById, list } from "../../../src/modules/deployments/deployment.contr
const depWithSecret = (id: string) => ({
id,
projectId: "project-1",
status: "ready",
meta: {
previousActiveDeploymentId: "dep_0",
composeServices: [
{ name: "web", environment: { API_TOKEN: SECRET, NODE_ENV: "production" } },
{
name: "web",
environment: { API_TOKEN: SECRET, NODE_ENV: "production" },
buildArgs: { API_TOKEN: SECRET, EMPTY: "", INHERITED: null },
},
{ name: "db", environment: { POSTGRES_PASSWORD: SECRET } },
],
},
@@ -86,6 +91,11 @@ describe("#336 deployment controller masks env in responses", () => {
const body = read() as any;
expect(JSON.stringify(body)).not.toContain(SECRET);
expect(body.data.meta.composeServices[0].environment.API_TOKEN).toBe(ENV_MASK);
expect(body.data.meta.composeServices[0].buildArgs).toEqual({
API_TOKEN: ENV_MASK,
EMPTY: "",
INHERITED: null,
});
expect(body.data.meta.composeServices[0].environment.NODE_ENV).toBe(ENV_MASK);
expect(body.data.meta.composeServices[1].environment.POSTGRES_PASSWORD).toBe(ENV_MASK);
// non-env meta preserved
@@ -70,6 +70,7 @@ function infoWithSecrets() {
dependsOn: [],
volumes: [],
environment: { STRIPE_SECRET: SENTINELS.serviceEnv },
buildArgs: { BUILD_CREDENTIAL: SENTINELS.serviceEnv },
},
{
name: "db",
@@ -78,6 +79,7 @@ function infoWithSecrets() {
dependsOn: [],
volumes: [],
environment: { POSTGRES_PASSWORD: SENTINELS.secondService },
buildArgs: { BUILD_CREDENTIAL: SENTINELS.secondService },
},
],
} as unknown as Parameters<typeof projectInfoToScanResponse>[0];
@@ -157,6 +157,22 @@ describe("syncComposeServices — hands the command to the repo untouched", () =
expect(synced()[0]).not.toHaveProperty("commandArgv");
});
it("#854: restores build args during compose sync and masks its response", async () => {
serviceRepo.listByProject.mockResolvedValue([row({ buildArgs: { TOKEN: "stored-token" } })]);
serviceRepo.syncFromCompose.mockImplementation(async (_project, services) =>
services.map((service: object) => row(service)),
);
const response = await syncComposeServices(ctx, project.id, [
{
name: "web",
buildArgs: { TOKEN: "••••••••", INHERITED: null, GHOST: "••••••••" },
},
]);
expect(synced()[0].buildArgs).toEqual({ TOKEN: "stored-token", INHERITED: null });
expect(response[0]?.buildArgs).toEqual({ TOKEN: "••••••••", INHERITED: null });
expect(JSON.stringify(response)).not.toContain("stored-token");
});
it("preserves Compose env expressions and resolves them from project env at deploy (#751)", async () => {
await syncComposeServices(ctx, project.id, [
{
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_MASK } from "../../../src/lib/secret-env";
const projectRepo = vi.hoisted(() => ({ findById: vi.fn() }));
const serviceRepo = vi.hoisted(() => ({
@@ -369,6 +370,41 @@ describe("service routing patch", () => {
);
});
it("restores masked build args and their interpolation provenance on an unrelated edit", async () => {
serviceRepo.findById.mockResolvedValue({
...multiRouteService(),
buildArgs: { TOKEN: "stored-secret", REF: "${BUILD_REF}", REMOVED: "old" },
advanced: { buildArgTemplateKeys: ["REF"], readiness: { enabled: true } },
});
await updateService(ctx, project.id, "svc_1", {
buildArgs: { TOKEN: ENV_MASK, REF: ENV_MASK, INHERITED: null, EMPTY: "", GHOST: ENV_MASK },
restart: "always",
} as never);
expect(writtenPatch().buildArgs).toEqual({
TOKEN: "stored-secret",
REF: "${BUILD_REF}",
INHERITED: null,
EMPTY: "",
});
expect(writtenPatch().advanced).toEqual({
buildArgTemplateKeys: ["REF"],
readiness: { enabled: true },
});
});
it("drops source-less masks on create and masks the returned build args", async () => {
const response = await createService(ctx, project.id, {
name: "api",
build: ".",
buildArgs: { TOKEN: "new-secret", GHOST: ENV_MASK, INHERITED: null },
} as never);
expect(serviceRepo.create.mock.calls.at(-1)?.[0].buildArgs).toEqual({
TOKEN: "new-secret",
INHERITED: null,
});
expect(response?.buildArgs).toEqual({ TOKEN: ENV_MASK, INHERITED: null });
});
it("makes a manual image update literal without dropping other advanced config", async () => {
serviceRepo.findById.mockResolvedValue({
...multiRouteService(),
+18
View File
@@ -11,6 +11,24 @@ services, syncing them from a `docker-compose` file, starting/stopping/restartin
resolving compose drift, and setting per-service environment variables. In the dashboard this is a project's
**Services** tab; from the terminal it's [`openship service`](/docs/cli/projects).
Service and deployment responses mask non-empty `buildArgs` values as `••••••••`, just like
`environment`. This also covers retained deployment snapshots and Compose drift previews. Empty strings
stay empty and `null` still means inherit from the build environment. On write, echoing a mask keeps
that key's stored value; a mask without a stored source is dropped. `buildArgs` remains a whole-map
replacement: omit a key to remove it, or send `{}` to clear the map.
For literal build args, responses also include `buildArgsFingerprints`, keyed by argument name. After
rotating a literal value, compare the fingerprint in the service write response with the same service's
fingerprint in deployment history (`meta.composeServices[]`) or build status (`config.composeServices[]`).
Equal fingerprints mean the stored literal values match; a rotation changes the fingerprint without
returning either value. They are HMAC-SHA256 values scoped to the project, service name and argument key,
so they cannot be computed offline or compared across services. Rotating the instance's auth secret
also changes them.
Fingerprints describe the stored build configuration, not a running container. Inherited (`null`) and
interpolated args have no fingerprint because their effective values depend on the build environment.
Snapshots remain intact internally for rollback; existing history is masked when read, without a migration.
<Callout title="Base path & auth">
Every path is nested under a project — relative to your instance, under **`/api/projects/:id/services`**,
where `:id` is the project id (e.g. `https://your-host/api/projects/proj_123/services`).