Skip to content

Library for building and pushing images to ECR without a container runtime - #2602

Draft
normj wants to merge 6 commits into
masterfrom
normj/aws-oci-builder
Draft

normj wants to merge 6 commits into
masterfrom
normj/aws-oci-builder

Conversation

@normj

@normj normj commented Oct 27, 2026

Copy link
Copy Markdown
Member

Draft: Will still reviewing the code myself

Description of changes:
Related to some other tooling we are building we want the ability to create a container without requiring a container runtime installation. Following the inspiration done with the .NET CLI to build containers this library will build containers that just need to add new layers of files and metadata to a base image.

The library is primarily intended for use with other AWS libraries so we plan to keep the version less then 1.0.0 to avoid implying any contract that we won't make API breaking changes. We will make changes based on primarily on the need of our AWS tooling.

The library by design avoids dependencies like the AWS SDK for .NET. This is because we anticipate some of our usage to be for PowerShell where it is easy to get a conflict with already loaded .NET assemblies.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

normj added 2 commits October 27, 2026 10:06
Enable IsTrimmable, EnableTrimAnalyzer, and IsAotCompatible, and remove all
reflection-based System.Text.Json usage so the library is trim- and AOT-safe:

- Add source-generated OciJsonContext for Manifest, ImageIndex, and TokenResponse;
  route their serialization/deserialization through compile-time JsonTypeInfo.
- Lift TokenResponse to an internal top-level type so the source-gen context can
  reference it.
- Serialize the free-form ImageConfig JsonObject via a Utf8JsonWriter and type each
  JsonArray.Add argument as JsonNode, dropping the reflection-based
  DefaultJsonTypeInfoResolver.
- Apply UnsafeRelaxedJsonEscaping through the source-gen resolver so manifest bytes
  (and thus the computed digest) stay identical to other container tooling.
@normj
normj requested review from a team as code owners October 27, 2026 17:37
@normj
normj marked this pull request as draft October 27, 2026 17:38
@normj
normj requested review from jnunn-aws and a balanced review from Copilot and removed request for richardwang1124 October 27, 2026 17:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical vulnerabilities permit SSRF, ECR credential disclosure, and unintended exposure of host files and private image contents.

16 open issues
What changed in this PR

Introduces AWS.OCI.Builder, a pre-1.0 .NET library for AWS tooling to build container images from existing base images and push them to Amazon ECR without a container runtime or AWS SDK dependency.

Changes:

  • Adds image assembly, file mappings, Unix permissions, and configurable image metadata.
  • Implements registry authentication, digest verification, disk-backed layer uploads, and progress reporting.
  • Adds unit and ECR integration tests, documentation, and build/release registration.
