fix(platform): apply production hardening from audit plan

Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-07-02 19:35:07 +03:30
parent 34c110be6a
commit 22359be40e
55 changed files with 4883 additions and 381 deletions
+241 -69
View File
@@ -31,12 +31,14 @@ export class BuildCancelledError extends Error {
interface ActiveBuildSession {
cancelled: boolean;
applicationId?: string;
coreApi?: k8s.CoreV1Api;
batchApi?: k8s.BatchV1Api;
namespace?: string;
buildPodName?: string;
sourcePvcName?: string;
helperPodName?: string;
gitSecretName?: string;
processes: ChildProcess[];
socket?: net.Socket;
}
@@ -68,8 +70,71 @@ export class BuildService {
private sourceStorage: SourceStorageService,
) {}
private beginBuildSession(deploymentId: string): void {
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [] });
/**
* Prefix Docker Hub base images with the configured mirror registry
* (BASE_IMAGE_REGISTRY), so generated Dockerfiles work on clusters that
* cannot reach docker.io. Images already pinned to another registry
* (gcr.io, mcr.microsoft.com, …) are returned unchanged.
*/
private baseImage(image: string): string {
const prefix = this.configService.get<string>('build.baseImageRegistry');
if (!prefix) return image;
const firstSegment = image.split('/')[0];
const hasRegistry = firstSegment.includes('.') || firstSegment.includes(':');
if (hasRegistry) return image;
return `${prefix}/${image}`;
}
/**
* Git branch names come from users and end up in a shell command — accept
* only conservative ref characters and reject anything option-like.
*/
private assertSafeGitBranch(branch: string): string {
const b = (branch || '').trim();
if (!b || b.length > 255 || b.startsWith('-') || b.includes('..') || !/^[A-Za-z0-9._/-]+$/.test(b)) {
throw new Error(`Invalid git branch name: "${branch}"`);
}
return b;
}
/**
* SSRF guard for user-supplied repo URLs: only http(s), no embedded
* credentials, and no loopback/link-local/private or cluster-internal hosts.
*/
private assertSafeGitUrl(gitUrl: string): void {
let url: URL;
try {
url = new URL(gitUrl);
} catch {
throw new Error(`Invalid git URL: "${gitUrl}"`);
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error(`Unsupported git URL protocol: "${url.protocol}" — only http(s) is allowed`);
}
if (url.username || url.password) {
throw new Error('Git URL must not contain embedded credentials — use the git token field instead');
}
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
const blockedHosts = ['localhost', 'metadata.google.internal'];
const blockedSuffixes = ['.local', '.localhost', '.internal', '.svc', '.svc.cluster.local', '.cluster.local'];
const isPrivateIPv4 =
/^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host);
const isIPv6Internal = host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd');
if (
blockedHosts.includes(host) ||
blockedSuffixes.some((s) => host.endsWith(s)) ||
isPrivateIPv4 ||
isIPv6Internal ||
!host.includes('.')
) {
throw new Error(`Git URL host "${url.hostname}" is not allowed`);
}
}
private beginBuildSession(deploymentId: string, applicationId?: string): void {
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [], applicationId });
this.persistSession(deploymentId);
}
private getSession(deploymentId?: string): ActiveBuildSession | undefined {
@@ -80,6 +145,26 @@ export class BuildService {
private updateBuildSession(deploymentId: string, update: Partial<ActiveBuildSession>): void {
const session = this.activeBuilds.get(deploymentId);
if (session) Object.assign(session, update);
this.persistSession(deploymentId);
}
/**
* Mirror the serializable part of the session to Redis, so interrupted
* builds can be detected and their cluster resources cleaned up after a
* backend restart (the in-memory map does not survive restarts).
*/
private persistSession(deploymentId: string): void {
const session = this.activeBuilds.get(deploymentId);
if (!session) return;
void this.progressStore.setSession({
deploymentId,
applicationId: session.applicationId,
namespace: session.namespace,
buildPodName: session.buildPodName,
sourcePvcName: session.sourcePvcName,
helperPodName: session.helperPodName,
gitSecretName: session.gitSecretName,
});
}
private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void {
@@ -122,7 +207,10 @@ export class BuildService {
}
private endBuildSession(deploymentId?: string): void {
if (deploymentId) this.activeBuilds.delete(deploymentId);
if (deploymentId) {
this.activeBuilds.delete(deploymentId);
void this.progressStore.clearSession(deploymentId);
}
}
async cancelBuild(deploymentId: string): Promise<void> {
@@ -154,7 +242,7 @@ export class BuildService {
}
}
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session;
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName, gitSecretName } = session;
if (coreApi && namespace) {
const cleanup: Promise<unknown>[] = [];
if (helperPodName) {
@@ -200,6 +288,11 @@ export class BuildService {
.catch(() => undefined),
);
}
if (gitSecretName) {
cleanup.push(
coreApi.deleteNamespacedSecret({ name: gitSecretName, namespace }).catch(() => undefined),
);
}
await Promise.all(cleanup);
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
}
@@ -209,7 +302,7 @@ export class BuildService {
percent: 0,
message: 'Cancelled by user',
});
this.activeBuilds.delete(deploymentId);
this.endBuildSession(deploymentId);
}
/** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */
@@ -226,13 +319,14 @@ export class BuildService {
const cleanup: Promise<unknown>[] = [];
const [pods, pvcs, jobs, configMaps] = await Promise.all([
const [pods, pvcs, jobs, configMaps, secrets] = await Promise.all([
coreApi.listNamespacedPod({ namespace: buildNamespace }),
coreApi.listNamespacedPersistentVolumeClaim({
namespace: buildNamespace,
}),
batchApi.listNamespacedJob({ namespace: buildNamespace }),
coreApi.listNamespacedConfigMap({ namespace: buildNamespace }),
coreApi.listNamespacedSecret({ namespace: buildNamespace }),
]);
for (const pod of pods.items) {
@@ -283,6 +377,12 @@ export class BuildService {
cleanup.push(coreApi.deleteNamespacedConfigMap({ name, namespace: buildNamespace }).catch(() => undefined));
}
}
for (const secret of secrets.items) {
const name = secret.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedSecret({ name, namespace: buildNamespace }).catch(() => undefined));
}
}
await Promise.all(cleanup);
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
@@ -320,7 +420,7 @@ export class BuildService {
this.logger.log(`Starting image build for ${app.name}${imageUri}`);
if (deploymentId) {
this.beginBuildSession(deploymentId);
this.beginBuildSession(deploymentId, app.id);
}
const hasUploadedCode = !!app.codePath;
@@ -389,6 +489,8 @@ export class BuildService {
// If we have uploaded code, create a PVC and upload via kubectl cp
let sourcePvcName: string | undefined;
// Secret holding the git token for private-repo clones (created lazily)
let gitSecretName: string | undefined;
if (hasUploadedCode && localZipPath) {
sourcePvcName = `${buildPodName}-source`;
if (deploymentId) {
@@ -446,7 +548,7 @@ export class BuildService {
// Add init container that unzips the source code from PVC
initContainers.push({
name: 'unzip-source',
image: 'alpine:3.19',
image: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
command: [
'sh',
@@ -493,36 +595,60 @@ export class BuildService {
],
});
} else if (hasGitUrl) {
// Build the git clone URL — inject token for private repos
let cloneUrl = app.gitUrl!;
// Validate user-controlled values before they get anywhere near a shell.
this.assertSafeGitUrl(app.gitUrl!);
const branch = this.assertSafeGitBranch(app.gitBranch || 'main');
// The token never appears in the command line or the clone URL — it is
// delivered via a Secret env var and handed to git through GIT_ASKPASS,
// so it can't leak through pod specs, `ps`, or job logs.
if (app.gitToken) {
// Convert https://github.com/user/repo.git → https://<token>@github.com/user/repo.git
// Also works for GitLab, Bitbucket, etc.
try {
const url = new URL(cloneUrl);
url.username = app.gitToken;
url.password = ''; // Some providers use token as username, others as password
cloneUrl = url.toString();
} catch {
// If URL parsing fails, try simple injection after protocol
cloneUrl = cloneUrl.replace('https://', `https://${app.gitToken}@`);
}
gitSecretName = `${buildPodName}-git`;
if (deploymentId) this.updateBuildSession(deploymentId, { gitSecretName });
await coreApi.createNamespacedSecret({
namespace: buildNamespace!,
body: {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: gitSecretName, namespace: buildNamespace },
type: 'Opaque',
stringData: { GIT_TOKEN: app.gitToken },
},
});
}
const branch = app.gitBranch || 'main';
// Clone git repo into /workspace/source, then copy our generated Dockerfile
initContainers.push({
name: 'git-clone',
image: 'alpine/git:2.43.0',
image: this.baseImage('alpine/git:2.43.0'),
imagePullPolicy: 'IfNotPresent',
env: [
{ name: 'GIT_URL', value: app.gitUrl! },
{ name: 'GIT_BRANCH', value: branch },
...(gitSecretName
? [
{
name: 'GIT_TOKEN',
valueFrom: { secretKeyRef: { name: gitSecretName, key: 'GIT_TOKEN' } },
},
]
: []),
],
command: [
'sh',
'-c',
`
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" &&
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
echo ">>> Workspace contents:" &&
set -e
if [ -n "\${GIT_TOKEN:-}" ]; then
printf '#!/bin/sh\\necho "$GIT_TOKEN"\\n' > /tmp/git-askpass.sh
chmod +x /tmp/git-askpass.sh
export GIT_ASKPASS=/tmp/git-askpass.sh
export GIT_TERMINAL_PROMPT=0
fi
echo ">>> Cloning branch '$GIT_BRANCH' from $GIT_URL"
git clone --depth 1 --branch "$GIT_BRANCH" "$GIT_URL" /workspace-out/source
cp /dockerfile/Dockerfile /workspace-out/Dockerfile
echo ">>> Workspace contents:"
ls -la /workspace-out/source/
`,
],
@@ -545,7 +671,7 @@ export class BuildService {
// add an init container that creates empty source dir + copies Dockerfile
initContainers.push({
name: 'prepare-workspace',
image: 'alpine:3.19',
image: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
command: [
'sh',
@@ -586,8 +712,14 @@ export class BuildService {
args: kanikoArgs,
volumeMounts: kanikoVolumeMounts,
resources: {
requests: { cpu: '500m', memory: '1Gi' },
limits: { cpu: '2', memory: '4Gi' },
requests: {
cpu: this.configService.get<string>('build.kaniko.cpuRequest') || '500m',
memory: this.configService.get<string>('build.kaniko.memoryRequest') || '1Gi',
},
limits: {
cpu: this.configService.get<string>('build.kaniko.cpuLimit') || '2',
memory: this.configService.get<string>('build.kaniko.memoryLimit') || '4Gi',
},
},
},
],
@@ -666,6 +798,17 @@ export class BuildService {
} catch (e: any) {
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
}
// Clean up git-token Secret
if (gitSecretName) {
try {
await coreApi.deleteNamespacedSecret({
name: gitSecretName,
namespace: buildNamespace!,
});
} catch (e: any) {
this.logger.warn(`Failed to clean up git Secret: ${e.message}`);
}
}
this.endBuildSession(deploymentId);
cleanupSource?.();
}
@@ -780,6 +923,8 @@ export class BuildService {
metadata: { name: pvcName, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
// Explicit StorageClass — don't rely on a cluster default existing
storageClassName: this.configService.get<string>('platform.storageClass') || undefined,
resources: { requests: { storage: `${sizeGi}Gi` } },
},
},
@@ -797,7 +942,7 @@ export class BuildService {
containers: [
{
name: 'helper',
image: 'alpine:3.19',
image: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'source', mountPath: '/data' }],
@@ -1012,10 +1157,12 @@ export class BuildService {
const port = app.port || 3000;
const nodeVersion = app.runtimeVersion || '20';
return `# --- Build stage ---
FROM node:${nodeVersion}-alpine AS builder
FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps && npm cache clean --force
# Reproducible install from the lockfile when present
RUN if [ -f package-lock.json ]; then npm ci --legacy-peer-deps; else npm install --legacy-peer-deps; fi \\
&& npm cache clean --force
COPY . .
# Auto-detect Next.js and enable standalone output
@@ -1031,13 +1178,19 @@ RUN for cfg in next.config.js next.config.mjs next.config.ts; do \\
break; \\
done
RUN npm run build || echo ">>> Build script failed or not found — continuing"
# Run the build script when one exists — and FAIL the image build if it fails,
# instead of silently shipping a broken image.
RUN if node -e "const s=(require('./package.json').scripts||{});process.exit(s.build?0:1)"; then \\
echo ">>> Running build script" && npm run build; \\
else \\
echo ">>> No build script defined — skipping"; \\
fi
# Clean up dev dependencies and caches to reduce image size
RUN rm -rf node_modules/.cache .next/cache /tmp/* /root/.npm 2>/dev/null; true
# --- Production stage ---
FROM node:${nodeVersion}-alpine AS runner
FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS runner
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
@@ -1070,9 +1223,9 @@ CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f serv
const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80;
return `# --- Build stage (match production PHP version for Composer) ---
FROM php:${phpVersion}-cli-alpine AS composer
FROM ${this.baseImage(`php:${phpVersion}-cli-alpine`)} AS composer
RUN apk add --no-cache git unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY --from=${this.baseImage('composer:2')} /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock* ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
@@ -1080,11 +1233,15 @@ COPY . .
RUN composer dump-autoload --optimize --no-dev --no-scripts
# --- Production stage ---
FROM php:${phpVersion}-fpm-alpine
FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
RUN apk add --no-cache nginx supervisor curl openssl \\
&& docker-php-ext-install pdo pdo_mysql opcache \\
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true
# Laravel needs bcmath/gd/intl/zip beyond the built-in set; pdo_pgsql is built
# properly against libpq instead of being silently skipped.
RUN apk add --no-cache nginx supervisor curl openssl icu-libs libzip libpng libjpeg-turbo freetype postgresql-libs \\
&& apk add --no-cache --virtual .build-deps icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev postgresql-dev \\
&& docker-php-ext-configure gd --with-jpeg --with-freetype \\
&& docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache bcmath zip gd intl exif pcntl \\
&& apk del .build-deps
WORKDIR /var/www/html
COPY --from=composer /app .
@@ -1163,7 +1320,7 @@ CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"]
const phpVersion = app.phpVersion || '8.3';
const hasUploadedCode = !!app.codePath;
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
return `FROM ${this.baseImage(`wordpress:${wpVersion}-php${phpVersion}-apache`)}
# Install additional PHP extensions commonly needed by WordPress
RUN docker-php-ext-install opcache
@@ -1281,7 +1438,7 @@ CMD []`
const port = app.port || 8080;
const buildTarget = detectGoBuildTarget(archiveEntries);
return `# --- Build stage ---
FROM golang:${goVersion}-alpine AS builder
FROM ${this.baseImage(`golang:${goVersion}-alpine`)} AS builder
WORKDIR /app
# Install git for fetching dependencies
@@ -1297,8 +1454,13 @@ COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main ${buildTarget}
# Collect optional runtime asset dirs — COPY has no shell so "|| true" is not
# valid there; stage them in the builder instead.
RUN mkdir -p /assets \\
&& for d in static templates public; do [ -d "$d" ] && cp -r "$d" /assets/ || true; done
# --- Production stage ---
FROM alpine:3.19
FROM ${this.baseImage('alpine:3.19')}
WORKDIR /app
# Add CA certificates for HTTPS requests
@@ -1307,11 +1469,9 @@ RUN apk --no-cache add ca-certificates tzdata
# Create non-root user
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
# Copy the binary from builder
# Copy the binary and any staged asset dirs from the builder
COPY --from=builder /app/main .
COPY --from=builder /app/static ./static 2>/dev/null || true
COPY --from=builder /app/templates ./templates 2>/dev/null || true
COPY --from=builder /app/public ./public 2>/dev/null || true
COPY --from=builder /assets/ ./
# Create data directory for persistent storage
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
@@ -1331,16 +1491,14 @@ CMD ["./main"]
private phpDockerfile(app: Application): string {
const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80;
return `FROM php:${phpVersion}-fpm-alpine
return `FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
RUN apk add --no-cache nginx supervisor curl \\
&& docker-php-ext-install pdo pdo_mysql opcache \\
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true
# Install common PHP extensions
RUN apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev \\
# Install common PHP extensions (pdo_pgsql built properly against libpq)
RUN apk add --no-cache nginx supervisor curl postgresql-libs libpng libjpeg-turbo freetype \\
&& apk add --no-cache --virtual .build-deps postgresql-dev libpng-dev libjpeg-turbo-dev freetype-dev \\
&& docker-php-ext-configure gd --with-freetype --with-jpeg \\
&& docker-php-ext-install gd
&& docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache gd \\
&& apk del .build-deps
WORKDIR /var/www/html
COPY . .
@@ -1401,7 +1559,7 @@ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `# --- Build stage ---
FROM python:${pythonVersion}-slim AS builder
FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
WORKDIR /app
# Install build dependencies
@@ -1409,13 +1567,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev \\
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
pip install --no-cache-dir --user flask gunicorn
# Install dependencies from requirements.txt or pyproject.toml. A failing
# install FAILS the build — no silent fallback that hides missing deps.
COPY . .
RUN if [ -f requirements.txt ]; then \\
echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\
elif [ -f pyproject.toml ]; then \\
echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\
else \\
echo ">>> No requirements.txt or pyproject.toml — installing default flask+gunicorn" \\
&& pip install --no-cache-dir --user flask gunicorn; \\
fi
# --- Production stage ---
FROM python:${pythonVersion}-slim
FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
WORKDIR /app
# Install runtime dependencies
@@ -1455,21 +1620,28 @@ CMD sh -c "if [ -f main.py ]; then if grep -qi fastapi main.py; then exec uvicor
const port = app.port || 8000;
const settingsModule = detectDjangoSettingsModule(archiveEntries);
return `# --- Build stage ---
FROM python:${pythonVersion}-slim AS builder
FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev \\
build-essential libpq-dev default-libmysqlclient-dev pkg-config \\
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient
# Install dependencies from requirements.txt or pyproject.toml. A failing
# install FAILS the build — no silent fallback that hides missing deps.
COPY . .
RUN if [ -f requirements.txt ]; then \\
echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\
elif [ -f pyproject.toml ]; then \\
echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\
else \\
echo ">>> No requirements.txt or pyproject.toml — installing Django defaults" \\
&& pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient; \\
fi
# --- Production stage ---
FROM python:${pythonVersion}-slim
FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
WORKDIR /app
# Install runtime dependencies