Generate an OBOM (Operations Bill of Materials) to capture the libraries, modules and configuration active in production, and correlate it with the SBOM.
The issue opens pre-filled with this page title and URL.
An OBOM (Operations Bill of Materials) is the inventory of what really
runs: dynamically loaded libraries, modules enabled depending on the
environment, active configuration, connected services. It complements the
SBOM (Software Bill of Materials, the machine-readable inventory of what
was assembled and shipped) without replacing it. This lesson is for a DevOps
engineer who already knows how to produce an SBOM: it shows why the build is
not enough, how to collect the runtime state on Linux, in containers and
in Kubernetes, and how to correlate the two inventories to spot "ghost"
components.
The typical scenario is a critical CVE (a publicly catalogued
vulnerability) landing on a library. The SBOM says "we don't use this lib",
yet in production a dynamically loaded plugin uses it. The SBOM described
what had been assembled, not the observed operational state.
What you will learn
Explain the gap between a build-time SBOM and the state in production.
Collect the runtime information of a Linux process and of a container,
with /proc, lsof and language introspection.
Generate an inventory with cdxgen and place the limits of its OBOM
support.
Correlate SBOM and OBOM with a comparison script.
Recognise what an OBOM does not guarantee, and its place among the
inventories.
Anchor the SBOM/OBOM correlation in the Socle framework: no requirement
names the OBOM, but the SBOM completeness check (no ghost component) of
SOCLE-PKG-SBM-3 (in French)
is where it serves as evidence.
What are the prerequisites?
You need to know the SBOM and the
family of inventories described in
xBOM: beyond the SBOM,
and be able to read the output of a Linux command. The examples touch
containers and Kubernetes; the SBOM generator
Syft (in French) serves as a point of
comparison.
SBOM vs OBOM: the build/runtime gap
The two inventories are not produced at the same moment or from the same source, and that is exactly where the gap comes from. The SBOM reads declaration files at the moment the software is assembled; the OBOM queries running processes. Between the two, there was a deployment, environment variables, enabled plugins and system libraries provided by the host.
What the SBOM captures... and what it misses
Read this table row by row as a list of questions that an SBOM alone cannot answer. The right-hand column states each time what extra information observing the runtime brings.
Aspect
SBOM (build-time)
OBOM (runtime)
Moment
At compilation/packaging
In production
Source
Manifests (package.json, go.mod, etc.)
Running processes
Dependencies
Declared and transitive
Actually loaded
Configuration
Default values
Active configuration
Optional modules
Listed as optional
Enabled or not
Plugins
Potential
Actually loaded
Swipe to see more
A concrete example: a Python application
The code below picks its database driver from an environment variable and loads its plugins by walking a folder. No static analysis tool (one that reads the code without running it) can guess what will really be imported: the decision is taken at startup, on the production machine.
What the SBOM sees (inventory produced from the build or from the packaged environment):
flask==3.0.0
databases-sqlite==0.9.0
databases-postgres==0.9.0 # extras dependency
plugin-analytics==1.2.0 # available plugin
plugin-cache==2.0.0 # available plugin
Note: this example is simplified for teaching purposes. In reality, the sources of divergence are more varied (wheels, entry points, runtime mounts, etc.).
What runs in production (real runtime):
flask==3.0.0
databases-postgres==0.9.0 # ← This one is enabled
plugin-cache==2.0.0 # ← Only this plugin is enabled
psycopg2==2.9.9 # ← Transitive dependency of postgres
libpq.so.5 # ← C library loaded by psycopg2
The SBOM lists databases-sqlite (the default) but production uses databases-postgres. If a CVE hits psycopg2, a build-time SBOM may not be enough to say whether this dependency is really active in this environment. The OBOM then helps tell theoretical presence from actual use.
What the OBOM captures
An OBOM is not limited to a list of packages: it describes a working state. The useful information falls into five families, some of which are obtained with one command while others need extra enrichment work. Knowing which one is easy to collect determines the order in which you will build your inventory.
The 5 categories of runtime information
The diagram groups these families and gives, for each, the type of source to query.
The right-hand column is the most useful: it indicates the type of incident each category helps settle, from a CVE in a C library to a lowered TLS setting in production.
Category
Examples
Why it matters
Loaded libraries
libssl.so.3, libc.so.6, libpq.so.5
CVEs in C/system libs
Enabled modules
WordPress plugins, Python extensions
Active code not visible at build
Active configuration
TLS_MIN_VERSION=1.2, DEBUG=false
Effective security settings
Connected services
postgres://db:5432, redis://cache:6379
Network dependencies
System state
Kernel 5.15, glibc 2.35
System vulnerabilities
Swipe to see more
Collecting runtime information on Linux
Linux exposes the state of a process in the /proc pseudo-filesystem (a virtual directory the kernel fills in on the fly), which lets you query it without instrumenting the application. The four methods that follow go from the most generic to the most specific: shared libraries, Python modules, Node.js packages, then environment variables. None is enough on its own, they complement each other. All of them require at least the right to read/proc for the account that owns the process.
Method 1: shared libraries through /proc
The most direct method to list the libraries loaded by a process:
On a python:3.12-slimcontainer (python3 process as PID 1, read with docker exec … cat /proc/1/maps), the same chain listed for example /usr/lib/x86_64-linux-gnu/libssl.so.3, /usr/lib/x86_64-linux-gnu/libcrypto.so.3 and /usr/local/lib/python3.12/lib-dynload/_ssl.cpython-312-x86_64-linux-gnu.so: paths vary depending on the image. These are .sofile names, not package names: the section
on correlation shows how to match them to an SBOM.
These commands depend on pgrep, which returns one PID per process carrying the requested name. With two python3 processes, the substitution produces two numbers and cat /proc/<pid1> <pid2>/maps fails: add -o to keep the oldest (pgrep -o -x python3), or pass the PID by hand. Another trap: lsof -p PID -i does an OR between the two filters and lists the connections of all processes on the machine, hence the -a in the last command. Finally, pgrep and lsof are not installed in the Python slim images.
Method 2: loaded Python modules
For Python applications, list the modules actually imported:
runtime_modules.py
import sys
import json
defget_loaded_modules():
"""Return the loaded Python modules with their path."""
modules = {}
for name, module in sys.modules.items():
ifhasattr(module,'__file__') and module.__file__:
modules[name] = {
"path": module.__file__,
"version": getattr(module,'__version__',None)
}
return modules
if __name__ =="__main__":
print(json.dumps(get_loaded_modules(),indent=2))
Example run through kubectl exec:
Terminal window
# Via a shell in the container
kubectlexec-itmyapp-pod--python-c"
import sys, json
mods = {k: getattr(v, '__file__', 'builtin') for k, v in sys.modules.items()}
print(json.dumps(mods, indent=2))
"
Method 3: loaded Node.js packages
For Node.js applications:
runtime-deps.js
// Run on demand in the container (not through a debug endpoint in production)
functiongetLoadedModules() {
const modules = {};
for (const [path,mod] ofObject.entries(require.cache)) {
Environment variables often define runtime behaviour.
The commands below keep only the name of each variable
(cut -d= -f1 cuts at the = sign), never its value, which may be a
secret:
In a container, the same logic applies but from the outside: docker exec opens a process in the container's namespaces (its isolated view of processes and files) and gives access to its /proc. Two angles are possible and do not give the same result: query the active processes, or export the filesystem to analyse it with a composition tool. The second is more complete on the installed packages, the first is the only one that sees what is really loaded.
Scanning a running container
These commands assume that the queried utilities exist in the image; on a distroless image (a minimal image with no shell or system tools) or one built FROM scratch, neither ps nor netstat will be present.
Terminal window
# List the processes in a container
dockerexecmycontainerpsaux
# Libraries loaded by the main process (PID 1 in the container)
Syft does not read its input from a pipe: docker export … | syft /dev/stdin fails (unable to get absolute path for analysis path="/dev/stdin"), because the path points to a pipe and not to a file. The intermediate archiverootfs.tar is therefore necessary. The syft file:rootfs.tar command unpacks it and catalogues the system and Python packages, then writes a CycloneDX document (an SBOM format maintained by OWASP) usable with the comparison script further down. Delete rootfs.tar afterwards: it contains the whole filesystem of the container, secrets included.
OBOM collection script for a Docker container
This script assembles the two previous sources, system packages and loaded libraries, into a minimal CycloneDX 1.6 document. It is deliberately simple and serves as a starting point: the obom:collection-method property it writes in the metadata lets you tell this document apart from a classic SBOM.
collect-obom.sh
#!/bin/bash
# Partial collection prototype for a container
# Useful for demonstration or experimentation, insufficient as a production OBOM
# Close the JSON (remove the last comma and close)
sed-i'$ s/,$//'"$OUTPUT"
echo" ]">>"$OUTPUT"
echo"}">>"$OUTPUT"
echo"OBOM written to: $OUTPUT"
Generating an OBOM with cdxgen
cdxgen is a CycloneDX generator tied to the OWASP/CycloneDX ecosystem. It offers useful capabilities to move toward the OBOM, notably to inventory a running Linux host, but its support must be validated depending on the context. Not all its commands produce an OBOM: the paragraph after the three examples states which ones do.
Installation
cdxgen is distributed as a global npm package and therefore needs a Node.js installation on the machine that runs the collection. The version given (13.4.0) is the one published on the npm registry at the time of writing; check it before use.
Terminal window
npminstall-g@cdxgen/cdxgen@13.4.0
Generating an OBOM
The --deep option pushes the analysis beyond dependency manifests, but it is not enough to make an OBOM: everything depends on the target (-t) you inventory, project, image or host.
Terminal window
# Node.js project: deeper dependency analysis (still an SBOM)
cdxgen-tnodejs-osbom-projet.json--deep
# Docker image: what is installed, not what is loaded (in-depth SBOM)
# Running host (type os, equivalent to the obom command): this is an OBOM
cdxgen-tos-oobom.json--deep
These three commands are not equal with regard to the thesis of this page, which defines the OBOM as the state observed while running and not as the list of what is installed. Only -t os on a running host comes close: the cdxgen documentation presents it as the way to get visibility into processes, network, services and startup (runtime state, for example listening ports), in addition to the installed software.
Conversely, -t docker on an image analyses frozen content: no container is running, so nothing is "loaded". You get what is installed in the image, that is, an in-depth SBOM. As for -t nodejs --deep, it looks for dependencies more thoroughly but remains a project analysis. Even for -t os, the cdxgen documentation does not state, at the time of writing (October 2026), whether kernel modules or the libraries loaded by each process are covered: compare the result with /proc/<pid>/maps before using it as evidence.
Kubernetes: collecting the OBOM of all pods
At cluster scale, collection runs into two constraints: kubectl exec must be allowed by the RBAC (role-based access control) of the account used, and each container of a pod must be processed separately. Also plan for pods without a shell: the command will fail silently and produce an empty file, which must be read as an absence of data and not as an absence of components.
Cluster-wide collection script
The script walks all the pods of a namespace and writes three files per container: packages, libraries and network connections.
These tools have different roles, do not confuse them. eBPF (extended Berkeley Packet Filter) is a Linux kernel mechanism that lets an agent observe system calls and processes without modifying the application; the table below mentions it for Sysdig.
Tool
Category
Function
Trivy
Composition scan
Analyses image/filesystem/k8s, not a fine-grained runtime inventory
Collecting the two inventories only matters if you compare them. Three gaps deserve investigation: a component present at runtime but absent from the SBOM, a version that differs between the two documents, and a component declared but never loaded. The first case is the most serious: it signals code that runs without having gone through your build chain.
Correlation workflow
The diagram summarises the sequence, from the production of the two documents to the identification of "ghost" components.
SBOM and OBOM correlation: the SBOM declares flask and redis; at runtime, the OBOM shows flask loaded, redis absent, and psycopg2 and libpq.so.5 loaded without appearing in the SBOM; the unified inventory sorts components into SBOM only (unused), OBOM only (to investigate) or both (active dependency).Unified inventorySBOM (build time)OBOM (runtime)SBOM onlyunusedOBOM onlyto investigateBothactive dependencyflask==3.0.0redis==5.0.0(no psycopg2)flask==3.0.0: presentredis: not loadedpsycopg2==2.9.9: missing from SBOMlibpq.so.5: missing from SBOMcorrelation
The script expects two CycloneDX documents: the SBOM from Syft or cdxgen on one side, and on the other an OBOM produced by collect-obom.sh or by cdxgen -t os run on the running host (a cdxgen -t docker on an image gives an SBOM: obom_only would stay empty by construction). The text files of the cluster script are not suitable as they are, they are not CycloneDX. It sorts components into four sets: present on both sides, specific to the SBOM, specific to the OBOM and diverging versions. The obom_only category is the one that must trigger an analysis.
A reading trap: the OBOM lists .sofiles (libssl.so.3) where the SBOM lists packages (openssl). The script therefore applies a
rough normalisation (removing the lib prefix and the .so.N
suffix), which matches libpq.so.5 with pq but not with libpq5, nor libssl
with openssl. Expect some noise in obom_only: sort it before
concluding there is a ghost component, or attach each file to its package with
dpkg -S <path> (Debian) or rpm -qf <path> (RHEL).
#!/usr/bin/env python3
"""compare-sbom-obom.py - Compare SBOM and OBOM to detect gaps."""
import json
import re
import sys
from collections import defaultdict
defnormalise(name):
"""Match a .so file name with a package name (heuristic)."""
name = name.lower().rsplit("/",1)[-1]
name = re.sub(r"\.so(\.[0-9]+)*$","", name)
name = re.sub(r"^lib","", name)
return name.replace("_","-")
defload_bom(path):
"""Load a CycloneDX BOM and extract the components."""
withopen(path) as f:
bom = json.load(f)
components = {}
for comp in bom.get("components",[]):
name =normalise(comp.get("name",""))
version = comp.get("version","unknown")
components[name] = version
return components
defcompare(sbom_path, obom_path):
"""Compare SBOM and OBOM, return the gaps."""
sbom =load_bom(sbom_path)
obom =load_bom(obom_path)
sbom_names =set(sbom.keys())
obom_names =set(obom.keys())
results = {
"sbom_only": sorted(sbom_names - obom_names), # Declared, not loaded
"obom_only": sorted(obom_names - sbom_names), # Loaded, not declared
"both": sorted(sbom_names & obom_names), # Match
"version_mismatch": []# Different versions
}
# Check the versions
for name in results["both"]:
# A .so library has no version in the OBOM: ignore it
if obom[name] !="unknown"and sbom[name] != obom[name]:
GUAC (Graph for Understanding Artifact Composition; guide in French) can ingest CycloneDX and SPDX documents (SPDX being the other standard SBOM format) as well as other supply chain metadata, which makes it a good candidate to correlate SBOMs, attestations, vulnerabilities and, potentially, operational inventories modelled in a compatible way. The real depth of this correlation depends, however, on the quality and structure of the ingested documents.
Ingesting SBOM and OBOM into GUAC
guacone collect files accepts both documents without distinguishing their nature: it is you who give meaning to the whole by ingesting both, then querying the graph.
Terminal window
# Ingest the SBOM (build-time)
guaconecollectfilessbom.json
# Ingest the OBOM (runtime)
guaconecollectfilesobom.json
# GUAC can now answer queries such as:
# "Which components are in production but not in the SBOM?"
# "Does this container use a lib vulnerable to this CVE?"
OBOM good practices
An OBOM is a snapshot: its value depends directly on its freshness and on the discipline with which it is produced. Two decisions structure the approach: how often to collect, and which data to forbid yourself from capturing. The second is not negotiable, an inventory that contains secrets becomes an asset to protect itself.
Collection frequency
The most useful trigger is not the calendar but the deployment: that is when the runtime state really changes.
Environment
Frequency
Reason
Production
Daily or after each deployment
Fast drift detection
Staging
At each deployment
Validation before prod
Dev/Test
Optional
Less critical
Swipe to see more
What to collect (and not to collect)
The rule fits in one sentence: collect identifiers, never values. The name of an environment variable documents the configuration, its value may be a password.
To collect
Not to collect
Package names
Values of secrets
Versions
User data
Library paths
Content of sensitive config files
Env variable names
Values of secret env variables
Network connections (hosts)
Credentials in connections
Swipe to see more
Recommended automation
Three triggers cover most needs and are set up with standard Kubernetes objects: a Job after deployment, a daily CronJob and a manual collection in response to an incident.
At each deployment
Generate an OBOM snapshot after the application starts. The cluster
script relies on kubectl exec: it runs in a Job whose
ServiceAccount has the create right on pods/exec (collect-obom.sh,
based on docker exec, does not work from a pod). Files written to
/tmp/obom disappear with the pod: mount a volume or send them to storage
before the Job ends.
Scheduled collection to detect drift (enabled plugins, updated libs):
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-obom
spec:
schedule: "0 2 * * *"# 2 a.m.
jobTemplate:
# ...
On a security incident
Immediate collection to answer "do we use this vulnerable lib?"
What an OBOM does not solve
The OBOM is a valuable complement, but you should not replace one myth ("the SBOM is enough") with another ("the OBOM settles the runtime").
What the OBOM does not guarantee
These six limits are structural: no improvement in tooling lifts them, because observing a system at a given moment says nothing about its origin or its future.
Limitation
Explanation
Integrity of the build chain
The OBOM observes, it does not prove origin
Absence of future drift
Snapshot at a time T, not continuous monitoring
Complete coverage
Depends on the collection method and privileges
Exploitability of a CVE
Presence ≠ exploitable vulnerability (that is the role of VEX, a document stating whether a product is really affected)
Compliance on its own
One element among others in a security posture
Automatic detection of everything
Highly dynamic plugins/behaviours may escape it
Swipe to see more
The OBOM complements, it does not replace
The OBOM fits into a whole:
SBOM: what was assembled
Attestations: proof of provenance and integrity
VEX: exploitability of vulnerabilities
Runtime telemetry: continuous observation
OBOM: operational state observed at a time T
Maturity levels
Place yourself on this scale to plan your progression:
Level 1, Foundations
The starting point: you know what the shipped image contains, but nothing of what happens after it starts.
Final image SBOM
Vulnerability scan
OS package inventory
Level 2, Partial observation
You take runtime snapshots and compare them by hand: enough to answer a one-off question, too slow for a large incident.
Runtime snapshot (loaded libs, env variables)
Manual SBOM / snapshot comparison
Alerts on major gaps
Level 3, Correlation
Introspection goes down to the language modules and the comparison is automated, with a history that lets you date a drift.
Language introspection (Python/Node/Java)
Automated multi-source correlation
Vulnerability enrichment
Historised storage
Level 4, Driven decisions
The inventory feeds a queryable graph and risk decisions are taken from its answers, without prior manual collection. This level cannot be built before the previous one.
Structured and normalised OBOM
Continuous correlation (GUAC or equivalent)
Queryable knowledge graph
Risk decision built into the workflow
Key points
Six ideas to keep:
SBOM and OBOM are complementary, the SBOM describes what was assembled, the OBOM observes the real operational state
Several collection methods: /proc/<pid>/maps, language introspection, filesystem scan, each with its limits
The SBOM/OBOM correlation reveals the gaps, "ghost" components absent from the SBOM but active in production
The OBOM is not magic, it is a partial snapshot, not continuous monitoring nor proof of integrity
Automate the collection, after each deployment and daily, without ever collecting secrets
Expected evidence (SOCLE-PKG-SBM-3): a completeness check of the SBOM, here the SBOM/OBOM correlation report whose obom_only components have been sorted and explained
Knowledge check
Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
6 questions
6 min
70% to pass
How it works
The timer starts when you click Start
Multiple choice, true/false and short answer questions
You can move between questions
Detailed results are shown at the end
Starts the quiz and the timer
⏱️--:--
0 / 6
Review
(0/0)
--:--
0%
0Correct
0To review
0:00Time
-Level
Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.