fix: adapt PR #864 build argument protection to shared platform

This commit is contained in:
Hydra
2026-09-15 22:41:02 +03:00
16 changed files with 741 additions and 33 deletions
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
ENV_MASK,
maskDeploymentEnv,
maskDriftChanges,
maskServiceEnv,
publicScanService,
unmaskBuildArgs,
} from "@repo/platform/engine/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 } },
]);
});
it("reveals source arguments only in an explicitly authorized editing scan", () => {
const scanned = service("scan-secret");
expect(publicScanService(scanned).buildArgs.TOKEN).toBe(ENV_MASK);
expect(publicScanService(scanned, true).buildArgs).toEqual(scanned.buildArgs);
// Even an editing scan must not leak old merge baselines.
expect(publicScanService(scanned, true)).not.toHaveProperty("importedSpec");
expect(publicScanService(scanned, true)).not.toHaveProperty("driftSpec");
});
it("preserves literal dollar values and legal prototype-like keys without inherited lookups", () => {
const args = { ["__proto__"]: "own-secret", constructor: "literal$secret" };
const masked = maskServiceEnv({
name: "web",
projectId: "project-1",
buildArgs: args,
advanced: { buildArgTemplateKeys: [] },
})!;
expect(Object.keys(masked.buildArgs)).toEqual(["__proto__", "constructor"]);
expect(Object.keys(masked.buildArgsFingerprints!)).toEqual(["__proto__", "constructor"]);
expect(unmaskBuildArgs({ ...masked.buildArgs, toString: ENV_MASK }, args)).toEqual(args);
expect(Object.prototype).not.toHaveProperty("polluted");
});
it("does not attest legacy expressions whose interpolation provenance is unknown", () => {
const masked = maskServiceEnv({
name: "web",
projectId: "project-1",
buildArgs: { TOKEN: "plain-secret", UNKNOWN: "${TOKEN:-default-secret}" },
})!;
expect(Object.keys(masked.buildArgsFingerprints!)).toEqual(["TOKEN"]);
expect(JSON.stringify(masked)).not.toContain("-secret");
});
});
@@ -2376,6 +2376,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([
@@ -54,11 +54,16 @@ import { getById, list } from "../../../src/modules/deployments/deployment.contr
const depWithSecret = (id: string) => ({
...storedDeployment("project-a", "org-1"),
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 } },
],
},
@@ -94,6 +99,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];
@@ -530,13 +530,14 @@ describe("deployment preparation HTTP/native parity", () => {
info.services!.push({
name: "db", image: "postgres:16", ports: [], dependsOn: [], volumes: [],
environment: { DB_PASSWORD: "sibling-secret" },
buildArgs: { TOKEN: "build-argument-secret", INHERITED: null, EMPTY: "" },
});
const local = await native();
const input = { owner: "acme", repo: "app", branch: "preview", composePath: " deploy/compose.yaml ", env: { OVERRIDE: "typed-value" }, includeEnv: true };
for (const deployments of [remote().deployments, local.deployments]) {
expect(await deployments.prepare(input)).toMatchObject({ services: [
{ name: "web", environment: { API_TOKEN: secret } },
{ name: "db", environment: { DB_PASSWORD: "sibling-secret" } },
{ name: "db", environment: { DB_PASSWORD: "sibling-secret" }, buildArgs: { TOKEN: "build-argument-secret", INHERITED: null, EMPTY: "" } },
] });
}
expect(h.localInfo).toHaveBeenCalledTimes(2);
@@ -558,6 +559,12 @@ describe("deployment preparation HTTP/native parity", () => {
});
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("no-store");
for (const deployments of [remote().deployments, local.deployments]) {
expect(await deployments.prepare({ ...input, includeEnv: false })).toMatchObject({ services: [
{ name: "web", environment: { API_TOKEN: ENV_MASK } },
{ name: "db", environment: { DB_PASSWORD: ENV_MASK }, buildArgs: { TOKEN: ENV_MASK, INHERITED: null, EMPTY: "" } },
] });
}
});
it("does not turn deploy-only source access into permission to reveal file values", async () => {
@@ -0,0 +1,330 @@
import { describe, expect, it } from "vitest";
import { db, schema, repos, seedOwner, installFakeRunner } from "../jobs/_harness";
import { Hono } from "hono";
import { eq } from "@repo/db";
import { ENV_MASK } from "@repo/core";
import { createShip } from "@repo/sdk/native";
import { OpenshipClient } from "@repo/sdk/client";
import { getPlatformKernel } from "@repo/platform/engine/lib/platform";
import { flushAudit } from "@repo/platform/engine/lib/audit-emitter";
import { mintPatToken } from "@repo/platform/engine/lib/pat";
import { resolveComposeBuildArgs } from "@repo/platform/engine/modules/deployments/compose/build.service";
import { serviceRoutes } from "../../../src/modules/services/service.routes";
import { deploymentRoutes } from "../../../src/modules/deployments/deployment.routes";
import { healthRoutes } from "../../../src/modules/health/health.routes";
import { handleApiError } from "../../../src/middleware/error-handler";
// Real database, authentication, policy, service writes and deployment reads.
// The runner is idle: these configuration operations never contact a host.
installFakeRunner();
const app = new Hono()
.onError(handleApiError)
.route("/api/health", healthRoutes)
.route("/api/projects/:id/services", serviceRoutes)
.route("/api/deployments", deploymentRoutes);
async function setup() {
const owner = await seedOwner();
const input = {
organizationId: owner.orgId,
name: "Build arguments",
slug: `build-args-${owner.userId}`,
framework: "docker-compose",
};
const group = await repos.projectGroup.create(input);
const project = await repos.project.create({ ...input, groupId: group.id });
const user = (await repos.user.findById(owner.userId))!;
const ship = createShip({
platform: getPlatformKernel(),
identity: {
resolve: async () => ({
user: { id: user.id, email: user.email, name: user.name },
sessionId: "build-args-test",
}),
},
});
const remote = (token = owner.token) =>
new OpenshipClient({
baseUrl: "http://openship.test",
token,
organizationId: owner.orgId,
fetch: ((url, init) => app.request(url as string, init)) as typeof fetch,
});
return {
owner,
project,
remote,
clients: {
native: await ship.scope({ identity: "verified", organizationId: owner.orgId }),
http: remote(),
},
};
}
describe("build arguments through the native and HTTP SDK (#854)", () => {
it.each(["native", "http"] as const)(
"%s preserves masked edits, empty and inherited arguments, and deletions",
async (transport) => {
const { project, clients, owner } = await setup();
const services = clients[transport].services;
const created = await services.create(project.id, {
name: "web",
kind: "compose",
build: ".",
environment: { TOKEN: "runtime-secret" },
buildArgs: {
TOKEN: "original-secret",
TEMPLATE: "${BUILD_TOKEN:-default-secret}",
LITERAL: "${KEEP_LITERAL}",
REMOVED: "removed-secret",
EMPTY: "",
INHERITED: null,
},
advanced: { buildArgTemplateKeys: ["TEMPLATE"] },
});
expect(created.buildArgs).toEqual({
TOKEN: ENV_MASK,
TEMPLATE: ENV_MASK,
LITERAL: ENV_MASK,
REMOVED: ENV_MASK,
EMPTY: "",
INHERITED: null,
});
expect(Object.keys(created.buildArgsFingerprints!).sort()).toEqual([
"EMPTY",
"LITERAL",
"REMOVED",
"TOKEN",
]);
const updated = await services.update(project.id, created.id, {
buildArgs: {
TOKEN: "rotated-secret",
TEMPLATE: ENV_MASK,
LITERAL: ENV_MASK,
EMPTY: "",
INHERITED: null,
GHOST: ENV_MASK,
},
});
expect(updated.buildArgsFingerprints?.TOKEN).toMatch(/^hmac-sha256:[a-f0-9]{64}$/);
expect(updated.buildArgsFingerprints?.TOKEN).not.toBe(created.buildArgsFingerprints?.TOKEN);
expect(updated.buildArgsFingerprints?.LITERAL).toBe(created.buildArgsFingerprints?.LITERAL);
expect(updated.advanced?.buildArgTemplateKeys).toEqual(["TEMPLATE"]);
expect(await repos.service.findById(created.id)).toMatchObject({
buildArgs: {
TOKEN: "rotated-secret",
TEMPLATE: "${BUILD_TOKEN:-default-secret}",
LITERAL: "${KEEP_LITERAL}",
EMPTY: "",
INHERITED: null,
},
});
expect(updated.buildArgs).not.toHaveProperty("REMOVED");
expect(updated.buildArgs).not.toHaveProperty("GHOST");
for (const client of Object.values(clients)) {
expect(await client.services.get(project.id, created.id)).toEqual(updated);
expect(await client.services.list(project.id)).toEqual([updated]);
}
await flushAudit();
const audit = await db
.select()
.from(schema.auditEvent)
.where(eq(schema.auditEvent.organizationId, owner.orgId));
expect(audit.length).toBeGreaterThanOrEqual(2);
expect(JSON.stringify([created, updated, audit])).not.toContain("-secret");
expect((await services.update(project.id, created.id, { buildArgs: {} })).buildArgs).toEqual(
{},
);
expect((await repos.service.findById(created.id))?.buildArgs).toEqual({});
},
);
it.each(["native", "http"] as const)(
"%s sync restores masked source expressions without reinterpreting literals",
async (transport) => {
const { project, clients } = await setup();
const services = clients[transport].services;
const [created] = await services.sync(project.id, {
services: [
{
name: "web",
build: ".",
buildArgs: {
TEMPLATE: "${BUILD_TOKEN:-default-secret}",
LITERAL: "${KEEP_LITERAL}",
EMPTY: "",
INHERITED: null,
},
advanced: { buildArgTemplateKeys: ["TEMPLATE"] },
},
],
});
// Minimal edits need not echo `advanced`; the sentinel still preserves the
// original expression's meaning. A source parser may supply its own marker.
const [synced] = await services.sync(project.id, {
services: [
{
name: created.name,
build: ".",
buildArgs: { ...created.buildArgs, GHOST: ENV_MASK },
},
],
});
expect(synced.buildArgs).toEqual(created.buildArgs);
expect(synced.advanced?.buildArgTemplateKeys).toEqual(["TEMPLATE"]);
const saved = (await repos.service.findById(created.id))!;
expect(
resolveComposeBuildArgs(
saved.buildArgs,
{
BUILD_TOKEN: "final-secret",
KEEP_LITERAL: "must-not-expand",
INHERITED: "inherited-secret",
},
saved.advanced?.buildArgTemplateKeys,
),
).toEqual({
TEMPLATE: "final-secret",
LITERAL: "${KEEP_LITERAL}",
EMPTY: "",
INHERITED: "inherited-secret",
});
await services.sync(project.id, {
services: [
{
name: created.name,
build: ".",
buildArgs: { TEMPLATE: "${KEEP_LITERAL}" },
advanced: { buildArgTemplateKeys: [] },
},
],
});
const normalized = (await repos.service.findById(created.id))!;
expect(
resolveComposeBuildArgs(
normalized.buildArgs,
{ KEEP_LITERAL: "must-not-expand" },
normalized.advanced?.buildArgTemplateKeys,
),
).toEqual({ TEMPLATE: "${KEEP_LITERAL}" });
},
);
it("masks retained history, build status and drift without changing the rollback snapshots", async () => {
const { project, clients, owner } = await setup();
const created = await clients.native.services.create(project.id, {
name: "web",
kind: "compose",
build: ".",
buildArgs: { TOKEN: "old-secret" },
});
const prior = (await repos.service.findById(created.id))!;
const old = (await repos.deployment.create({
organizationId: owner.orgId,
projectId: project.id,
branch: "main",
status: "ready",
meta: { composeServices: [prior] },
}))!;
const updated = await clients.http.services.update(project.id, created.id, {
buildArgs: { TOKEN: "new-secret" },
});
await repos.service.update(created.id, {
importedSpec: { buildArgs: { TOKEN: "new-secret" } },
driftSpec: { buildArgs: { TOKEN: "${TOKEN:-pending-secret}" } },
});
const current = (await repos.service.findById(created.id))!;
const latest = (await repos.deployment.create({
organizationId: owner.orgId,
projectId: project.id,
branch: "main",
status: "ready",
meta: { composeServices: [current] },
}))!;
const protectedService = (fingerprint: string | undefined) =>
expect.objectContaining({
buildArgs: { TOKEN: ENV_MASK },
buildArgsFingerprints: { TOKEN: fingerprint },
});
for (const client of Object.values(clients)) {
const previous = await client.deployments.get(old.id);
const recent = await client.deployments.get(latest.id);
const history = await client.deployments.list({ projectId: project.id });
const status = await client.deployments.buildStatus(latest.id);
const service = await client.services.get(project.id, created.id);
expect(previous.meta).toMatchObject({
composeServices: [protectedService(created.buildArgsFingerprints?.TOKEN)],
});
expect(recent.meta).toMatchObject({
composeServices: [protectedService(updated.buildArgsFingerprints?.TOKEN)],
});
expect(history.data).toHaveLength(2);
expect(status).toMatchObject({
composeServices: [protectedService(updated.buildArgsFingerprints?.TOKEN)],
});
expect(service.drift?.changes).toContainEqual({
field: "buildArgs",
from: { TOKEN: ENV_MASK },
to: { TOKEN: ENV_MASK },
});
const serialized = JSON.stringify([previous, recent, history, status, service]);
for (const hidden of ["-secret", "importedSpec", "driftSpec"])
expect(serialized).not.toContain(hidden);
}
expect((await repos.deployment.findById(old.id))?.meta).toMatchObject({
composeServices: [{ buildArgs: { TOKEN: "old-secret" } }],
});
expect((await repos.deployment.findById(latest.id))?.meta).toMatchObject({
composeServices: [{ buildArgs: { TOKEN: "new-secret" } }],
});
});
it("keeps read-only tokens masked and prevents foreign service or deployment reads", async () => {
const { owner, project, clients, remote } = await setup();
const service = await clients.native.services.create(project.id, {
name: "web",
buildArgs: { TOKEN: "read-only-secret" },
});
const token = mintPatToken();
await repos.personalAccessToken.create({
userId: owner.userId,
organizationId: owner.orgId,
name: "reader",
tokenPrefix: token.tokenPrefix,
tokenHash: token.tokenHash,
readOnly: true,
scoped: false,
expiresAt: null,
});
const reader = remote(token.token);
expect((await reader.services.get(project.id, service.id)).buildArgs).toEqual({
TOKEN: ENV_MASK,
});
await expect(
reader.services.update(project.id, service.id, { buildArgs: { TOKEN: "changed" } }),
).rejects.toMatchObject({ code: "TOKEN_READ_ONLY" });
const foreign = await setup();
const foreignService = await foreign.clients.native.services.create(foreign.project.id, {
name: "web",
buildArgs: { TOKEN: "foreign-secret" },
});
const deployment = (await repos.deployment.create({
organizationId: foreign.owner.orgId,
projectId: foreign.project.id,
branch: "main",
status: "ready",
}))!;
for (const client of Object.values(clients)) {
await expect(
client.services.get(foreign.project.id, foreignService.id),
).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(client.services.get(project.id, foreignService.id)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(client.deployments.get(deployment.id)).rejects.toMatchObject({
code: "NOT_FOUND",
});
}
});
});
@@ -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 "@repo/platform/engine/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(),
+22
View File
@@ -14,6 +14,28 @@ resolving compose drift, and setting per-service environment variables. In the d
These operations are available with supported self-hosted and Cloud providers. A fixed organization
scope requires a direct compatible Cloud connection; see [Cloud compatibility](/docs/api/sdk/compatibility#cloud).
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 (`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.
Source scans also mask build arguments by default. An authorized editing scan with `includeEnv: true`
includes source values so the deploy form can edit them; it requires write access and, for GitHub sources,
permission to read the repository's full contents.
## Share a service between projects
A service can stay in its owning project and be connected to several other projects. This reuses the
+2
View File
@@ -31,6 +31,8 @@ export const ServiceSchema = Type.Object({
build: nullableString,
dockerfile: nullableString,
buildArgs: Type.Record(Type.String(), nullableString),
/** Instance-keyed fingerprints for stored literal arguments; never runtime attestation. */
buildArgsFingerprints: Type.Optional(Type.Record(Type.String(), Type.String())),
ports: nullableStrings,
dependsOn: nullableStrings,
environment: Type.Union([Type.Record(Type.String(), Type.String()), Type.Null()]),
@@ -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> = Object.create(null);
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 (
typeof value !== "string" ||
isMaskedValue(value) ||
(Array.isArray(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;
}
+86 -16
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,31 @@ 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> {
// fromEntries defines own properties safely (including `__proto__`) while
// retaining a normal object prototype for the database's JSON serializer.
return Object.fromEntries(
Object.entries(incoming ?? {}).flatMap(([key, value]) => {
if (!isMaskedValue(value)) return [[key, value]];
return stored && Object.hasOwn(stored, key) ? [[key, stored[key]]] : [];
}),
);
}
/**
* 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
@@ -89,8 +115,8 @@ export function unmaskEnv(
* Merge an incoming compose-service `environment` patch onto what's stored,
* preserving untouched variables and restoring masked secrets (#336, #619).
*
* `environment` is the only field on the PATCH endpoint that is masked on read,
* and reveal is deliberately off the automation surface — so a client cannot see
* Runtime environment edits are partial, and reveal is deliberately off the
* automation surface — so a client cannot see
* what a whole-map replace is about to destroy, and cannot read it back. Omission
* therefore has to mean "keep", and removal has to be explicit. Same triad as
* `mergeAdvanced`, plus the sentinel arm that only a masked field needs:
@@ -128,31 +154,43 @@ 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 & { buildArgsFingerprints?: Record<string, string> }) | 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 +200,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 +217,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 +239,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. */
@@ -229,8 +283,12 @@ function publicEnvironmentMeta(
...(m.unresolvedVariables !== undefined && {
unresolvedVariables: [...m.unresolvedVariables],
}),
...(m.resolvedValue !== undefined && { resolvedValue: includeEnv ? m.resolvedValue : maskValue(m.resolvedValue) }),
...(m.defaultValue !== undefined && { defaultValue: includeEnv ? m.defaultValue : maskValue(m.defaultValue) }),
...(m.resolvedValue !== undefined && {
resolvedValue: includeEnv ? m.resolvedValue : maskValue(m.resolvedValue),
}),
...(m.defaultValue !== undefined && {
defaultValue: includeEnv ? m.defaultValue : maskValue(m.defaultValue),
}),
};
}
return out;
@@ -243,6 +301,7 @@ function publicEnvironmentMeta(
*/
export function publicScanService<
T extends {
buildArgs?: Record<string, string | null> | null;
environment?: Record<string, string> | null;
environmentTemplates?: Record<string, string> | null;
environmentMeta?: Record<string, EnvMetaLike> | null;
@@ -258,7 +317,10 @@ export function publicScanService<
return {
...masked,
...(includeEnv && svc.environment && { environment: { ...svc.environment } }),
...(svc.environmentMeta && { environmentMeta: publicEnvironmentMeta(svc.environmentMeta, includeEnv) }),
...(includeEnv && svc.buildArgs && { buildArgs: { ...svc.buildArgs } }),
...(svc.environmentMeta && {
environmentMeta: publicEnvironmentMeta(svc.environmentMeta, includeEnv),
}),
};
}
@@ -269,9 +331,9 @@ export function maskScanService<T extends Parameters<typeof publicScanService>[0
/**
* 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
* shared presentation boundary: `getDeployment` is also used internally and must
* keep plaintext. No-op when there's no `meta.composeServices`.
*/
export function maskDeploymentEnv<T extends { meta?: unknown } | null | undefined>(dep: T): T {
@@ -292,6 +354,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,
),
},
};
@@ -314,6 +377,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,
@@ -1697,19 +1697,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);
@@ -43,10 +43,12 @@ import { encrypt, decrypt } from "../../lib/encryption";
import {
ENV_MASK,
hasMaskedValue,
isMaskedValue,
maskDriftChanges,
maskServiceEnv,
mergeServiceEnv,
unmaskEnv,
unmaskBuildArgs,
} from "../../lib/secret-env";
import { assertNotControlPlane, assertNotControlPlaneById, assertResourceInOrg } from "../../lib/resource-access";
import { platform } from "../../lib/platform-config";
@@ -672,7 +674,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 ?? {},
@@ -753,6 +755,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),
@@ -785,7 +790,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 storedByName = new Map(stored.map((svc) => [svc.name, svc]));
// Import path, but the hostnames are still client-authored — same gate as the
// create/update editors (normalizeRoutingPatch); `syncFromCompose` writes the
@@ -1408,8 +1418,25 @@ export async function syncComposeServices(
svc.environmentTemplates !== undefined ||
(!hasExplicitTemplateMarker && environment !== undefined);
// A masked argument keeps its value AND its interpolation semantics. Sync
// is a whole-map replacement, so new literals must not inherit a marker
// from the previous value. An explicit parser marker still takes priority
// (notably [] from `docker compose config`, whose values are already final).
const previous = storedByName.get(svc.name);
const buildArgs = svc.buildArgs && unmaskBuildArgs(svc.buildArgs, previous?.buildArgs);
const buildArgTemplateKeys =
buildArgs && !Object.hasOwn(advanced ?? {}, "buildArgTemplateKeys")
? (previous?.advanced?.buildArgTemplateKeys ?? []).filter(
(key) => isMaskedValue(svc.buildArgs?.[key]) && Object.hasOwn(buildArgs, key),
)
: undefined;
return {
...svc,
...(buildArgs && { buildArgs }),
...(buildArgTemplateKeys && {
advanced: { ...advanced, buildArgTemplateKeys },
}),
...(environment && { environment }),
...(persistTemplateProvenance && { environmentTemplates }),
};
@@ -1456,7 +1483,6 @@ export async function syncComposeServices(
// Best-effort per hostname: an invalid or foreign hostname throws here
// (Validation / Conflict) and a sync that already persisted its services must not
// fail on the follow-up bookkeeping; the deploy path re-attempts the same ensure.
const storedByName = new Map(stored.map((svc) => [svc.name, svc]));
for (const svc of synced) {
for (const row of serviceDomainRowsToEnsure(svc)) {
await ensurePendingServiceDomain({
+8 -5
View File
@@ -680,15 +680,17 @@ describe("owned native platform on Node", () => {
"services:", " db:", " image: postgres:16", " environment:",
" POSTGRES_PASSWORD: ${PASSWORD}", " POSTGRES_DB: app", " EMPTY: ''",
" worker:", " image: node:22", " environment:", " API_TOKEN: sibling-secret",
" build:", " context: .", " args:", " TOKEN: native-build-secret", " INHERITED:", " EMPTY: ''",
].join("\n"));
const preparedSource = { source: "local" as const, path: source, composePath: "deploy/stack.yml", env: { PASSWORD: "typed-override" } };
const preview = await deployments.prepare(preparedSource);
expect(preview).toMatchObject({ services: [{ name: "db", environment: { POSTGRES_PASSWORD: "••••••••", POSTGRES_DB: "••••••••", EMPTY: "" } }, { name: "worker" }] });
expect(preview).toMatchObject({ services: [{ name: "db", environment: { POSTGRES_PASSWORD: "••••••••", POSTGRES_DB: "••••••••", EMPTY: "" } }, { name: "worker", buildArgs: { TOKEN: "••••••••", INHERITED: null, EMPTY: "" } }] });
expect(JSON.stringify(preview)).not.toContain("typed-override");
expect(JSON.stringify(preview)).not.toContain("native-build-secret");
expect(await deployments.prepare({ ...preparedSource, includeEnv: true })).toMatchObject({
services: [
{ name: "db", environment: { POSTGRES_PASSWORD: "typed-override", POSTGRES_DB: "app", EMPTY: "" } },
{ name: "worker", environment: { API_TOKEN: "sibling-secret" } },
{ name: "worker", environment: { API_TOKEN: "sibling-secret" }, buildArgs: { TOKEN: "native-build-secret", INHERITED: null, EMPTY: "" } },
],
});
expect(await deployments.prepare({ ...preparedSource, env: {}, includeEnv: true })).toMatchObject({ services: [
@@ -696,18 +698,19 @@ describe("owned native platform on Node", () => {
] });
await expect(deployments.prepare({ ...preparedSource, path: directory, includeEnv: true })).rejects.toMatchObject({ code: "SOURCE_PATH_NOT_ALLOWED" });
const compose = "services:\n db:\n image: postgres:16\n environment:\n POSTGRES_PASSWORD: ${PASSWORD}\n";
const compose = "services:\n db:\n image: postgres:16\n environment:\n POSTGRES_PASSWORD: ${PASSWORD}\n build:\n context: .\n args:\n TOKEN: staged-build-secret\n";
await writeFile(join(source, "docker-compose.yml"), compose);
await writeFile(join(source, ".env"), "PASSWORD=local-scan-password\n");
expect(await projects.scanLocal({ path: source, includeEnv: true })).toMatchObject({
services: [{ name: "db", environment: { POSTGRES_PASSWORD: "local-scan-password" } }],
services: [{ name: "db", environment: { POSTGRES_PASSWORD: "local-scan-password" }, buildArgs: { TOKEN: "staged-build-secret" } }],
});
const staged = await sources.stage({ source: { type: "files", files: {
"docker-compose.yml": compose, ".env": "PASSWORD=uploaded-password\n",
} } });
expect(JSON.stringify(await sources.scan(staged.sessionId))).not.toContain("uploaded-password");
expect(await sources.scan(staged.sessionId)).toMatchObject({ services: [{ buildArgs: { TOKEN: "••••••••" } }] });
expect(await sources.scan(staged.sessionId, { includeEnv: true })).toMatchObject({
services: [{ name: "db", environment: { POSTGRES_PASSWORD: "uploaded-password" } }],
services: [{ name: "db", environment: { POSTGRES_PASSWORD: "uploaded-password" }, buildArgs: { TOKEN: "staged-build-secret" } }],
});
await symlink(join(directory, "outside.txt"), join(source, "escape.txt"));
await expect(system.browse({ path: join(source, "escape.txt") })).rejects.toMatchObject({ code: "SOURCE_PATH_NOT_ALLOWED" });