mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix(api): mask build arguments and preserve secrets on round trips
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user