Skip to content

Manual build: IHL-CTR-0001

Artifact: Indian Hill ASP.NET 10 Runtime variants

Lab versions: 10.0.11-secure-lab.1 and 10.0.11-hardened-lab.1 Maturity: learning reference; not signed, certified, or approved for release

What this build creates

There are two reusable runtime levels and a test consumer:

  1. ihl/aspnet-runtime:10.0.11-secure-lab.1 is the minimal Secure runtime. It contains ASP.NET and its selected runtime dependencies, but no application.
  2. ihl/aspnet-runtime:10.0.11-hardened-lab.1 is a distinct child of that exact Secure image. It disables .NET debugger, profiler, EventPipe, and diagnostic IPC entry points without adding packages.
  3. ihl/reference-api:10.0.11-hardened-lab.1 is a disposable test consumer. Its build stage uses the SDK; its final stage copies only published application files onto the selected IHL runtime.

This separation demonstrates the multi-stage principle: build tools may be necessary to create software, but they do not need to be present where that software runs.

Read the runtime Dockerfile

Open images/aspnet/config/Dockerfile. Its base is:

ARG ASPNET_BASE=mcr.microsoft.com/dotnet/aspnet:10.0.11-noble-chiseled-amd64@sha256:edec6ea65a92f432083a8f75fc3c18addd004015bbd4d523ce1d13e23b347008
FROM ${ASPNET_BASE}

Important details:

  • The tag is human-readable; the digest is the immutable identity Docker enforces.
  • The architecture-specific digest intentionally makes this first exercise amd64-only. A later multi-architecture release needs one verified child per platform plus an image index.
  • Microsoft's Chiseled image is assembled from scratch with selected Ubuntu package slices. Its official recipe sets UID 1654, has no shell/package manager, and uses globalization-invariant mode because ICU is absent.
  • No RUN instruction exists in the IHL final runtime because a shell is unavailable and no package should be added casually.
  • USER 1654:1654 makes least-privilege intent explicit.
  • ENTRYPOINT ["/usr/bin/dotnet"] uses exec form. The runtime receives Unix signals directly instead of being hidden behind a shell process.
  • EXPOSE 8080 records intended container-port metadata only. It does not open a firewall or publish a host port.

The pinned Chiseled base was created on 2026-08-10, has five compressed layers totaling 55,137,755 bytes, defaults to UID 1654, and already uses /usr/bin/dotnet as its entrypoint.

Step 1: build the Secure runtime

From the repository root:

docker build --pull `
  --file images/aspnet/config/Dockerfile `
  --tag ihl/aspnet-runtime:10.0.11-secure-lab.1 `
  .
  • --pull asks Docker to resolve the base instead of trusting a stale local copy. Because the base is digest-pinned, a registry mismatch fails rather than silently changing the input.
  • --file selects the runtime definition.
  • --tag creates a convenient local name; the built image's eventual digest remains the stronger identity.
  • . is the build context. .dockerignore excludes repository internals, evidence, tools, and build outputs.

Expected result: a small metadata-only layer is added to the 55.1 MB compressed base. Record the actual output rather than assuming an exact final size.

Validate its configuration:

docker image inspect ihl/aspnet-runtime:10.0.11-secure-lab.1 `
  --format 'user={{json .Config.User}} entrypoint={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}} ports={{json .Config.ExposedPorts}}'

docker run --rm ihl/aspnet-runtime:10.0.11-secure-lab.1

Expected: user 1654:1654, entrypoint /usr/bin/dotnet, command --info, TCP port 8080 metadata, and .NET/ASP.NET 10.0.11 information.

Step 2: build the Hardened runtime

The Hardened definition accepts the exact Secure identity as a build argument. For a local exercise the local tag is the input; CI replaces it with the registry digest recorded by BuildKit.

docker build `
  --file images/aspnet/config/Dockerfile.hardened `
  --build-arg SECURE_BASE=ihl/aspnet-runtime:10.0.11-secure-lab.1 `
  --tag ihl/aspnet-runtime:10.0.11-hardened-lab.1 `
  .

Inspect the level and diagnostic contract:

