mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix: adapt PR #864 build argument protection to shared platform
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user