What’s in a Docker image? Nobody knows! Unless, you use an SBOM. What is an SBOM you say? Source Bill of Materials is a file containing all of the resources and tools that were used to build or are included in a software release artifact. SBOMs can be used to identify vulnerable software or even ensure compliance with software licenses. Imagine being able to block vulnerable software using a Kyverno policy.
I wanted to generate a Docker image that contained .net AOT compiled application and also generate an SBOM. Unfortunately, it wasn’t as straight forward as generating it from the output Docker image because it didn’t contain any build packages or any dotnet packages.
Additionally, the build tools have just as much of a role to play in software security as the libraries that end up in the artifact. For example, there have been several supply chain attacks that injected code into a binary or modified release artifacts using stole tokens. An SBOM should contain the build tools too, which simply running Syft won’t give you.
The Problem
This is a pretty standard Dockerfile that the C#/.net application template will generate for you. It uses a Dockerfile multi-stage build to build and publish (which performs the AOT, ahead-of-time compilation), then it copies the binary into a final image. In my case, I’m using the chiseled which contained only 9 packages. dotnet/runtime:10.0 contained slightly more at 98 packages
Starting Dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["LLMProxy/LLMProxy.csproj", "LLMProxy/"]
RUN dotnet restore "LLMProxy/LLMProxy.csproj"
COPY . .
WORKDIR "/src/LLMProxy"
RUN dotnet build "./LLMProxy.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
clang zlib1g-dev
RUN dotnet publish "./PlaidSync.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "LLMProxy.dll"]
|
Example Syft output
Tools like syft are able to scan an image and identify packages, such as debian apt packages, but an AOT compiled .net application is slimmed down.
Running syft on the output of the above Dockerfile gives me:
1
2
3
4
5
6
7
8
9
10
| NAME VERSION TYPE
base-files 13ubuntu10.4 deb
ca-certificates 20240203 deb
gcc-14 14.2.0-4ubuntu2~24.04.1 deb
gcc-14-base 14.2.0-4ubuntu2~24.04.1 deb
libc6 2.39-0ubuntu8.7 deb
libgcc-s1 14.2.0-4ubuntu2~24.04.1 deb
libssl3t64 3.0.13-0ubuntu3.9 deb
libstdc++6 14.2.0-4ubuntu2~24.04.1 deb
openssl 3.0.13-0ubuntu3.9 deb
|
None of my application dependencies are in here. If any of them had a report security vulnerability, I wouldn’t be able to detect it. An AOT compiled application removes all of the indicators that Syft and other scanners need to know what your dependencies look like.
Capturing dependencies during the build
How can we fix this problem?
We have to capture the dependencies during the build stage. The compiler sees all the dependencies during the build target above, but it gets lost. I’m using Forgejo Actions workflows, which is modeled very similarly to GitHub actions workflows
Attempt 1 - Using Syft
My first attempt using Syft, I built the image to the build target, then ran Syft on that output:
1
2
3
| docker build --target=build -t test .
syft docker:test
|
I got 2,322 dotnet packages + 7k other, non-dotnet packages. Do we have success?!
| NAME | VERSION | TYPE |
|---|
| Json.NET | 13.0.3.27908 | dotnet (+2 duplicates) |
| Microsoft.CodeAnalysis.CSharp | 4.13.0 | dotnet |
| Microsoft.CodeAnalysis.CSharp | 5.3.0-2.26064.108 | dotnet |
| Microsoft.CodeAnalysis.CSharp | 5.3.0-2.26153.122 | dotnet (+7 duplicates) |
Unfortunately, not yet. Syft is detecting all the dotnet libraries that exist in the image regardless of whether or not I use them. In addition, it can’t identify what versions I’m actually using and shows multiple versions for the same library.
I need the SBOM to be generated based on what my project actually depends on.
The .csproj file does contain package references as the below shows, however it only includes direct dependencies, no transitive dependencies, and isn’t guaranteed to be exactly the same each time.
1
2
3
4
5
6
7
8
9
10
11
| <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="NSwag.ApiDescription.Client" Version="14.7.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="OpenAI" Version="2.10.0" />
<PackageReference Include="Vecc.YamlDotNet.Analyzers.StaticGenerator" Version="18.0.0" />
<PackageReference Include="YamlDotNet" Version="18.0.0" />
</ItemGroup>
|
Thus, Nuget supports a lock file like most other major package managers. To do so, add the RestorePackagesWithLockFile to your .csproj file and when you restore, you’ll get a packages.lock.json file
1
2
3
4
5
| <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
|
Attempt 2 - Using CycloneDX
I then can use another SBOM generation tool, CycloneDX-dotnet to generate the SBOM file. It actually uses the obj/project.assets.json file generated during build, but a lock file is still smart.
1
2
3
4
5
| dotnet tool install cyclonedx
dotnet tool run dotnet-CycloneDX -- -F Json LLMProxy.csproj --disable-package-restore --set-version "1.0.0" --recursive
jq '.components[] | ."bom-ref"' bom.json -r
|
I get the following:
1
2
3
4
5
6
7
8
9
10
11
12
| pkg:nuget/Microsoft.AspNetCore.OpenApi@10.0.8
pkg:nuget/Microsoft.DotNet.ILCompiler@10.0.7
pkg:nuget/Microsoft.Extensions.ApiDescription.Client@8.0.14
pkg:nuget/Microsoft.NET.ILLink.Tasks@10.0.7
pkg:nuget/Microsoft.OpenApi@2.0.0
pkg:nuget/NSwag.ApiDescription.Client@14.7.1
pkg:nuget/NSwag.MSBuild@14.7.1
pkg:nuget/OpenAI@2.10.0
pkg:nuget/System.ClientModel@1.10.0
pkg:nuget/System.Memory.Data@10.0.3
pkg:nuget/Vecc.YamlDotNet.Analyzers.StaticGenerator@18.0.0
pkg:nuget/YamlDotNet@18.0.0
|
It correctly, contains all of the NuGet dependencies, but is unfortunately missing references for the .net framework itself. I haven’t fixed this problem yet.
Create a Dockerfile
I added a new Docker stage tools that installs CycloneDX and makes it available in the container, but doesn’t run it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["LLMProxy/LLMProxy.csproj", "LLMProxy/"]
COPY ["LLMProxy/packages.lock.json", "LLMProxy/"]
RUN dotnet restore "LLMProxy/LLMProxy.csproj"
COPY . .
WORKDIR "/src/LLMProxy"
RUN dotnet build "./LLMProxy.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM base AS tools
WORKDIR /src/LLMProxy
COPY LLMProxy/dotnet-tools.json .
RUN dotnet tool restore
COPY --link --from=build /src/LLMProxy /src/LLMProxy
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
clang zlib1g-dev
RUN dotnet publish "./PlaidSync.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "LLMProxy.dll"]
|
Running in a workflow
Then, in the workflow, I build the Docker image to the tools target stage mentioned above, and run it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| jobs:
build:
- name: Build tools docker image
id: build_sbom
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
with:
build-args: BUILD_CONFIGURATION=Debug
cache-from: |
type=gha,scope=tools
cache-to: type=gha,scope=tools
context: .
file: ./Dockerfile
push: false
platforms: linux/amd64
target: tools
- name: Generate dotnet SBOM
run: |
set -exo pipefail
CONTAINER_ID=$(docker create ${STEPS_BUILD_SBOM_OUTPUTS_IMAGEID} dotnet tool run dotnet-CycloneDX -o /sbom -F Json LLMProxy.csproj --disable-package-restore --set-version "$APP_VERSION" --recursive)
docker start -a $CONTAINER_ID
docker cp $CONTAINER_ID:/sbom/bom.json ./aot-bom.json
docker rm $CONTAINER_ID
env:
STEPS_BUILD_SBOM_OUTPUTS_IMAGEID: ${{ steps.build_sbom.outputs.imageid }}
APP_VERSION: ${{ steps.version.outputs.version }}
|
Generate the container SBOM
The previous step generated the SBOM for the dotnet binary, now we need to build the final image and generate an SBOM with all the libraries in the
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| - name: Build final docker image
id: build_final
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
with:
cache-from: |
type=gha,scope=tools
type=gha,scope=final
cache-to: type=gha,scope=final
context: .
file: ./Dockerfile
platforms: linux/amd64
target: final
outputs: |
type=image,push=${{ env.SHOULD_PUSH }}
tags: ${{ steps.metadata.outputs.tags }}
- name: Generate Container SBOM
uses: https://github.com/anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with:
image: ${{ steps.build_final.outputs.imageid }}
output-file: container-sbom.json
format: cyclonedx-json
config: ./.github/syft-config.yaml
upload-artifact: false
|
Merging and Converting
Now there’s a dotnet SBOM located at ${{ forge.workspace }}/aot-bom.json. There’s two major formats for SBOMs: CycloneDX and SPDX. I’m sure there’s some advantages of each, but I converted everything to SPDX
1
2
3
4
5
6
7
| - name: Prepare SBOM
run: |
set -ex
wget -nv -O cyclonedx https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.32.0/cyclonedx-linux-x64
chmod +x cyclonedx
./cyclonedx merge --input-files container-sbom.json ./aot-bom.json --output-file merged-sbom.json
./cyclonedx convert --input-file merged-sbom.json --output-format spdxjson --output-file spdx.json
|
Uploading
Our SPDX format SBOM is located in ${{ forge.workspace }}/spdx.json. I upload it as an artifact:
1
2
3
4
5
6
7
| - name: Upload output
uses: https://data.forgejo.org/forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4
with:
path: |
merged-sbom.json
spdx.json
name: sbom
|
Then, attach it to the Docker image. This uses the ORAS CLI which is a tool used to upload arbitrary files to a Docker/OCI compatible registry. It creates a tag named sha256-{a hash} that contains the SBOM.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| - uses: https://github.com/oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2
if: ${{ env.SHOULD_PUSH == 'true' }}
- name: Upload SBOM
if: ${{ env.SHOULD_PUSH == 'true' }}
run: |
set -exo pipefail
echo ${STEPS_METADATA_OUTPUTS_TAGS}
# Iterate through the multi-line tags output from the metadata step
for TAG in ${STEPS_METADATA_OUTPUTS_TAGS}; do
echo "Attaching SBOM to $TAG..."
oras attach "${{ env.DOCKER_REGISTRY }}/${{ env.TARGET_REPO }}@$TAG" \
--artifact-type application/vnd.spdx+json \
--plain-http \
spdx.json:application/json
done
env:
STEPS_METADATA_OUTPUTS_TAGS: ${{ steps.build_final.outputs.imageid }}
|
Now other consumers of your Docker image can download the SBOM and see the contents. Docker’s built-in SBOM attestation feature differs from this in that it attaches the SBOM directly to the tag you use. I’d love to do this instead, but unfortunately, the built-in SBOM generator isn’t able to identify all the resources I can with this approach.
Scanning for vulnerabilities
With a generated SBOM, we can scan for vulnerabilities using Anchore grype. The following step will look for known vulnerabilities and will fail the build if a high vuln is found.
1
2
3
4
5
6
7
8
| - name: Scan project for vulnerabilities
if: ${{ env.SHOULD_PUSH == 'true' }}
uses: https://github.com/anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7
with:
cache-db: true
output-format: table
sbom: spdx.json
severity-cutoff: high
|
Next Steps
Next, I’m going to explore integrating the GitHub Actions workflow steps into the SBOM. Workflow steps seem like they are a weak point in supply chain security with the Trivy vulnerability. Step versions can be identified and attached to the SBOM just like any other package.
I’m also going to explore how Docker performs attestations and signing to see if I can keep the manifests together.
Conclusion
While this post talks primarily about the challenges of generating SBOMs for AOT (ahead-of-time) compiled C#/.net applications due to the resulting OCI image not containing dependency details, this can also apply to other compiled and minified images, like Go or Rust.
I ended up having to create my own SBOMs for two main reasons: C#/.net with publishing did not include dependency versions and Docker SBOM generation used Syft which got confused about .net dependency versions.