docker image inspect ihl/aspnet-runtime:10.0.11-hardened-lab.1 `
  --format 'level={{index .Config.Labels "com.indianhilllabs.assurance.level"}} env={{json .Config.Env}}'

Expected: the level is hardened and the .NET diagnostic, profiler, EventPipe, and performance-map switches are all set to 0. The Hardened Dockerfile contains no RUN instruction and therefore adds no package or shell tooling.

Step 3: prove absent tooling manually

docker run --rm --entrypoint /bin/sh ihl/aspnet-runtime:10.0.11-hardened-lab.1 -c true
docker run --rm --entrypoint /usr/bin/apt-get ihl/aspnet-runtime:10.0.11-hardened-lab.1 --version
docker run --rm --entrypoint /usr/bin/bash ihl/aspnet-runtime:10.0.11-hardened-lab.1 --version

Each command is expected to fail with an OCI runtime “no such file or directory” style error. Here, failure is evidence of absence. It is different from an application crash: Docker cannot even start the requested executable.

Also inspect history:

docker image history --no-trunc ihl/aspnet-runtime:10.0.11-hardened-lab.1

Expected: no IHL layer installing packages and no SDK/compiler layer in the final image.

Step 4: build the validation application

docker build --pull `
  --file images/aspnet/examples/reference-api/Dockerfile `
  --build-arg RUNTIME_BASE=ihl/aspnet-runtime:10.0.11-hardened-lab.1 `
  --build-arg ASSURANCE_LEVEL=hardened `
  --tag ihl/reference-api:10.0.11-hardened-lab.1 `
  .

The first stage uses a digest-pinned .NET SDK 10.0.400 image to restore and publish. The repository-local NuGet.Config clears remote sources because this sample has no third-party packages; restore therefore uses SDK-provided framework packs. UseAppHost=false avoids an extra native launcher; the application is invoked by dotnet Ihl.ReferenceApi.dll. The final FROM starts a new image lineage from the local IHL runtime. Only /out crosses the boundary, owned by UID/GID 1654.

Validation:

docker image history --no-trunc ihl/reference-api:10.0.11-hardened-lab.1
docker run --rm --entrypoint /usr/bin/dotnet ihl/reference-api:10.0.11-hardened-lab.1 --list-sdks

--list-sdks is expected to print nothing: the final image has runtimes but no SDK. A missing SDK is the desired production property.

Step 5: run with least privilege

docker run --detach `
  --name ihl-ctr-0001-test `
  --publish 127.0.0.1:8080:8080 `
  --read-only `
  --cap-drop ALL `
  --security-opt no-new-privileges=true `
  --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m `
  ihl/reference-api:10.0.11-hardened-lab.1

Parameter meanings:

  • 127.0.0.1:8080:8080 binds only the local host interface for this lab.
  • --read-only mounts the image root filesystem read-only.
  • --cap-drop ALL removes Linux capability grants. Binding port 8080 requires no privileged-port capability.
  • no-new-privileges=true prevents a process from gaining privilege through setuid/setgid transitions.
  • The /tmp tmpfs is the only declared writable path. noexec, nosuid, and nodev constrain that scratch space.

Query the application from the host:

Invoke-RestMethod http://127.0.0.1:8080/healthz
Invoke-RestMethod http://127.0.0.1:8080/diagnostics/security

Expected:

health status: ok
effectiveUid: 1654
assuranceLevel: hardened
dotnetDiagnosticsDisabled: true
dotnetDiagnosticSocketPresent: false
rootFilesystemWriteSucceeded: false
temporaryFilesystemWriteSucceeded: true

The diagnostics endpoint is explicitly lab-only. It turns three runtime properties into observable behavior without adding id, touch, cat, or a shell to the production base.

Inspect Docker's applied runtime controls:

docker inspect ihl-ctr-0001-test `
  --format 'readonly={{.HostConfig.ReadonlyRootfs}} capDrop={{json .HostConfig.CapDrop}} securityOpt={{json .HostConfig.SecurityOpt}} user={{json .Config.User}}'

docker top ihl-ctr-0001-test -eo pid,user,args
docker logs ihl-ctr-0001-test

Expected: read-only is true, ALL is dropped, no-new-privileges is present, image user is 1654:1654, and the running process is dotnet Ihl.ReferenceApi.dll under the non-root identity.

Clean up the disposable container:

docker rm --force ihl-ctr-0001-test

Step 6: record the artifact digest

docker image inspect ihl/aspnet-runtime:10.0.11-secure-lab.1 --format '{{.Id}}'
docker image inspect ihl/aspnet-runtime:10.0.11-hardened-lab.1 --format '{{.Id}}'
docker image inspect ihl/reference-api:10.0.11-hardened-lab.1 --format '{{.Id}}'

A local image ID identifies local configuration plus filesystem content. After publishing to an OCI registry, record the registry RepoDigest; signing and verification will target that registry digest, not the mutable tag.

Current validation state

GitLab builds and tests these variants on the dedicated container runner. Local Docker commands remain useful for development, but only the pipeline binds registry digests, variant lineage, SBOMs, vulnerability results, and assurance decisions. Neither a local success nor a green assessment makes the artifact signed, production-approved, or Validated.