April 3, 2026
4m 3s

The EU Cyber Resilience Act is changing the Software Bill of Materials (SBOM) conversation for connected products. Manufacturers placing products with digital elements on the EU market need a defensible way to understand, document, and maintain the software inside their products. The CRA requires manufacturers to generate a machine-readable SBOM covering the software components in products with digital elements, while future implementing acts may further define the required format and elements.
Germany's BSI TR-03183-2 is the clearest technical reference available in the meantime, describing expectations around component identity, versions, licenses, dependency relationships, creator information, and traceable software evidence.
The challenge is that software in an embedded repository is not necessarily the software that ends up in the product. A repository may contain packages pulled in by other builds, support code for multiple hardware targets, unused libraries, and dependencies that never make it through the linker. For an SBOM to describe a shipped device accurately, the important question is not simply what exists in the source tree. It is what actually reached the firmware image.
To see what that looks like in practice, this post walks through the process of generating an SBOM from a real embedded firmware build by running commands with lynkctl v0.3.3.
The example uses RIOT OS at commit 91bc24a, building the gcoap CoAP example for Nordic's nrf52840dk. Every component, version, property, and hash discussed below comes from that build.
I chose RIOT because it is an open-source IoT operating system built with GNU Make and behaves like a real embedded product. The firmware depends on board-specific configuration. The same application produces different software for different MCUs. Some components are source modules, some are fetched packages, and some come from the toolchain. Ultimately, the linker decides what becomes part of the firmware.
It is also public, so you can run every command here yourself.
The example can be reproduced with RIOT's public repository:
git clone https://github.com/RIOT-OS/RIOT
cd RIOT/examples/networking/coap/gcoap
make BOARD=nrf52840dk
For this application, RIOT produces:
bin/nrf52840dk/gcoap_example.elf
bin/nrf52840dk/gcoap_example.map
Building first is important. RIOT's build plan depends on generated files, so the SBOM process needs evidence from a build that has actually occurred for the selected target.
For this test, I generated the SBOM with lynkctl v0.3.3:
lynkctl generate . \
--make-config-vars BOARD=nrf52840dk \
--no-oss-index \
--reproducible --timestamp 2026-08-31T00:00:00Z \
--manufacturer-name "Example Manufacturer" \
--manufacturer-email sbom@example.com \
--evidence \
-o gcoap-nrf52840dk-bsi.cdx.json
The result is an SBOM that generated 10 components, 3 warnings, and 0 errors.
The output uses CycloneDX 1.6. For this walkthrough, content matching was disabled so component identification came from build and package metadata rather than file-content analysis.
The important configuration is the board:
Without it, RIOT falls back to its default native target. The SBOM would then describe a different build.
The generator identified ten components in addition to the firmware itself as the primary component.
c_nano is the newlib-nano build the firmware links against. On Ubuntu, lynkctl resolved it to its owning system package and emitted pkg:deb/ubuntu/libnewlib-arm-none-eabi@4.6.0.20260123-1?arch=all.
Where the build could not prove a version, license, or other property, the field was left unset rather than inferred.
RIOT's package cache in this checkout holds six packages:
$ ls build/pkg
cmsis
kconfiglib
libcoap
littlefs2
mpaland-printf
Tinydtls
But the firmware build actually used only two of them:
cmsis
mpaland-printf
The other four were fetched by other examples in the same tree and contribute nothing to this firmware. RIOT agrees:
$ make BOARD=nrf52840dk info-packages
cmsis
Mpaland-printf
That gap is the reason to build an SBOM from the build rather than from the repository. A tree scan would have reported libcoap and tinydtls as part of a CoAP product, which sounds plausible but is wrong.
An inventory based on what happens to exist on disk can therefore produce a believable but incorrect answer.
An SBOM generated from the build with a tool like lynkctl reads the compile commands and the linker map, so a package that exists on disk but is not included in the image does not appear.
Embedded software also complicates the idea of having a single SBOM for an application.
The same RIOT application, built for an STM32 Nucleo instead, produces a different product. Only the MCU support component changes:
make BOARD=nucleo-f401re
lynkctl generate . --make-config-vars BOARD=nucleo-f401re -o gcoap-nucleo-f401re.cdx.json
The shared application code remains the same, but the hardware-support component changes:
Everything the application shares stays pinned to the same commits. The vendor support code swaps out. If a product ships multiple hardware or firmware variants, each variant needs an SBOM that describes the software actually incorporated into that image rather than one generic inventory.
An SBOM is useful not only as an inventory but also as release evidence.
Two runs of the same generation command produced byte-identical files:
$ sha256sum run1.json run2.json
7bea3b38e714a13f8ce1e3f4c4e63c97b2a2a6d4f07f80b89a955de6db177f37 run1.json
7bea3b38e714a13f8ce1e3f4c4e63c97b2a2a6d4f07f80b89a955de6db177f37 run2.json
In this example, reproducible generation fixes the timestamp, derives the serial number from content rather than a random UUID, and sorts collections consistently.
That makes the SBOM easier to treat like any other release artifact. Teams can compare it against a previous version, detect meaningful dependency changes, and associate it with the binary it describes.
If an unchanged build creates a substantially different SBOM every time it runs, those workflows become much harder.
CycloneDX already provides standard fields for component names, versions, hashes, licenses, PURLs, external references, dependencies, and evidence.
BSI TR-03183-2 also calls for more specific information about the software components being described.
In this example, lynkctl emits those as properties under a bsi: prefix.
The firmware component:
{
"type": "firmware",
"name": "gcoap_example.elf",
"properties": [
{ "name": "bsi:component:filename", "value": "gcoap_example.elf" },
{ "name": "bsi:component:executable", "value": "executable" },
{ "name": "bsi:component:archive", "value": "no archive" },
{ "name": "bsi:component:structured", "value": "structured" }
]
}
A linked static archive classifies differently from the firmware, and lynkctl derives all four fields from the build rather than asking for them:
{
"type": "library",
"name": "c_nano",
"version": "4.6.0.20260123-1",
"purl": "pkg:deb/ubuntu/libnewlib-arm-none-eabi@4.6.0.20260123-1?arch=all",
"properties": [
{ "name": "bsi:component:filename", "value": "libc_nano.a" },
{ "name": "bsi:component:executable", "value": "non-executable" },
{ "name": "bsi:component:archive", "value": "archive" },
{ "name": "bsi:component:structured", "value": "structured" }
]
}
libc_nano.a is an archive, is not executable, and has internal structure, so it reports archive, non-executable, and structured where the firmware reported the opposite on the first two. BSI asks for the concrete filename rather than the component name, which is why bsi:component:filename is libc_nano.a and not c_nano.
BSI asks for component names and versions that identify the component, not the build's private label for it. RIOT calls the Nordic support package nrf5x_nrfx_mdk. The SBOM reports it as nrfx at pkg:github/nordicsemiconductor/nrfx@b0107c49…, because that is the identity vulnerability data is keyed on. The build's own name is kept as a property, so the entry stays traceable to the tree it came from.
At the document level:
{
"metadata": {
"timestamp": "2026-08-31T00:00:00Z",
"manufacturer": {
"name": "Example Manufacturer",
"contact": [ { "email": "sbom@example.com" } ]
},
"properties": [
{ "name": "cdx:reproducible", "value": "true" }
]
}
}
Here are the four classification fields and the values lynkctl emits:
lynkctl derives these from the build where it can, and leaves a field unset rather than guessing. In this SBOM 4 of the 10 components carry all four, and they are exactly the ones the linker pulled in as a concrete file: c, m, gcc, and c_nano. A source tree like RIOT, or a fetched package like printf, has no single file to classify and gets none of them. Those need an override.
For this build, the resulting SBOM covers many of the areas addressed by BSI TR-03183-2:
BSI TR-03183-2 is a useful technical guideline; however, meeting the BSI guidelines does not mean CRA conformity. The Cyber Resilience Act covers far more than SBOM generation, including secure-by-design requirements, vulnerability handling, security updates, technical documentation, reporting, and manufacturer responsibilities across the product lifecycle.
BSI does give manufacturers a more concrete technical target for the SBOM itself.
The practical workflow is straightforward:
For this example, that final flow looks like:
The result can then feed vulnerability-monitoring, product-security, compliance, and incident-response workflows.
An accurate SBOM generated during development but left on a build machine is not much use when a new vulnerability is published months or years after the product ships.
For embedded teams, the better model is to treat the SBOM as part of the product release itself.
Generate it from the build. Generate one for each firmware variant. Preserve it with the binary it describes. Because six months later, when a new CVE arrives, the important question will not be what was sitting in the repository but what actually shipped.