{(() => {
if (isFailed) {
From abc82b246e6f38bddf92d8d631e3916abce8d4b5 Mon Sep 17 00:00:00 2001
From: Cahllagerfeld <43843195+Cahllagerfeld@users.noreply.github.com>
Date: Tue, 18 Nov 2025 14:59:26 +0100
Subject: [PATCH 4/4] feat: add sanitization func to docker image (#948)
---
.../_components/docker-build-collapsible.tsx | 2 +-
.../docker-image-collapsible.tsx} | 4 +-
.../collapsibles/docker-image.spec.ts | 393 ++++++++++++++++++
src/components/collapsibles/docker-image.ts | 72 ++++
.../runs/detail-tabs/Configuration/index.tsx | 2 +-
.../steps/step-sheet/ConfigurationTab.tsx | 2 +-
6 files changed, 471 insertions(+), 4 deletions(-)
rename src/components/{runs/detail-tabs/Configuration/DockerImageCollapsible.tsx => collapsibles/docker-image-collapsible.tsx} (95%)
create mode 100644 src/components/collapsibles/docker-image.spec.ts
create mode 100644 src/components/collapsibles/docker-image.ts
diff --git a/src/app/deployments/[deploymentId]/_components/docker-build-collapsible.tsx b/src/app/deployments/[deploymentId]/_components/docker-build-collapsible.tsx
index 96e09ee3f..4c4e0f6d9 100644
--- a/src/app/deployments/[deploymentId]/_components/docker-build-collapsible.tsx
+++ b/src/app/deployments/[deploymentId]/_components/docker-build-collapsible.tsx
@@ -1,4 +1,4 @@
-import { DockerImageCollapsible } from "@/components/runs/detail-tabs/Configuration/DockerImageCollapsible";
+import { DockerImageCollapsible } from "@/components/collapsibles/docker-image-collapsible";
import { usePipelineBuild } from "@/data/pipeline-builds/all-pipeline-builds-query";
import { pipelineSnapshotQueries } from "@/data/pipeline-snapshots";
import { Deployment } from "@/types/deployments";
diff --git a/src/components/runs/detail-tabs/Configuration/DockerImageCollapsible.tsx b/src/components/collapsibles/docker-image-collapsible.tsx
similarity index 95%
rename from src/components/runs/detail-tabs/Configuration/DockerImageCollapsible.tsx
rename to src/components/collapsibles/docker-image-collapsible.tsx
index 288c717ab..1110f5242 100644
--- a/src/components/runs/detail-tabs/Configuration/DockerImageCollapsible.tsx
+++ b/src/components/collapsibles/docker-image-collapsible.tsx
@@ -14,6 +14,7 @@ import { Codesnippet } from "@/components/CodeSnippet";
import { KeyValue } from "@/components/KeyValue";
import { BuildItem } from "@/types/pipeline-builds";
import { extractDockerImageKey } from "@/lib/strings";
+import { sanitizeDockerfile } from "./docker-image";
type Props = {
data: BuildItem;
@@ -83,7 +84,8 @@ export function DockerImageCollapsible({ data, displayCopyButton = true }: Props
language="dockerfile"
fullWidth
wrap
- code={data.dockerfile}
+ code={sanitizeDockerfile(data.dockerfile)}
+ copyCode={data.dockerfile}
/>
>
)}
diff --git a/src/components/collapsibles/docker-image.spec.ts b/src/components/collapsibles/docker-image.spec.ts
new file mode 100644
index 000000000..ddde20341
--- /dev/null
+++ b/src/components/collapsibles/docker-image.spec.ts
@@ -0,0 +1,393 @@
+import { describe, expect, it } from "vitest";
+import { sanitizeDockerfile } from "./docker-image";
+
+describe("sanitizeDockerfile", () => {
+ describe("sensitive variables", () => {
+ it("should mask API_KEY values", () => {
+ const input = "ENV OPENAI_API_KEY=sk-abc123def456";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV OPENAI_API_KEY=***************"); // 15 chars
+ });
+
+ it("should mask SECRET values", () => {
+ const input = "ENV DATABASE_SECRET=my-secret-value";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV DATABASE_SECRET=***************");
+ });
+
+ it("should mask TOKEN values", () => {
+ const input = "ENV AUTH_TOKEN=bearer-token-12345";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV AUTH_TOKEN=******************");
+ });
+
+ it("should mask PASSWORD values", () => {
+ const input = "ENV DB_PASSWORD=super-secret-password";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV DB_PASSWORD=*********************");
+ });
+
+ it("should mask values ending with KEY", () => {
+ const input = "ENV PRIVATE_KEY=rsa-private-key-data";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV PRIVATE_KEY=********************");
+ });
+
+ it("should mask CREDENTIALS values", () => {
+ const input = "ENV AWS_CREDENTIALS=credential-string";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV AWS_CREDENTIALS=*****************");
+ });
+
+ it("should mask values with different sensitive suffixes", () => {
+ const inputs = [
+ "ENV ACCESS_KEY=value123",
+ "ENV SECRET_KEY=value456",
+ "ENV BEARER_TOKEN=value789",
+ "ENV USER_PASS=value000",
+ "ENV DB_PWD=value111",
+ "ENV API_CREDS=value222"
+ ];
+
+ inputs.forEach((input) => {
+ const output = sanitizeDockerfile(input);
+ expect(output).toContain("=********");
+ });
+ });
+ });
+
+ describe("non-sensitive variables", () => {
+ it("should preserve NODE_ENV values", () => {
+ const input = "ENV NODE_ENV=production";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV NODE_ENV=production");
+ });
+
+ it("should preserve PORT values", () => {
+ const input = "ENV PORT=3000";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV PORT=3000");
+ });
+
+ it("should preserve PATH values", () => {
+ const input = "ENV PATH=/usr/local/bin:$PATH";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV PATH=/usr/local/bin:$PATH");
+ });
+
+ it("should preserve ENVIRONMENT values", () => {
+ const input = "ENV ENVIRONMENT=staging";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV ENVIRONMENT=staging");
+ });
+
+ it("should preserve DEBUG values", () => {
+ const input = "ENV DEBUG=true";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV DEBUG=true");
+ });
+ });
+
+ describe("quoted values", () => {
+ it("should mask quoted sensitive values (double quotes)", () => {
+ const input = 'ENV API_KEY="sk-abc123def456"';
+ const output = sanitizeDockerfile(input);
+ // Function removes quotes and masks the value inside (15 chars)
+ expect(output).toBe("ENV API_KEY=***************");
+ });
+
+ it("should mask quoted sensitive values (single quotes)", () => {
+ const input = "ENV SECRET='my-secret-value'";
+ const output = sanitizeDockerfile(input);
+ // Function removes quotes and masks the value inside
+ expect(output).toBe("ENV SECRET=***************");
+ });
+
+ it("should preserve quoted non-sensitive values", () => {
+ const input = 'ENV NODE_ENV="production"';
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe('ENV NODE_ENV="production"');
+ });
+
+ it("should handle empty quoted values for sensitive vars", () => {
+ const input = 'ENV API_KEY=""';
+ const output = sanitizeDockerfile(input);
+ // Empty string inside quotes results in minimum 1 star
+ expect(output).toBe("ENV API_KEY=*");
+ });
+ });
+
+ describe("separator styles", () => {
+ it("should handle ENV with = separator", () => {
+ const input = "ENV API_KEY=secret-value";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV API_KEY=************");
+ });
+
+ it("should handle ENV with space separator", () => {
+ const input = "ENV API_KEY secret-value";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV API_KEY ************");
+ });
+
+ it("should handle ENV with spaces around = separator", () => {
+ const input = "ENV API_KEY = secret-value";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV API_KEY = ************");
+ });
+ });
+
+ describe("line continuations", () => {
+ it("should handle line continuation with backslash", () => {
+ const input = "ENV API_KEY=secret-value \\";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV API_KEY=************ \\");
+ });
+
+ it("should preserve trailing whitespace with backslash", () => {
+ const input = "ENV SECRET=value123 \\\n";
+ const output = sanitizeDockerfile(input);
+ expect(output).toContain("********");
+ expect(output).toContain("\\");
+ });
+ });
+
+ describe("whitespace handling", () => {
+ it("should handle leading whitespace", () => {
+ const input = " ENV API_KEY=secret";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe(" ENV API_KEY=******");
+ });
+
+ it("should handle trailing whitespace", () => {
+ const input = "ENV API_KEY=secret ";
+ const output = sanitizeDockerfile(input);
+ expect(output).toContain("******");
+ });
+
+ it("should handle tabs", () => {
+ const input = "\tENV API_KEY=secret-value";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("\tENV API_KEY=************");
+ });
+ });
+
+ describe("multiple ENV statements", () => {
+ it("should handle mixed sensitive and non-sensitive variables", () => {
+ const input = `ENV NODE_ENV=production
+ENV API_KEY=secret123
+ENV PORT=8080
+ENV DATABASE_PASSWORD=db-secret
+ENV DEBUG=true`;
+
+ const output = sanitizeDockerfile(input);
+ const lines = output.split("\n");
+
+ expect(lines[0]).toBe("ENV NODE_ENV=production");
+ expect(lines[1]).toBe("ENV API_KEY=*********");
+ expect(lines[2]).toBe("ENV PORT=8080");
+ expect(lines[3]).toBe("ENV DATABASE_PASSWORD=*********");
+ expect(lines[4]).toBe("ENV DEBUG=true");
+ });
+
+ it("should handle full Dockerfile with ENV statements", () => {
+ const input = `FROM node:18
+WORKDIR /app
+ENV NODE_ENV=production
+ENV API_KEY=sk-1234567890abcdef
+ENV PORT=3000
+COPY package.json .
+RUN npm install
+ENV DATABASE_SECRET=super-secret-db-key
+CMD ["npm", "start"]`;
+
+ const output = sanitizeDockerfile(input);
+
+ expect(output).toContain("FROM node:18");
+ expect(output).toContain("ENV NODE_ENV=production");
+ expect(output).toContain("ENV API_KEY=*******************"); // 20 chars masked -> 19 stars
+ expect(output).toContain("ENV PORT=3000");
+ expect(output).toContain("ENV DATABASE_SECRET=*******************"); // 20 chars masked -> 19 stars
+ expect(output).toContain('CMD ["npm", "start"]');
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle empty value for sensitive variable", () => {
+ const input = "ENV API_KEY=";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV API_KEY=");
+ });
+
+ it("should handle single character sensitive value", () => {
+ const input = "ENV SECRET=x";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV SECRET=*");
+ });
+
+ it("should handle very long sensitive values", () => {
+ const longValue = "a".repeat(100);
+ const input = `ENV API_KEY=${longValue}`;
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe(`ENV API_KEY=${"*".repeat(100)}`);
+ });
+
+ it("should handle special characters in sensitive values", () => {
+ const input = "ENV API_KEY=key-with-@#$%^&*()";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV API_KEY=******************"); // 19 chars
+ });
+
+ it("should handle special characters in non-sensitive values", () => {
+ const input = "ENV PATH=/usr/bin:$PATH:/opt/bin";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV PATH=/usr/bin:$PATH:/opt/bin");
+ });
+
+ it("should be case-insensitive for variable names", () => {
+ const input = "ENV api_key=secret";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV api_key=******");
+ });
+
+ it("should handle uppercase KEY suffix", () => {
+ const input = "ENV MY_SPECIAL_KEY=value";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV MY_SPECIAL_KEY=*****");
+ });
+
+ it("should not mask if KEY is not a suffix", () => {
+ const input = "ENV KEYBOARD_LAYOUT=us";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV KEYBOARD_LAYOUT=us");
+ });
+
+ it("should handle mixed case in variable names", () => {
+ const input = "ENV My_Api_Key=secret123";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV My_Api_Key=*********");
+ });
+
+ it("should handle non-ENV Docker instructions", () => {
+ const input = `FROM ubuntu
+RUN apt-get update
+COPY . /app
+ENV API_KEY=secret
+EXPOSE 8080`;
+
+ const output = sanitizeDockerfile(input);
+
+ expect(output).toContain("FROM ubuntu");
+ expect(output).toContain("RUN apt-get update");
+ expect(output).toContain("COPY . /app");
+ expect(output).toContain("ENV API_KEY=******");
+ expect(output).toContain("EXPOSE 8080");
+ });
+
+ it("should return empty string for empty input", () => {
+ const input = "";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("");
+ });
+
+ it("should handle Dockerfile with no ENV statements", () => {
+ const input = `FROM node:18
+WORKDIR /app
+COPY . .
+RUN npm install
+CMD ["npm", "start"]`;
+
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe(input);
+ });
+ });
+
+ describe("value length preservation", () => {
+ it("should mask with correct number of stars for unquoted values", () => {
+ const input = "ENV API_KEY=12345";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV API_KEY=*****");
+ });
+
+ it("should mask with correct number of stars for quoted values", () => {
+ const input = 'ENV API_KEY="12345"';
+ const output = sanitizeDockerfile(input);
+ // Should count only the value inside quotes (5 chars), quotes are removed
+ expect(output).toBe("ENV API_KEY=*****");
+ });
+
+ it("should handle minimum length of 1 star", () => {
+ const input = "ENV SECRET=a";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV SECRET=*");
+ });
+ });
+
+ describe("additional edge cases", () => {
+ it("should mask when variable name IS the sensitive keyword", () => {
+ const input = "ENV KEY=my-value";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV KEY=********");
+ });
+
+ it("should mask SECRET when it is the full variable name", () => {
+ const input = "ENV SECRET=confidential";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV SECRET=************");
+ });
+
+ it("should mask TOKEN when it is the full variable name", () => {
+ const input = "ENV TOKEN=bearer-token";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV TOKEN=************");
+ });
+
+ it("should mask PASSWORD when it is the full variable name", () => {
+ const input = "ENV PASSWORD=secret123";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV PASSWORD=*********"); // 9 chars
+ });
+
+ it("should NOT mask CONNECTION_STRING (doesn't end with sensitive suffix)", () => {
+ const input = "ENV CONNECTION_STRING=server=localhost;key=secret123";
+ const output = sanitizeDockerfile(input);
+ // CONNECTION_STRING doesn't end with a sensitive suffix, so it's preserved
+ expect(output).toBe("ENV CONNECTION_STRING=server=localhost;key=secret123");
+ });
+
+ it("should handle values with double equals (base64 padding)", () => {
+ const input = "ENV API_KEY=base64encodedvalue==";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV API_KEY=********************"); // 20 chars
+ });
+
+ it("should handle database connection string with embedded equals", () => {
+ const input = "ENV DB_SECRET=postgresql://user:pass@host:5432/db?ssl=true";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV DB_SECRET=********************************************"); // 44 chars
+ });
+
+ it("should handle multiple key-value pairs in single ENV (only first is processed)", () => {
+ // Note: Current implementation only processes first key-value pair per ENV line
+ // This documents the current behavior - Docker allows multiple pairs but regex only captures first
+ const input = "ENV API_KEY=secret123 PORT=8080 NODE_ENV=production";
+ const output = sanitizeDockerfile(input);
+ // Current behavior: treats everything after first = as the value (including spaces)
+ expect(output).toBe("ENV API_KEY=***************************************"); // 39 chars
+ });
+
+ it("should not mask non-sensitive vars in multi-pair ENV format", () => {
+ const input = "ENV PORT=8080 DEBUG=true NODE_ENV=production";
+ const output = sanitizeDockerfile(input);
+ // Since PORT is not sensitive, entire line should be preserved
+ expect(output).toBe("ENV PORT=8080 DEBUG=true NODE_ENV=production");
+ });
+
+ it("should handle mixed sensitive and non-sensitive in multi-pair format", () => {
+ // When the first variable in a multi-pair ENV is sensitive, everything gets masked
+ const input = "ENV SECRET=value1 PORT=8080 DEBUG=true";
+ const output = sanitizeDockerfile(input);
+ expect(output).toBe("ENV SECRET=***************************"); // 27 chars
+ });
+ });
+});
diff --git a/src/components/collapsibles/docker-image.ts b/src/components/collapsibles/docker-image.ts
new file mode 100644
index 000000000..006af1b3a
--- /dev/null
+++ b/src/components/collapsibles/docker-image.ts
@@ -0,0 +1,72 @@
+/**
+ * Sanitizes a Dockerfile by masking sensitive environment variable values.
+ *
+ * Targets ENV variables with common sensitive patterns:
+ * - *_KEY, *_SECRET, *_TOKEN, *_PASSWORD, *_API_KEY, etc.
+ *
+ * Preserves non-sensitive variables like NODE_ENV, PORT, etc.
+ *
+ * @param dockerfile - The original Dockerfile content
+ * @returns The sanitized Dockerfile with masked sensitive values
+ *
+ * @example
+ * // Input:
+ * ENV OPENAI_API_KEY=sk-abc123def456
+ * ENV NODE_ENV=production
+ * ENV DATABASE_SECRET=my-secret
+ * ENV PORT=3000
+ *
+ * // Output (sanitized for display):
+ * ENV OPENAI_API_KEY=****************
+ * ENV NODE_ENV=production
+ * ENV DATABASE_SECRET=*********
+ * ENV PORT=3000
+ */
+
+const envPattern = /^(\s*ENV\s+)([A-Z_][A-Z0-9_]*)(\s*=\s*|\s+)(.+?)(\s*\\?\s*)$/gim;
+
+const sensitiveSuffixes = [
+ "KEY",
+ "SECRET",
+ "TOKEN",
+ "PASSWORD",
+ "PASS",
+ "PWD",
+ "API_KEY",
+ "ACCESS_KEY",
+ "SECRET_KEY",
+ "PRIVATE_KEY",
+ "AUTH_TOKEN",
+ "BEARER_TOKEN",
+ "CREDENTIALS",
+ "CREDS"
+];
+
+export function sanitizeDockerfile(dockerfile: string): string {
+ return dockerfile.replace(envPattern, (match, envPrefix, varName, separator, value, trailing) => {
+ const isSensitive = sensitiveSuffixes.some((suffix) => varName.toUpperCase().endsWith(suffix));
+
+ if (!isSensitive) {
+ return match; // Keep non-sensitive variables as-is
+ }
+
+ // Trim the value to get accurate length (remove leading/trailing whitespace and quotes)
+ const trimmedValue = value.trim();
+ const valueLength = trimmedValue.length;
+
+ // Remove quotes if present to count the actual value length
+ let actualValueLength = valueLength;
+ if (
+ (trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ||
+ (trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))
+ ) {
+ actualValueLength = Math.max(0, valueLength - 2);
+ }
+
+ // Generate stars matching the actual value length
+ const stars = "*".repeat(Math.max(1, actualValueLength));
+
+ // Reconstruct the ENV statement with masked value
+ return `${envPrefix}${varName}${separator}${stars}${trailing}`;
+ });
+}
diff --git a/src/components/runs/detail-tabs/Configuration/index.tsx b/src/components/runs/detail-tabs/Configuration/index.tsx
index a61645631..6171d5548 100644
--- a/src/components/runs/detail-tabs/Configuration/index.tsx
+++ b/src/components/runs/detail-tabs/Configuration/index.tsx
@@ -4,7 +4,7 @@ import { usePipelineRun } from "@/data/pipeline-runs/pipeline-run-detail-query";
import { BuildItem, BuildItemMap } from "@/types/pipeline-builds";
import { Skeleton } from "@zenml-io/react-component-library";
import { CodeCollapsible } from "./CodeCollapsible";
-import { DockerImageCollapsible } from "./DockerImageCollapsible";
+import { DockerImageCollapsible } from "../../../collapsibles/docker-image-collapsible";
import { EnvironmentCollapsible } from "./EnvironmentCollapsible";
import { PipelineParamsCollapsible } from "./ParameterCollapsible";
type Props = {
diff --git a/src/components/steps/step-sheet/ConfigurationTab.tsx b/src/components/steps/step-sheet/ConfigurationTab.tsx
index 2f006aef8..09d94ade3 100644
--- a/src/components/steps/step-sheet/ConfigurationTab.tsx
+++ b/src/components/steps/step-sheet/ConfigurationTab.tsx
@@ -1,4 +1,4 @@
-import { DockerImageCollapsible } from "@/components/runs/detail-tabs/Configuration/DockerImageCollapsible";
+import { DockerImageCollapsible } from "@/components/collapsibles/docker-image-collapsible";
import { Codesnippet } from "@/components/CodeSnippet";
import { CollapsibleCard } from "@/components/CollapsibleCard";
import { usePipelineBuild } from "@/data/pipeline-builds/all-pipeline-builds-query";