File Description
Libraries/​test/​AWS.OCI.Builder.Tests/​UnixPermissionsTests.cs Tests octal permission parsing.
Libraries/​test/​AWS.OCI.Builder.Tests/​RealmValidatorTests.cs Tests authentication realm validation.
Libraries/​test/​AWS.OCI.Builder.Tests/​OciImageBuilderTests.cs Tests push orchestration, spill files, and progress.
Libraries/​test/​AWS.OCI.Builder.Tests/​LayerTests.cs Tests layer structure, reproducibility, and permissions.
Libraries/​test/​AWS.OCI.Builder.Tests/​ImageReferenceTests.cs Tests image reference parsing and ECR detection.
Libraries/​test/​AWS.OCI.Builder.Tests/​ImageAssemblyTests.cs Tests configuration, manifests, and image assembly.
Libraries/​test/​AWS.OCI.Builder.Tests/​FileManifestTests.cs Tests file-mapping collection initialization.
Libraries/​test/​AWS.OCI.Builder.Tests/​DigestVerificationTests.cs Tests blob integrity and temporary-file handling.
Libraries/​test/​AWS.OCI.Builder.Tests/​AWS.OCI.Builder.Tests.csproj Defines the unit-test project.
Libraries/​test/​AWS.OCI.Builder.Tests/​AuthHandshakeHandlerTests.cs Tests authentication exchanges and realm rejection.
Libraries/​test/​AWS.OCI.Builder.IntegrationTests/​EcrPushIntegrationTests.cs Exercises real ECR pushes and uploaded permissions.
Libraries/​test/​AWS.OCI.Builder.IntegrationTests/​AWS.OCI.Builder.IntegrationTests.csproj Defines the ECR integration-test project.
Libraries/​src/​AWS.OCI.Builder/​UnixPermissions.cs Converts octal permissions to Unix mode flags.
Libraries/​src/​AWS.OCI.Builder/​Registry/​TokenResponse.cs Models authentication tokens and expiration.
Libraries/​src/​AWS.OCI.Builder/​Registry/​RegistryExceptions.cs Defines registry and integrity exceptions.
Libraries/​src/​AWS.OCI.Builder/​Registry/​RegistryClient.cs Implements manifest and blob transfers with verification.
Libraries/​src/​AWS.OCI.Builder/​Registry/​RealmValidator.cs Validates authentication endpoint URLs.
Libraries/​src/​AWS.OCI.Builder/​Registry/​IRegistryCredentialSource.cs Defines anonymous and static credential sources.
Libraries/​src/​AWS.OCI.Builder/​Registry/​AuthHandshakeHandler.cs Implements Basic and Bearer authentication retries.
Libraries/​src/​AWS.OCI.Builder/​README.md Documents usage, authentication, progress, and permissions.
Libraries/​src/​AWS.OCI.Builder/​OciImageBuilder.cs Coordinates base-image retrieval, layer creation, and ECR pushes.
Libraries/​src/​AWS.OCI.Builder/​MediaTypes.cs Defines OCI and Docker media types.
Libraries/​src/​AWS.OCI.Builder/​LayerPushProgress.cs Defines per-layer progress events.
Libraries/​src/​AWS.OCI.Builder/​Images/​OciJsonContext.cs Provides source-generated JSON serialization metadata.
Libraries/​src/​AWS.OCI.Builder/​Images/​Manifest.cs Models manifests, indexes, and platform selection.
Libraries/​src/​AWS.OCI.Builder/​Images/​Layer.cs Builds reproducible compressed layers and their digests.
Libraries/​src/​AWS.OCI.Builder/​Images/​ImageConfig.cs Supports image configuration customization.
Libraries/​src/​AWS.OCI.Builder/​Images/​ImageBuilder.cs Assembles final configuration and manifest artifacts.
Libraries/​src/​AWS.OCI.Builder/​ImageReference.cs Parses registry, repository, tag, and digest references.
Libraries/​src/​AWS.OCI.Builder/​FileManifest.cs Defines local-to-container file mappings and permissions.
Libraries/​src/​AWS.OCI.Builder/​EcrPushOptions.cs Configures memory thresholds and progress reporting.
Libraries/​src/​AWS.OCI.Builder/​Descriptor.cs Models content-addressed image descriptors.
Libraries/​src/​AWS.OCI.Builder/​AWS.OCI.Builder.csproj Configures packaging, trimming, and Native AOT compatibility.
Libraries/​Libraries.sln Registers the library and test projects.
Libraries/​AWS.OCI.Builder.slnf Adds a focused solution filter.
buildtools/​build.proj Adds unit tests to the build workflow.
.autover/​changes/​1df94c4d-8841-4630-89ca-9324e3bcaf79.json Records the initial-release changelog entry.
.autover/​autover.json Registers the package for automated versioning.

Review effort: Balanced

Comment thread Libraries/src/AWS.OCI.Builder/ImageReference.cs
Comment thread Libraries/src/AWS.OCI.Builder/Images/Layer.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/Images/Layer.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/OciImageBuilder.cs
Comment thread Libraries/src/AWS.OCI.Builder/OciImageBuilder.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/OciImageBuilder.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/Registry/AuthHandshakeHandler.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/Registry/RegistryClient.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/Registry/RegistryClient.cs Outdated
Comment thread Libraries/test/AWS.OCI.Builder.IntegrationTests/EcrPushIntegrationTests.cs Outdated
Security and correctness fixes across the library plus regression tests.

Security:
- Validate the repository name against the OCI grammar so a malformed path
  cannot override the request authority and leak credentials.
- Reject symbolic links / reparse points during layer assembly (a link inside a
  mapped directory could otherwise copy files from outside it into the image).
- Enforce the SSRF guard at the socket layer: disable auto-redirect and add a
  connect callback that resolves the host, refuses blocked
  (loopback/private/link-local) addresses, and connects to the validated
  address - robust against DNS rebinding and covering realm, registry, and blob
  redirects. Blob-download redirects are followed explicitly and validated.
