feat(build): revamp app build pipeline (queue, Nixpacks, MinIO, Trivy, registry GC)
Rework the application build/deploy pipeline for scalability, reproducibility, and security: - Build queue: deploys run through a bounded-concurrency Bull queue (BUILD_CONCURRENCY, default 3) so concurrent user deploys can't flood the cluster with Kaniko jobs. Build state (progress / cancel / session) moves from in-memory Maps to Redis, so cancel + live logs work across backend replicas. - Nixpacks + BYO Dockerfile: code runtimes build via Nixpacks (or the user's own Dockerfile when present); the hand-written per-runtime Dockerfile generators and runtime auto-detection are removed. WordPress keeps its templated path. Build-time mirror env (NIXPACKS_BUILD_ENV) supports the Iran network. - Source upload to MinIO: archives stream to in-cluster MinIO; build pods pull via a presigned URL. Removes the PVC + helper pod + kubectl cp upload path. - Report-only Trivy scan after build; per-severity summary stored on the deployment and shown as a badge in the dashboard. Never gates a deploy. - Registry GC: a Redis-locked daily job keeps the newest N image tags per app (REGISTRY_KEEP_VERSIONS, default 3) and reclaims disk via garbage-collect. - Hardening: git tokens are delivered via a per-build Secret + git credential store instead of being embedded in the clone URL / Job manifest; build timeout is configurable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { BuildService } from './build.service';
|
||||
import { ScanService } from './scan.service';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
import { ClustersModule } from '../clusters/clusters.module';
|
||||
|
||||
@@ -8,7 +9,7 @@ import { ClustersModule } from '../clusters/clusters.module';
|
||||
forwardRef(() => KubernetesModule),
|
||||
ClustersModule,
|
||||
],
|
||||
providers: [BuildService],
|
||||
exports: [BuildService],
|
||||
providers: [BuildService, ScanService],
|
||||
exports: [BuildService, ScanService],
|
||||
})
|
||||
export class BuildModule {}
|
||||
|
||||
@@ -1,298 +1,16 @@
|
||||
import { AppRuntime } from '../common/enums';
|
||||
|
||||
/**
|
||||
* Tests for build service — Dockerfile generation for all runtimes
|
||||
* Tests for build service:
|
||||
* • Nixpacks build preparation (BYO Dockerfile vs generated) for code runtimes
|
||||
* • WordPress templated Dockerfile + helper-pod / entrypoint / zip-structure logic
|
||||
*
|
||||
* NOTE: like the rest of this file, the Nixpacks tests reproduce the pure logic
|
||||
* locally instead of importing BuildService — the service pulls in the ESM
|
||||
* `@kubernetes/client-node`, which this project's Jest config does not transform.
|
||||
* Keep these copies in sync with nixpacksPrepareInitContainer in build.service.ts.
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Go Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Go Dockerfile generation', () => {
|
||||
function goDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const goVersion = app.runtimeVersion || '1.22';
|
||||
const port = app.port || 8080;
|
||||
return `FROM golang:${goVersion}-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download || true
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
|
||||
COPY --from=builder /app/main .
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
|
||||
CMD ["./main"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Go version', () => {
|
||||
const df = goDockerfile({ runtimeVersion: '1.21' });
|
||||
expect(df).toContain('FROM golang:1.21-alpine');
|
||||
});
|
||||
|
||||
it('should default to Go 1.22', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('FROM golang:1.22-alpine');
|
||||
});
|
||||
|
||||
it('should build static binary with CGO_ENABLED=0', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('CGO_ENABLED=0');
|
||||
});
|
||||
|
||||
it('should use multi-stage build for smaller image', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('AS builder');
|
||||
expect(df).toContain('FROM alpine:3.19');
|
||||
});
|
||||
|
||||
it('should include health check', () => {
|
||||
const df = goDockerfile({ port: 8080 });
|
||||
expect(df).toContain('HEALTHCHECK');
|
||||
expect(df).toContain('http://localhost:8080/health');
|
||||
});
|
||||
|
||||
it('should create data directory for persistent storage', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('mkdir -p /app/data');
|
||||
});
|
||||
|
||||
it('should run as non-root user', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('USER appuser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Python Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Python Dockerfile generation', () => {
|
||||
function pythonDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y build-essential libpq-dev
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user flask gunicorn
|
||||
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY . .
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
|
||||
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:${port}", "app:app"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Python version', () => {
|
||||
const df = pythonDockerfile({ runtimeVersion: '3.11' });
|
||||
expect(df).toContain('FROM python:3.11-slim');
|
||||
});
|
||||
|
||||
it('should default to Python 3.12', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('FROM python:3.12-slim');
|
||||
});
|
||||
|
||||
it('should use multi-stage build', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('AS builder');
|
||||
});
|
||||
|
||||
it('should install from requirements.txt', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('requirements.txt');
|
||||
});
|
||||
|
||||
it('should include health check', () => {
|
||||
const df = pythonDockerfile({ port: 8000 });
|
||||
expect(df).toContain('HEALTHCHECK');
|
||||
expect(df).toContain('http://localhost:8000/health');
|
||||
});
|
||||
|
||||
it('should run as non-root user', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('USER appuser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Django Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Django Dockerfile generation', () => {
|
||||
function djangoDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y build-essential libpq-dev
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user django gunicorn
|
||||
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY . .
|
||||
RUN mkdir -p /app/staticfiles /app/media /app/data
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
ENV DJANGO_SETTINGS_MODULE=config.settings
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health/ || exit 1
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:${port}"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Python version', () => {
|
||||
const df = djangoDockerfile({ runtimeVersion: '3.10' });
|
||||
expect(df).toContain('FROM python:3.10-slim');
|
||||
});
|
||||
|
||||
it('should set DJANGO_SETTINGS_MODULE', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('DJANGO_SETTINGS_MODULE');
|
||||
});
|
||||
|
||||
it('should create staticfiles and media directories', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('/app/staticfiles');
|
||||
expect(df).toContain('/app/media');
|
||||
});
|
||||
|
||||
it('should run migrations on startup', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('migrate');
|
||||
});
|
||||
|
||||
it('should use gunicorn for production', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('gunicorn');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// .NET Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('.NET Dockerfile generation', () => {
|
||||
function dotnetDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const dotnetVersion = app.runtimeVersion || '8.0';
|
||||
const port = app.port || 5000;
|
||||
return `FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
|
||||
WORKDIR /src
|
||||
COPY *.csproj ./
|
||||
RUN dotnet restore || true
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
RUN mkdir -p /app/data
|
||||
USER appuser
|
||||
ENV ASPNETCORE_URLS=http://+:${port}
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
|
||||
CMD ["dotnet", "app.dll"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct .NET version', () => {
|
||||
const df = dotnetDockerfile({ runtimeVersion: '7.0' });
|
||||
expect(df).toContain('dotnet/sdk:7.0');
|
||||
expect(df).toContain('dotnet/aspnet:7.0');
|
||||
});
|
||||
|
||||
it('should default to .NET 8.0', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('dotnet/sdk:8.0');
|
||||
});
|
||||
|
||||
it('should use multi-stage build', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('AS build');
|
||||
expect(df).toContain('dotnet/aspnet');
|
||||
});
|
||||
|
||||
it('should publish in Release mode', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('-c Release');
|
||||
});
|
||||
|
||||
it('should set ASPNETCORE_ENVIRONMENT to Production', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('ASPNETCORE_ENVIRONMENT=Production');
|
||||
});
|
||||
|
||||
it('should configure ASPNETCORE_URLS for correct port', () => {
|
||||
const df = dotnetDockerfile({ port: 8080 });
|
||||
expect(df).toContain('ASPNETCORE_URLS=http://+:8080');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PHP Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('PHP Dockerfile generation', () => {
|
||||
function phpDockerfile(app: { phpVersion?: string; port?: number }): string {
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const port = app.port || 80;
|
||||
return `FROM php:${phpVersion}-fpm-alpine
|
||||
RUN apk add --no-cache nginx supervisor curl
|
||||
RUN docker-php-ext-install pdo pdo_mysql opcache
|
||||
WORKDIR /var/www/html
|
||||
COPY . .
|
||||
RUN mkdir -p /var/www/html/uploads /var/www/html/data
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
EXPOSE ${port}
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct PHP version', () => {
|
||||
const df = phpDockerfile({ phpVersion: '8.2' });
|
||||
expect(df).toContain('FROM php:8.2-fpm-alpine');
|
||||
});
|
||||
|
||||
it('should default to PHP 8.3', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('FROM php:8.3-fpm-alpine');
|
||||
});
|
||||
|
||||
it('should use FPM with nginx via supervisord', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('supervisord');
|
||||
expect(df).toContain('nginx');
|
||||
});
|
||||
|
||||
it('should install common PHP extensions', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('pdo');
|
||||
expect(df).toContain('opcache');
|
||||
});
|
||||
|
||||
it('should create upload and data directories', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('/var/www/html/uploads');
|
||||
expect(df).toContain('/var/www/html/data');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for the WordPress build flow — specifically:
|
||||
* 1. Helper pod PVC race condition (must wait for termination)
|
||||
@@ -467,3 +185,75 @@ describe('WordPress zip structure handling', () => {
|
||||
expect(copiedAsIs).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nixpacks build preparation', () => {
|
||||
// Local copies of the pure logic in build.service.ts (see NOTE at top of file).
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function nixpacksPlanEnv(app: { runtime: AppRuntime; runtimeVersion?: string }): { name: string; value: string }[] {
|
||||
const env: { name: string; value: string }[] = [];
|
||||
if (app.runtime === AppRuntime.NODEJS && app.runtimeVersion) {
|
||||
env.push({ name: 'NIXPACKS_NODE_VERSION', value: String(app.runtimeVersion) });
|
||||
}
|
||||
if ((app.runtime === AppRuntime.PYTHON || app.runtime === AppRuntime.DJANGO) && app.runtimeVersion) {
|
||||
env.push({ name: 'NIXPACKS_PYTHON_VERSION', value: String(app.runtimeVersion) });
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function nixpacksPrepareInitContainer(
|
||||
app: { runtime: AppRuntime; runtimeVersion?: string },
|
||||
config: { nixpacksImage?: string; nixpacksBuildEnv?: string[] } = {},
|
||||
): any {
|
||||
const image = config.nixpacksImage || 'ghcr.io/railwayapp/nixpacks:latest';
|
||||
const buildEnv = config.nixpacksBuildEnv || [];
|
||||
const envFlags = buildEnv.map((kv) => `--env ${shellQuote(kv)}`).join(' ');
|
||||
const planEnv = nixpacksPlanEnv(app);
|
||||
return {
|
||||
name: 'nixpacks-prepare',
|
||||
image,
|
||||
env: planEnv.length ? planEnv : undefined,
|
||||
command: [
|
||||
'sh',
|
||||
'-c',
|
||||
`if [ -f source/Dockerfile ]; then cp source/Dockerfile /workspace/Dockerfile; ` +
|
||||
`else nixpacks build source --out source ${envFlags} && cp source/.nixpacks/Dockerfile /workspace/Dockerfile; fi`,
|
||||
],
|
||||
volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }],
|
||||
};
|
||||
}
|
||||
|
||||
it('prefers a user-provided Dockerfile (BYO), falling back to Nixpacks', () => {
|
||||
const script = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS }).command[2] as string;
|
||||
expect(script).toContain('if [ -f source/Dockerfile ]');
|
||||
expect(script).toContain('cp source/Dockerfile /workspace/Dockerfile');
|
||||
expect(script).toContain('nixpacks build source --out source');
|
||||
expect(script).toContain('cp source/.nixpacks/Dockerfile /workspace/Dockerfile');
|
||||
});
|
||||
|
||||
it('uses the configured Nixpacks image (default when unset)', () => {
|
||||
expect(nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }).image).toBe('ghcr.io/railwayapp/nixpacks:latest');
|
||||
expect(
|
||||
nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }, { nixpacksImage: 'registry.local/nixpacks:1.2.3' }).image,
|
||||
).toBe('registry.local/nixpacks:1.2.3');
|
||||
});
|
||||
|
||||
it('bakes build-time mirror env into the build via --env flags', () => {
|
||||
const script = nixpacksPrepareInitContainer(
|
||||
{ runtime: AppRuntime.NODEJS },
|
||||
{ nixpacksBuildEnv: ['NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'] },
|
||||
).command[2] as string;
|
||||
expect(script).toContain(`--env 'NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'`);
|
||||
});
|
||||
|
||||
it('maps the selected Node version to NIXPACKS_NODE_VERSION', () => {
|
||||
const c = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS, runtimeVersion: '20' });
|
||||
expect(c.env).toContainEqual({ name: 'NIXPACKS_NODE_VERSION', value: '20' });
|
||||
});
|
||||
|
||||
it('shellQuote escapes embedded single quotes safely', () => {
|
||||
expect(shellQuote("a'b")).toBe("'a'\\''b'");
|
||||
});
|
||||
});
|
||||
|
||||
+381
-960
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Tests for the pure Trivy-report aggregation logic in ScanService.summarize.
|
||||
*
|
||||
* NOTE: like the other build specs, this reproduces the pure logic locally rather
|
||||
* than importing ScanService — the service pulls in the ESM `@kubernetes/client-node`,
|
||||
* which this project's Jest config does not transform. Keep in sync with scan.service.ts.
|
||||
*/
|
||||
|
||||
interface VulnerabilitySummary {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
unknown: number;
|
||||
total: number;
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
function parseTrivyJson(output: string): any | null {
|
||||
if (!output) return null;
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
const start = output.indexOf('{');
|
||||
const end = output.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(output.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(trivyOutput: string): VulnerabilitySummary {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
||||
const parsed = parseTrivyJson(trivyOutput);
|
||||
const results: any[] = Array.isArray(parsed?.Results) ? parsed.Results : [];
|
||||
for (const result of results) {
|
||||
const vulns: any[] = Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : [];
|
||||
for (const v of vulns) {
|
||||
const sev = String(v?.Severity || 'UNKNOWN').toUpperCase();
|
||||
if (sev === 'CRITICAL') counts.critical++;
|
||||
else if (sev === 'HIGH') counts.high++;
|
||||
else if (sev === 'MEDIUM') counts.medium++;
|
||||
else if (sev === 'LOW') counts.low++;
|
||||
else counts.unknown++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...counts,
|
||||
total: counts.critical + counts.high + counts.medium + counts.low + counts.unknown,
|
||||
scannedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScanService.summarize', () => {
|
||||
it('counts vulnerabilities per severity across results', () => {
|
||||
const report = JSON.stringify({
|
||||
Results: [
|
||||
{ Vulnerabilities: [{ Severity: 'CRITICAL' }, { Severity: 'HIGH' }, { Severity: 'high' }] },
|
||||
{ Vulnerabilities: [{ Severity: 'MEDIUM' }, { Severity: 'LOW' }, { Severity: 'WeIrD' }] },
|
||||
{ Vulnerabilities: null },
|
||||
{},
|
||||
],
|
||||
});
|
||||
const s = summarize(report);
|
||||
expect(s).toMatchObject({ critical: 1, high: 2, medium: 1, low: 1, unknown: 1, total: 6 });
|
||||
expect(typeof s.scannedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('returns all-zero summary for a clean image', () => {
|
||||
expect(summarize(JSON.stringify({ Results: [{ Target: 'x' }] }))).toMatchObject({
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
unknown: 0,
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates leading log noise before the JSON', () => {
|
||||
const noisy = `2026-06-20 INFO Need to update DB\n{"Results":[{"Vulnerabilities":[{"Severity":"CRITICAL"}]}]}`;
|
||||
expect(summarize(noisy).critical).toBe(1);
|
||||
});
|
||||
|
||||
it('returns a zero summary on unparseable output', () => {
|
||||
expect(summarize('not json at all').total).toBe(0);
|
||||
expect(summarize('').total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { RegistryService } from '../kubernetes/registry.service';
|
||||
|
||||
export interface VulnerabilitySummary {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
unknown: number;
|
||||
total: number;
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report-only image vulnerability scanning with Trivy. Runs a one-shot K8s Job
|
||||
* that scans the freshly-pushed image in the in-cluster registry and stores a
|
||||
* severity summary on the deployment. Never blocks a deployment — any failure is
|
||||
* logged and ignored.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScanService {
|
||||
private readonly logger = new Logger(ScanService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly clustersService: ClustersService,
|
||||
private readonly registryService: RegistryService,
|
||||
) {}
|
||||
|
||||
/** Aggregate a Trivy JSON report (raw stdout) into per-severity counts. Pure & testable. */
|
||||
summarize(trivyOutput: string): VulnerabilitySummary {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
||||
const parsed = this.parseTrivyJson(trivyOutput);
|
||||
const results: any[] = Array.isArray(parsed?.Results) ? parsed.Results : [];
|
||||
for (const result of results) {
|
||||
const vulns: any[] = Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : [];
|
||||
for (const v of vulns) {
|
||||
const sev = String(v?.Severity || 'UNKNOWN').toUpperCase();
|
||||
if (sev === 'CRITICAL') counts.critical++;
|
||||
else if (sev === 'HIGH') counts.high++;
|
||||
else if (sev === 'MEDIUM') counts.medium++;
|
||||
else if (sev === 'LOW') counts.low++;
|
||||
else counts.unknown++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...counts,
|
||||
total: counts.critical + counts.high + counts.medium + counts.low + counts.unknown,
|
||||
scannedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Trivy prints clean JSON to stdout, but tolerate any leading noise from the log stream. */
|
||||
private parseTrivyJson(output: string): any | null {
|
||||
if (!output) return null;
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch {
|
||||
const start = output.indexOf('{');
|
||||
const end = output.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(output.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a pushed image and return a severity summary, or null on any failure.
|
||||
* Report-only: callers must treat null as "no data", never as a deploy gate.
|
||||
*/
|
||||
async scanImage(app: Application, imageUri: string): Promise<VulnerabilitySummary | null> {
|
||||
if (this.configService.get<boolean>('build.scanEnabled') === false) return null;
|
||||
|
||||
const buildNs = this.registryService.getBuildNamespace();
|
||||
const image = this.configService.get<string>('build.trivyImage') || 'aquasec/trivy:latest';
|
||||
const dbRepo = this.configService.get<string>('build.trivyDbRepository') || '';
|
||||
const timeoutSeconds = this.configService.get<number>('build.scanTimeoutSeconds') || 300;
|
||||
const { username, password } = this.registryService.getRegistryCredentials();
|
||||
|
||||
const jobName = `scan-${app.name}-${Date.now()}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
|
||||
|
||||
try {
|
||||
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
|
||||
const env: { name: string; value: string }[] = [
|
||||
{ name: 'TRIVY_INSECURE', value: 'true' }, // in-cluster registry is plain HTTP
|
||||
{ name: 'TRIVY_NON_SSL', value: 'true' },
|
||||
];
|
||||
if (username) env.push({ name: 'TRIVY_USERNAME', value: username });
|
||||
if (password) env.push({ name: 'TRIVY_PASSWORD', value: password });
|
||||
if (dbRepo) env.push({ name: 'TRIVY_DB_REPOSITORY', value: dbRepo });
|
||||
|
||||
const job: k8s.V1Job = {
|
||||
apiVersion: 'batch/v1',
|
||||
kind: 'Job',
|
||||
metadata: { name: jobName, namespace: buildNs },
|
||||
spec: {
|
||||
backoffLimit: 0,
|
||||
ttlSecondsAfterFinished: 120,
|
||||
template: {
|
||||
spec: {
|
||||
restartPolicy: 'Never',
|
||||
containers: [
|
||||
{
|
||||
name: 'trivy',
|
||||
image,
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
env,
|
||||
args: ['image', '--quiet', '--no-progress', '--format', 'json', '--severity', 'CRITICAL,HIGH,MEDIUM,LOW', imageUri],
|
||||
resources: {
|
||||
requests: { cpu: '250m', memory: '512Mi' },
|
||||
limits: { cpu: '1', memory: '1Gi' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await batchApi.createNamespacedJob({ namespace: buildNs, body: job });
|
||||
await this.waitForJob(batchApi, jobName, buildNs, timeoutSeconds);
|
||||
|
||||
const output = await this.getJobPodLogs(coreApi, jobName, buildNs);
|
||||
const summary = this.summarize(output);
|
||||
|
||||
await batchApi
|
||||
.deleteNamespacedJob({ name: jobName, namespace: buildNs, gracePeriodSeconds: 0, propagationPolicy: 'Foreground' })
|
||||
.catch(() => undefined);
|
||||
|
||||
this.logger.log(`Scan complete for ${imageUri}: ${summary.critical}C/${summary.high}H/${summary.medium}M/${summary.low}L`);
|
||||
return summary;
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Image scan failed for ${imageUri} (report-only, ignored): ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForJob(batchApi: k8s.BatchV1Api, jobName: string, namespace: string, timeoutSeconds: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
if (job.status?.succeeded) return;
|
||||
if ((job.status?.failed ?? 0) > 0) return; // Trivy exits non-zero on findings with some flags; read logs anyway
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
}
|
||||
throw new Error(`Scan job ${jobName} timed out after ${timeoutSeconds}s`);
|
||||
}
|
||||
|
||||
private async getJobPodLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise<string> {
|
||||
const pods = await coreApi.listNamespacedPod({ namespace, labelSelector: `job-name=${jobName}` });
|
||||
const podName = pods.items[0]?.metadata?.name;
|
||||
if (!podName) throw new Error(`No pod found for scan job ${jobName}`);
|
||||
return coreApi.readNamespacedPodLog({ name: podName, namespace, container: 'trivy' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user