diff --git a/docs/technical-stakeholders-functional-overview.md b/docs/technical-stakeholders-functional-overview.md index 56334e54b..aa7d205bc 100644 --- a/docs/technical-stakeholders-functional-overview.md +++ b/docs/technical-stakeholders-functional-overview.md @@ -311,7 +311,7 @@ Producer/consumer split so requests never wait on Notify: `RequestNotificationSe ## 16. Hosting & deployment -- **Docker**: multi-stage Dockerfiles for Web and Worker (sdk:10.0 → aspnet:10.0, port 8080); pinned Playwright image for E2E; nginx maintenance page (`maintenance_page/`). +- **Docker**: multi-stage Dockerfiles for Web and Worker (sdk:10.0 → aspnet:10.0, port 8080); both runtime images run as the aspnet image's unprivileged `app` user (uid 1654, `USER $APP_UID`) and the AKS modules enforce `run_as_non_root` (#609); pinned Playwright image for E2E; nginx maintenance page (`maintenance_page/`). - **docker-compose**: profiled services — `web`, `rules_engine`, `db` (Postgres 18.1-alpine), `azurite` (+ one-shot `azurite_init` rules seeder), `pgadmin`; `docker-compose.e2e.yml` adds the one-shot `e2e-tests` runner (kept separate so the VS `docker-compose.dcproj` orchestration ignores it). Never add a `docker-compose.override.yml` (breaks `make test-e2e`). - **CI/CD** (`.github/workflows/build-and-deploy.yml`): build + push both images to GHCR + Snyk scan → PR review apps (`deploy` label) → E2E (non-visual) against the review app → sequential deploy matrix **development → qa → preproduction → production** on push to `main`, via shared `DFE-Digital/github-actions` actions. Also: DB backup/PTR/restore workflows, maintenance toggle, Terraform validation, review-app teardown. - **Terraform** (`terraform/application/`): AKS web app + worker (separate replica counts, exec health probes), Azure Postgres **Flexible Server v17** (note: local compose runs 18.1), two storage accounts (app: `files`, `rules-config`; private LDS ingress account), two Key Vaults, StatusCake, GCP WIF for analytics; `terraform/domains/` for DNS + Front Door. Environments: development, qa, review (per-PR), preproduction, production (`config/*.tfvars.json`). diff --git a/src/DfE.CheckPerformanceData.Web/Dockerfile b/src/DfE.CheckPerformanceData.Web/Dockerfile index 37dffcea5..87856a01e 100644 --- a/src/DfE.CheckPerformanceData.Web/Dockerfile +++ b/src/DfE.CheckPerformanceData.Web/Dockerfile @@ -18,6 +18,12 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* WORKDIR /app EXPOSE 8080 +# Run as the image's unprivileged `app` user (least privilege, #609), as the worker image does. +# $APP_UID is the numeric uid 1654 the aspnet image defines; it must stay numeric because +# Kubernetes' runAsNonRoot check reads the image's USER and refuses a name. The app writes +# nothing to disk at run time: data-protection keys persist to blob storage, and the multipart +# buffering ASP.NET does for large uploads goes to /tmp, which is world-writable. +USER $APP_UID # Build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build @@ -57,6 +63,9 @@ RUN dotnet publish DfE.CheckPerformanceData.Web/DfE.CheckPerformanceData.Web.csp # Derives from `base` so it carries the same OS security updates. FROM base AS debug-runtime +# `base` runs as the app user; installing the debugger needs root. Handed back below. +USER root + # Install vsdbg — the .NET debugger used by VS Code Docker/C# Dev Kit RUN apt-get update && apt-get install -y --no-install-recommends unzip wget && \ rm -rf /var/lib/apt/lists/* && \ @@ -65,6 +74,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends unzip wget && \ COPY --from=vscode-debug /app/publish . +# Back to the unprivileged user so the debug image runs the way the deployed one does. +USER $APP_UID + # Disable debug wait so the app starts immediately; the debugger attaches on demand ENV COMPlus_DebugWait=0 diff --git a/terraform/application/application.tf b/terraform/application/application.tf index 144054e69..e1acc154f 100644 --- a/terraform/application/application.tf +++ b/terraform/application/application.tf @@ -47,6 +47,10 @@ module "web_application" { enable_logit = var.enable_logit web_port = 8080 + # The image runs as the numeric app user (USER $APP_UID in its Dockerfile); have Kubernetes + # refuse to start it as anything else (#609, OWASP least privilege). + run_as_non_root = true + send_traffic_to_maintenance_page = var.send_traffic_to_maintenance_page enable_gcp_wif = var.enable_dfe_analytics_federated_auth @@ -82,6 +86,9 @@ module "rules_engine_worker" { docker_image = var.worker_docker_image enable_logit = var.enable_logit + # The worker image has run as the numeric app user all along; enforce it too (#609). + run_as_non_root = true + # Gate startup and liveness on readiness so a worker that cannot reach its database # or queue is detected and restarted rather than left silently stuck. The liveness # path is the lighter process-up signal exposed for orchestrators that probe it. diff --git a/tests/DfE.CheckPerformanceData.UnitTests/Web/ContainerUserSourceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/Web/ContainerUserSourceTests.cs new file mode 100644 index 000000000..50d6663a3 --- /dev/null +++ b/tests/DfE.CheckPerformanceData.UnitTests/Web/ContainerUserSourceTests.cs @@ -0,0 +1,115 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace DfE.CheckPerformanceData.Application.UnitTests.Web; + +// Both runtime images must run as the aspnet image's unprivileged `app` user, and the AKS modules +// must enforce it (#609, OWASP least privilege). The user is written as `USER $APP_UID` — uid 1654, +// numeric — because Kubernetes' runAsNonRoot check reads the image's USER and refuses a name. +// Locally nothing notices a dropped line: the image builds and compose runs it as root. On AKS the +// pod is refused at deploy time, far from the cause. +public sealed class ContainerUserSourceTests +{ + [Theory] + [InlineData("DfE.CheckPerformanceData.Web")] + [InlineData("DfE.CheckPerformanceData.RulesEngineWorker")] + public void BaseStage_DeclaresTheAppUser(string project) + { + var dockerfile = File.ReadAllText(DockerfilePath(project)); + + Assert.Contains("$APP_UID", UserDirectives(StageNamed(dockerfile, "base"))); + } + + // The effective user is the last USER directive across the stages the shipped one builds on, so a + // later `USER root` in the shipped stage (or in base) must fail even though base still names the app user. + [Theory] + [InlineData("DfE.CheckPerformanceData.Web", "runtime")] + [InlineData("DfE.CheckPerformanceData.RulesEngineWorker", "final")] + public void ShippedStage_RunsAsTheAppUser(string project, string shippedStage) + { + var dockerfile = File.ReadAllText(DockerfilePath(project)); + var shipped = StageNamed(dockerfile, shippedStage); + + Assert.StartsWith("FROM base AS", shipped); + + var users = UserDirectives(StageNamed(dockerfile, "base")).Concat(UserDirectives(shipped)).ToList(); + + Assert.NotEmpty(users); + Assert.Equal("$APP_UID", users[^1]); + } + + [Fact] + public void WebDebugStage_HandsBackToTheAppUser_AfterInstallingTheDebugger() + { + var dockerfile = File.ReadAllText(DockerfilePath("DfE.CheckPerformanceData.Web")); + var stage = StageNamed(dockerfile, "debug-runtime"); + + var users = UserMatches(stage); + var install = stage.IndexOf("apt-get install", StringComparison.Ordinal); + var root = users.FirstOrDefault(m => m.Groups[1].Value == "root"); + var app = users.LastOrDefault(m => m.Groups[1].Value == "$APP_UID"); + + Assert.True(install >= 0, "the debug stage installs the debugger with apt-get"); + Assert.True(root is not null && root.Index < install, "the apt-get install needs root, so USER root must come before it"); + Assert.True(app is not null && app.Index > install, "the stage must hand back to the app user after the install"); + } + + [Theory] + [InlineData("web_application")] + [InlineData("rules_engine_worker")] + public void AksModule_EnforcesNonRoot(string module) + { + var tf = File.ReadAllText(Path.Combine(RepoRoot, "terraform", "application", "application.tf")); + + Assert.Matches(new Regex(@"^[ \t]*run_as_non_root[ \t]*=[ \t]*true\b", RegexOptions.Multiline), ModuleBlock(tf, module)); + } + + // The text of one build stage: its `FROM … AS ` line through to the next `FROM` or the end. + private static string StageNamed(string dockerfile, string name) + { + var match = Regex.Match( + dockerfile, + $@"^FROM [^\r\n]* AS {Regex.Escape(name)}[ \t]*\r?$.*?(?=^FROM |\z)", + RegexOptions.Multiline | RegexOptions.Singleline); + + Assert.True(match.Success, $"no stage named {name}"); + return match.Value; + } + + // Real USER directives only (anchored at line start), so a comment naming one never counts. + private static MatchCollection UserMatches(string stageText) => + Regex.Matches(stageText, @"^[ \t]*USER[ \t]+(\S+)[ \t]*\r?$", RegexOptions.Multiline); + + private static List UserDirectives(string stageText) => + UserMatches(stageText).Select(m => m.Groups[1].Value).ToList(); + + private static string DockerfilePath(string project) => Path.Combine(RepoRoot, "src", project, "Dockerfile"); + + // The text of one `module "" { … }` block, up to its closing brace at column 0. + private static string ModuleBlock(string tf, string name) + { + var start = tf.IndexOf($"module \"{name}\" {{", StringComparison.Ordinal); + Assert.True(start >= 0, $"no module {name}"); + + var end = tf.IndexOf("\n}", start, StringComparison.Ordinal); + Assert.True(end > start, $"module {name} never closes"); + return tf[start..end]; + } + + private static string RepoRoot + { + get + { + var dir = new DirectoryInfo(Path.GetDirectoryName(ThisFilePath())!); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Makefile"))) + { + dir = dir.Parent; + } + + return dir?.FullName + ?? throw new InvalidOperationException("Could not locate the repository root from the test file path."); + } + } + + private static string ThisFilePath([CallerFilePath] string path = "") => path; +}