- Create spilled layer/blob temp files with owner-only (0600) permissions.

Correctness:
- Validate the fetched base config's OS/architecture against the requested
  platform instead of silently pushing the wrong platform.
- Strip an optional tag from a reference that also carries a digest.
- Preserve empty descendant directories in recursive directory mappings, and
  normalize/validate expanded target paths.
- Stop emitting synthesized ancestor directory entries so they no longer clobber
  base-image directory permissions.
- Mutate the image builder only after the layer spill succeeds.
- Replay a seekable 401-retry body by rewinding instead of buffering the blob.
- Make the upload timeout configurable (default: no client timeout).
- Use the stream's remaining length for an implicit Content-Length.
- Use a synchronous progress sink in the integration test so assertions do not
  race asynchronous Progress<T> callbacks.

Tests: 139 pass on net8.0/net10.0 (+20 new, 2 updated to the corrected
directory-entry contract). AOT publish remains warning-free.
@normj

normj commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Thanks for the thorough review. All 16 findings are addressed in 669231d. Summary of how each was handled:

High

  • Reject malformed ECR repository paths — ImageReference.Parse now validates the repository against the OCI name grammar (rejecting //, empty/uppercase components), so a crafted path can no longer override the request authority.
  • Don't follow symlinks during enumeration — layer assembly now rejects symbolic links / reparse points, both at a mapped path and inside a mapped directory, instead of following them.
  • Normalize/validate expanded archive paths — each expanded directory target is now run through NormalizeContainerPath, so a ../backslash source name can't escape the container root.
  • Validate platform against fetched config — FromBaseImageAsync compares the base config's os/architecture to the requested platform and throws on mismatch rather than pushing the wrong platform.
  • Owner-only spill files (built layer and downloaded blob) — both are now created 0600 on Unix via FileStreamOptions.UnixCreateMode.
  • Realm SSRF / DNS rebinding + Disable token-request redirects — reworked at the socket layer: auto-redirect is disabled on the transport, and a ConnectCallback resolves the host, refuses any blocked (loopback/private/link-local) address, and connects to the exact address it validated. Because the check and the connect use the same resolved address, this is robust against DNS rebinding and covers realm requests, the registry, and blob redirects. Blob-download redirects are now followed explicitly with each hop validated.

Medium

  • Strip tag when a digest is present — name:tag@sha256:… now yields the bare repository.
  • Preserve empty descendant directories — recursive mappings enumerate directories as well as files.
  • Parent-directory permissions — synthesized ancestor directory entries are no longer emitted (tar extraction creates parents implicitly), so a layer no longer overlays/resets base-image directory modes like /tmp 1777 or a private 0700 parent.
  • Commit mutations only after spill succeeds — AddLayerAsync records the layer in the image builder only after the spill write completes.
  • Replay without buffering — a 401-retry rewinds a seekable upload body instead of buffering the whole blob into memory.
  • Configurable upload timeout — added EcrPushOptions.UploadTimeout, defaulting to no client-imposed timeout (bounded by the cancellation token).
  • Implicit Content-Length — uses the stream's remaining length (Length - Position), not total length.
  • Await progress before assertions — the integration test now uses a synchronous progress sink so assertions don't race Progress<T> callbacks.

Added regression tests for the repository grammar, tag+digest parsing, symlink rejection, empty-directory preservation, the SSRF address/host checks, and the 0600 spill permissions. Test count is 139 (up from 119) passing on net8.0 and net10.0, and the Native AOT publish is still warning-free.

One note on #11 (parent-dir permissions): two existing LayerTests asserted the old behavior of emitting synthesized ancestor directory entries, which was the bug — I updated them to assert those entries are not emitted while the file entry is still present.

@normj
normj requested a balanced review from Copilot October 27, 2026 18:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

20 open issues

Review effort: Lite

Comment thread Libraries/src/AWS.OCI.Builder/Registry/RegistryClient.cs
Comment thread Libraries/src/AWS.OCI.Builder/ImageReference.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/README.md Outdated
…e-sensitivity

- GetBlobAsync now returns a ResponseOwningStream that disposes the underlying
  HttpResponseMessage and HttpRequestMessage when the stream is disposed, and
  disposes the per-hop request on redirects and on the error path. Previously
  the response/request were leaked on the success path.
- ImageReference host detection compares the 'localhost' literal with
  OrdinalIgnoreCase so an uppercase host (e.g. LOCALHOST/app) is recognized as a
  registry rather than misparsed as a Docker Hub repository.
- Fix README typos (based on, non-AWS).
@normj

normj commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Addressed the three new findings from the latest scan in 5504c507. (The scan also re-listed the 16 earlier findings without a · New tag — those were already fixed in 669231de; the re-list appears to be from the scan timing out before it could observe the fixes.)

High — GetBlobAsync leaked the HTTP response/request. The redirect-following rewrite returned only the content stream, so the HttpResponseMessage (and the per-hop HttpRequestMessage) were never disposed on the success path. GetBlobAsync now returns a ResponseOwningStream wrapper that disposes the inner stream, the response, and the request when the returned stream is disposed (both Dispose and DisposeAsync). Each redirect hop's request is disposed before the next hop, and the success path is wrapped so a failure in EnsureSuccessAsync/ReadAsStreamAsync also disposes. Callers already await using the stream, so no call-site change was needed.

Medium — localhost detection was case-sensitive. The host-vs-repository check compared the localhost literal with StringComparison.Ordinal, so LOCALHOST/app was misparsed as a Docker Hub repository. Changed to OrdinalIgnoreCase, with a regression theory covering localhost/LOCALHOST/LocalHost.

Low — README typos. "change base on" → "based on"; "non AWS context" → "non-AWS context".

Verification: 142 unit tests pass on net8.0 and net10.0 (+3 new), the AWS.OCI.Builder.slnf solution builds with 0 warnings/0 errors, and a Native AOT publish of a harness that references the library is warning-free (the ResponseOwningStream uses no reflection); the native binary parses LOCALHOST/myapp:dev correctly end-to-end.

@normj
normj requested a balanced review from Copilot October 27, 2026 21:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

3 open issues
20 resolved since last review

Review effort: Lite

Comment thread Libraries/src/AWS.OCI.Builder/Images/Layer.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/OciImageBuilder.cs Outdated
Comment thread Libraries/src/AWS.OCI.Builder/Registry/AuthHandshakeHandler.cs
- AuthHandshakeHandler.TryParseChallenge now scans all WWW-Authenticate
  challenges instead of only the first, selecting a usable scheme (preferring
  Bearer with a realm, falling back to Basic). A registry may return multiple
  challenges in any order (RFC 7235); the previous first-only logic could fail
  auth when a usable challenge was not listed first. Added regression tests for
  Bearer-not-first and Basic-fallback.
- Fix Layer class XML doc that claimed synthesized parent directory entries are
  emitted; they are intentionally not (ancestors are created implicitly on
  extraction).
- Fix OciImageBuilder class XML doc example to call PushToEcrAsync with an auth
  token instead of the non-existent PushAsync; correct a stale PushAsync
  reference in an inline comment.
@normj

normj commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Addressed the three new findings from the latest scan in 0fc2534c.

Medium — TryParseChallenge inspected only the first WWW-Authenticate challenge. A registry may legally return multiple challenges in any order (RFC 7235), so a usable Bearer/Basic challenge that wasn't listed first would be missed and authentication would fail. TryParseChallenge now iterates over all challenges and selects a usable scheme — preferring a Bearer challenge that carries a realm, and falling back to the first Basic challenge otherwise — rather than challenges.First(). Added two regression tests: a Negotiate-then-Bearer ordering (full token handshake completes) and a Negotiate-then-Basic ordering (Basic fallback, no token round-trip). I verified both actually guard the fix by temporarily reverting to the first-only logic and confirming exactly those two tests fail.

Medium — Layer XML doc contradicted the implementation. The class remarks said every entry "(including synthesized parent directories)" is stamped, but synthesized ancestor directory entries are intentionally not emitted (they're created implicitly on extraction, and emitting them would overlay base-image directory permissions). Corrected the doc to "every emitted entry".

Medium — OciImageBuilder XML doc example wouldn't compile. The class example called builder.PushAsync(...), but the public API is PushToEcrAsync(targetImage, authorizationToken, ...). Updated the snippet to call PushToEcrAsync with an authorization token, and fixed a stale PushAsync reference in an inline comment.

Verification: 144 unit tests pass on net8.0 and net10.0 (+2 new), the AWS.OCI.Builder.slnf solution builds with 0 errors, and a Native AOT publish of a harness referencing the library is warning-free (the multi-challenge change is plain LINQ/C# with no reflection). The two CA1416/xUnit1031 warnings surfaced by the degraded-mode scan's build are pre-existing in the test project and unrelated to this change.

@normj
normj requested a balanced review from Copilot October 27, 2026 21:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread Libraries/src/AWS.OCI.Builder/Registry/RegistryClient.cs
Comment thread Libraries/src/AWS.OCI.Builder/OciImageBuilder.cs
Comment thread Libraries/src/AWS.OCI.Builder/Registry/AuthHandshakeHandler.cs
…eck, caller auth

- Blob-download redirect hops to a different host/port are marked to skip
  authentication, and AuthHandshakeHandler honors that: no cached credentials
  are attached and a 401 challenge is not answered. Previously a registry could
  redirect a blob GET to a host it controls and harvest credentials via a
  challenge, since the handler authenticated every request on the pipeline.
- Verified blob downloads accept the manifest-declared size and throw a new
  BlobSizeMismatchException when the downloaded length differs, so a wrong
  descriptor size fails early with a clear error instead of an opaque
  Content-Length transport failure during the push. Applied to the base image
  config and both base-layer paths.
- AuthHandshakeHandler respects a caller-supplied Authorization header: the
  request is sent unchanged and any 401 is returned to the caller.
@normj

normj commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Addressed the three new findings from the latest scan in ed26046c. (The scan also re-listed the three findings fixed in 0fc2534c as open; that appears to be the degraded/timed-out run not observing the fix.)

High — blob redirect could leak credentials through AuthHandshakeHandler. Confirmed: redirect hops went through the same pipeline, so a registry could redirect a blob GET to a host it controls, answer 401 with a challenge, and receive credentials (the static credential source answers for any host). The connect-layer SSRF guard doesn't cover this because the target is a public host. GetBlobAsync now marks any redirect hop whose host/port differs from the registry's with an HttpRequestOptions flag, and AuthHandshakeHandler sends flagged requests without attaching cached credentials and without answering a challenge. The comparison is host-and-port so a registry with an explicit port still authenticates its own first hop. Presigned storage URLs carry their own authorization, so legitimate S3 redirects are unaffected. Regression tests cover both the attacker case (the redirect target is contacted once, with no Authorization, and its token endpoint is never called) and the normal storage-redirect case.

Medium — upload Content-Length used the manifest's declared size. Rather than switching to the file length, I made a declared/actual mismatch fail early. A wrong descriptor size would otherwise fail during the PATCH with an opaque transport error (Sent N request content bytes, but Content-Length promised M), and advertising the real length instead would only push the problem into a manifest that references an incorrect size. GetVerifiedBlobBytesAsync and GetVerifiedBlobToFileAsync now take the descriptor's size and, after the digest check, throw a new BlobSizeMismatchException ("the manifest declares a size of X bytes but the blob is Y bytes") on mismatch. This is applied to the base image config and both base-layer paths; with the check in place the declared size passed as Content-Length is guaranteed to equal the bytes uploaded.

Medium — AuthHandshakeHandler overwrote a caller-supplied Authorization. If the outgoing request already has an Authorization header, the handler now sends it unchanged and returns any 401 to the caller instead of applying cached auth or running a handshake. No library code sets this header ahead of the handler, so internal behavior is unchanged.

Verification: 153 unit tests pass on net8.0 and net10.0 (+9 new). For each fix I temporarily reverted it and confirmed its new tests fail, then restored. The AWS.OCI.Builder.slnf solution builds with 0 warnings/0 errors, and a Native AOT publish of a harness referencing the library is warning-free.

@normj
normj requested a balanced review from Copilot October 27, 2026 22:22
@normj
normj requested a balanced review from Copilot and removed request for Copilot October 27, 2026 22:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +597 to +606
string decoded = System.Text.Encoding.UTF8.GetString(decodedBytes);
int separator = decoded.IndexOf(':');
if (separator < 0)
{
throw new ArgumentException(
"The ECR authorization token did not decode to the expected 'username:password' form.",
nameof(authorizationToken));
}

return (decoded[..separator], decoded[(separator + 1)..]);
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri)
{
Version = original.Version,
Comment on lines +522 to +524
byte[] hash = SHA256.HashData(content);
// Convert.ToHexStringLower exists only on net9.0+, so lower-case explicitly for net8.0 compatibility.
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants