Cryptographic asset discovery & CBOM (ADR-241)
A complete guide to gadriel's cryptographic-asset discovery feature:
finding keys, certificates, cryptographic algorithm usage, protocol/
config settings, and crypto-library dependencies across a codebase,
and emitting the result as a CycloneDX CBOM (Cryptography Bill of
Materials) — with post-quantum migration tiering on top.
See docs/adr/ADR-241-cryptographic-asset-discovery-and-cyclonedx-cbom-2026-09.md
for the full design record — §14/§15 specifically for the newest
capabilities covered below (real JKS/PKCS#12 parsing, Maven/Gradle,
CI/CD signing, declared-inventory reconciliation, container
build-context discovery, key-to-certificate linking, git-history
exposure, hardcoded literal key material, managed-key references).
This guide is the "how do I actually run this" companion to that ADR.
Why this exists
A Software Bill of Materials tells you what libraries you depend on. It does not tell you what cryptography you depend on — and cryptographic inventory is its own, different problem: the same library can contain both quantum-safe and quantum-vulnerable algorithms, and the riskiest crypto in a codebase is often hand-rolled source code or an infrastructure config setting, not a dependency at all. A CBOM closes that gap: a structured, standard (CycloneDX) inventory of every cryptographic asset a tool can find, with enough context attached to tell you not just "you have RSA-2048" but "here is which RSA-2048 key should be migrated first, and why."
Quick start
# Standalone: scan a project and emit a CBOM, nothing else.gadriel code cbom /path/to/project# Also write the 36-column CSV register (ADR-241 D-17):gadriel code cbom /path/to/project --csv# Include keys/certs that only exist in git history (rotated but still# recoverable — opt-in, costs real time on a large repo; see below):gadriel code cbom /path/to/project --csv --git-history# Reconcile against an external declared inventory (CMDB/cert-management# export) to get real Inventory Status / Shadow Crypto Flag columns:gadriel code cbom /path/to/project --csv --declared declared-inventory.csv# Integrated: run crypto discovery as part of a normal scan, with# crypto policy findings merged into the regular findings output.gadriel scan --crypto /path/to/projectgadriel scan --crypto --git-history /path/to/project # same flag --git-history already has for the secrets sweep# Then render the HTML report (the CBOM tab needs this step):gadriel code report --format html# open .security/reports/cbom.html
The two entry points
gadriel code cbom | gadriel scan --crypto | |
|---|---|---|
| Purpose | Standalone crypto-only inventory | Crypto discovery riding alongside a normal security scan |
| Crypto policy findings | Written only to the sidecar .security/crypto-findings.json | Written to the sidecar and merged into .security/findings.json (same dedup/aggregation pass every other scanner's findings go through) |
| CSV register | Opt-in via --csv | Always written |
| A2 source-API arm | Runs its own pass | Rides free on whatever SAST pass the scan already ran — contributes nothing only if SAST itself is excluded by a pillar filter (e.g. gadriel scan secrets --crypto) |
--staged/--diff | N/A | Material/protocol/library discovery always scans the full tree regardless of these flags (same precedent as SBOM/SCA emission); only the source-API arm is diff-scoped when SAST itself is |
| When to use | CI jobs or ad-hoc runs that only care about crypto | Normal day-to-day scanning, once you want crypto findings showing up alongside everything else |
Both entry points call the exact same underlying discovery/scoring code — they differ only in what gets written where, not in what gets found.
Command reference
gadriel code cbom [OPTIONS] [PATH]
| Flag | Default | Meaning |
|---|---|---|
[PATH] | current directory | Project root to scan |
--spec <1.7|1.6> | 1.7 | CycloneDX spec version. 1.7 has richer cryptoProperties; 1.6 is a lossy downgrade for CBOMkit/sonar-cryptography interop |
--scope <all|prod> | all | all inventories everything and tags what it is (vendored/example/test/prod); prod filters to only Scope::Prod-tagged assets |
--csv | off | Also write .security/cbom-register.csv |
--declared <PATH> | none | Reconcile discovered assets against an external declared-inventory CSV. Populates the CSV register's real Inventory Status / Shadow Crypto Flag columns instead of the universal Discovered only default. See "Declared-inventory reconciliation" below |
--git-history | off | Also sweep every reachable git commit for crypto material that doesn't exist in the current working tree — a key rotated out but still recoverable from history. Real, opt-in cost (see below); hard-fails on a shallow clone the same way the secrets sweep does |
--json | off | Output as JSON instead of the human-readable summary |
-q/--quiet, -v/--verbose, --no-color | — | Standard output controls |
gadriel scan --crypto [OPTIONS] [PATH]
Adds crypto discovery to a normal gadriel scan invocation. Takes no
crypto-specific flags of its own beyond --crypto itself — it
inherits the surrounding scan's pillar filters, --staged/--diff,
etc., with the scope caveat above. --git-history is not a
separate crypto flag here: gadriel scan already has --git-history
for the secrets sweep, and passing --crypto --git-history together
rides that same flag for the crypto-material sweep too — no second
flag to remember. (--declared has no scan equivalent; it's
specific to the standalone code cbom command.)
gadriel code report --format html
Renders the HTML report, including the CBOM tab, from whatever
.security/cbom*.json files the most recent code cbom or
scan --crypto run produced. Also accepts --format values
markdown/pdf/combinations — see gadriel code report --help for
the full flag set (compliance-report generation shares this command).
What gets written
| File | From | Contents |
|---|---|---|
.security/cbom.cyclonedx.json | both entry points | The CycloneDX CBOM document itself — every discovered asset as a component, with gadriel:crypto:* properties carrying scope/arm/resolution/tier |
.security/cbom-summary.json | both | D-3 population accounting: considered / emitted / parse-failures, overall and per discovery arm |
.security/crypto-findings.json | both | D-6 policy findings (sidecar) — read directly by the HTML report's CBOM tab |
.security/cbom-register.csv | --csv (cbom) / always (scan --crypto) | The CBOM Reference Model workbook's 36-column register layout (D-17); columns this tool cannot derive are left blank, never guessed |
.security/findings.json | scan --crypto only | Crypto policy findings merged in alongside every other pillar's findings |
.security/reports/cbom.html | code report --format html | The rendered CBOM tab — see "Reading the HTML report" below |
The four discovery arms
| Arm | What it looks for | Where |
|---|---|---|
| A1 — Material | Key, certificate, and keystore files — every PEM label, OpenSSH format, JWK, keystore magic bytes, CSR | Any file in the tree, content-sniffed (not filtered by extension) |
| A2 — Source-API | Cryptographic algorithm calls in application code (Java, Python, Go, JavaScript, TypeScript) | Rides on the SAST pass |
| A3 — Protocol/config | TLS/SSH/KMS settings in infrastructure config | nginx, Apache, HAProxy, sshd_config, Terraform (AWS/GCP/Azure) |
| A4 — Library | Crypto libraries among dependencies | Cargo.lock, package-lock.json, yarn.lock, requirements.txt, poetry.lock, go.sum |
A single algorithm found via multiple arms (e.g. an RSA-2048 key file
and a Terraform KMS spec referencing RSA_2048) collapses into
one CycloneDX component with multiple recorded occurrences — the
component identity (bom_ref) is the algorithm name, content-addressed,
not the file path.
A1 now opens JKS and PKCS#12 keystores, not just detects them
A Java KeyStore or PKCS#12 file (.jks/.p12/.pfx) is parsed
entry-by-entry: certificates inside resolve to real Certificate
components the same as a loose .crt file would, and private key
entries are inventoried as detected-but-encrypted (JKS's own
encryption scheme, or a PKCS#12 shrouded key bag) — never decrypted,
consistent with how every other encrypted-key format in this tool is
handled.
A4 covers Java/Kotlin now too
Alongside the original six lockfile formats (Cargo.lock,
package-lock.json, yarn.lock, requirements.txt, poetry.lock,
go.sum), the library arm now also reads Maven's pom.xml and
Gradle's gradle.lockfile, classifying BouncyCastle, Google Tink,
Conscrypt, Jasypt, Nimbus JOSE+JWT, and Apache Santuario the same way
it already classified ring/openssl/cryptography for other
ecosystems.
Additional discovery capabilities (beyond the four core arms)
These aren't part of the A1–A4 funnel — they're separate, purpose-built detectors that feed the same CBOM, each addressing a gap the four arms can't reach by design.
| Capability | What it finds | Notes |
|---|---|---|
| C4 — CI/CD signing | codesign/cosign/jarsigner/signtool/notation/gpg --sign invocations in GitHub Actions, GitLab CI, and Jenkinsfile pipelines | Always informational. These assets can never produce a gating policy finding — not "low severity," structurally incapable of it — so turning this on never breaks a CI gate |
| Container build-context | A Dockerfile COPY/ADD instruction whose source resolves to a real key/cert file in the repo — i.e. a credential about to be baked into an image | Scoped to repo-reachable build contexts only. Does not pull or scan remote registry images, --from=<stage> copies, or exec-form COPY ["a","b"] |
| Private-key ↔ certificate linking | Matches a standalone private key to its certificate across separate files (the common server.key + server.crt pattern) via SPKI fingerprint — not just the existing cert-to-issuer-cert chain linking | RSA and EC only (Ed25519/DSA need a cheap public-key derivation this tool doesn't have yet). Never attempted for an encrypted key — there's no key to derive from |
| Git-history exposure | Keys/certs that exist only in git history, not the current working tree | --git-history on either entry point (see above). Real cost: scales with repo history size, correctly opt-in-only |
| Hardcoded literal key/IV material | A byte array, hex string, or base64 literal reaching a key/IV argument slot directly at the call site — SecretKeySpec/IvParameterSpec (Java), AES.new (Python), aes.NewCipher/cipher.NewCBC{En,De}crypter (Go), createCipheriv/createDecipheriv (JS/TS) | A variable or function-call result at the same slot is correctly not flagged — only an inline literal is |
| Managed-key vendor references | A code-side reference to an externally-managed key: an AWS KMS ARN or SDK call, a HashiCorp Vault path, an Azure Key Vault URI, a GCP KMS resource name | The key material itself is never present (by definition — it's managed elsewhere); this surfaces that something in the codebase depends on an external key, which is new, partial visibility into otherwise code-invisible infrastructure |
Policy findings (D-6)
Every scan also evaluates discovered assets against a fixed policy
registry, producing findings under the CRYPTO scan code
(CODE-W4-CRYPTO-2xx):
- Classically-weak algorithm (MD5, SHA-1, DES, 3DES, RC4, RC2, ECB mode, …)
- Quantum-vulnerable algorithm (RSA, DSA, (EC)DH, ECDSA)
- Expiring or expired certificate
- Unencrypted private key at rest
Priority tiers (D-16) and the context overlay (D-14)
Not every quantum-vulnerable algorithm is equally urgent to migrate. By default, every quantum-vulnerable asset lands in one of these code-derived buckets:
- Tier 0 — Fix now: classically weak today, independent of any quantum computer
- Monitor: quantum-vulnerable, migration path exists, not flagged urgent by default heuristics
- Unrated — attest: quantum-vulnerable, insufficient code-derived signal to rank further
- Quantum-vulnerable (unscored): quantum-vulnerable, no business context available for this specific asset
That last bucket is the default for everything — real migration prioritization needs business context no static scan can derive on its own (how sensitive is the protected data, how soon must this rotate anyway, what's the regulatory exposure, how hard will migrating this specific asset actually be). Supplying that context is what turns an unscored asset into a ranked Tier 1 / 2 / 3.
Setting up .gadriel/crypto-overlay.yaml
Create this file at your project root (see
docs/examples/crypto-overlay.example.yaml for a fully-commented
template):
assets:"crypto/algorithm/RSA-2048":sensitivity: 5 # 1-5: how damaging would exposure be?lifecycle_pressure: 4 # 1-5: how soon must this rotate anyway?regulatory_exposure: 5 # 1-5: compliance/audit exposure if brokenmigration_complexity: 4 # 1-5: how hard to migrate this specific asset?protection_horizon_years: 10 # how long must this data stay protected?migration_time_years: 3 # how long would migrating this realistically take?business_service: "payments-api" # attribution only, doesn't affect scoringsystem_owner: "team-platform-security"
The key is the asset's bom_ref — find it in the CBOM JSON or the
HTML report's Register table (each row shows its own bom_ref).
All four of sensitivity, lifecycle_pressure,
regulatory_exposure, and migration_complexity must be present for
scoring to activate. A partial entry is deliberately left unscored —
nothing is ever guessed.
The formula (a plain, auditable equal-weighted average):
weighted_score = (sensitivity + lifecycle_pressure + regulatory_exposure + migration_complexity) / 4.0weighted_score >= 4.0 -> Tier 1 (highest priority)weighted_score >= 2.5 and < 4.0 -> Tier 2weighted_score < 2.5 -> Tier 3 (lowest priority among scored assets)
Mosca exposure is a separate, binary flag computed alongside the tier:
mosca_exposed = migration_time_years > protection_horizon_years
If migrating would take longer than the data needs to stay protected for, the asset is already past the point where waiting is safe — this can be true for a Tier 3 asset and false for a Tier 1 one; they answer different questions ("how much should we care, relative to other assets" vs. "is there still runway left to migrate before it's too late"). See the ADR's §12 D-14/D-16 for Mosca's theorem background.
Declared-inventory reconciliation (D-17)
Some crypto assets exist but are never code-discoverable at all — a
printer's TLS cert, a BMC, BitLocker managed via Intune, an HSM-resident
key, third-party SaaS crypto. The CBOM can't see these by scanning your
repo, but you may already track them somewhere else (a CMDB, a
cert-management platform's export). --declared reconciles the two:
gadriel code cbom --csv --declared declared-inventory.csv
The declared-inventory file is a small CSV, columns in any order, only
name required:
name,bom_ref,fingerprint,locationPayments TLS terminator cert,,a1b2c3d4e5f6...,lb-prod-01Printer/MFP fleet certs,,,HSM-resident signing key,crypto/key/private-key/sha256:deadbeef,,
Matching precedence, per declared row: exact bom_ref → certificate
fingerprint/serial → fuzzy (name, location). Every asset ends up in
exactly one of three states:
- Declared — found in both the scan and the declared inventory (matched)
- Discovered only — found by the scan, absent from the declared inventory. This is the Shadow Crypto Flag signal — crypto that exists but isn't tracked/governed anywhere
- Declared — not observed — in the declared inventory, but the scan found nothing matching. Could mean it's genuinely not code-discoverable (expected, not a problem) or that the declared entry is stale
Omitting --declared changes nothing about today's output — every
asset defaults to Discovered only, same as before this existed.
Not built yet: the reconciliation result only reaches the CSV
register today, not the HTML report's Register table — that would need
persisting it onto the CBOM document itself as a new property. Read
.security/cbom-register.csv's Inventory Status/Shadow Crypto Flag
columns, or the CLI's own summary line, for now.
Reading the HTML report
Run gadriel code report --format html after any code cbom or
scan --crypto run, then open .security/reports/cbom.html. The tab
has:
- Overview — totals by asset type
- Tier cards — Tier 0 / Monitor / Unrated / Quantum-vulnerable (unscored), plus Tier 1/2/3 cards that only render once at least one asset has overlay-driven scoring
- Population strip — D-3 considered/emitted/parse-failures, as three independent counters (not a funnel — a parse failure isn't necessarily "lost" from consideration)
- Policy findings table — D-6 findings, with a dedicated quantum-vulnerable-in-production callout
- Register — every discovered asset, sortable, with per-type filter chips (Algorithm / Certificate / Key material / Protocol) and a click-to-open detail drawer showing the asset's full raw CycloneDX JSON
- Relationship graph — a force-directed graph linking key material to the certificates it signs, and other data-backed relationships (never an invented one)
- Trend & diff — asset counts and tier changes across the last 10 scans of this project (reuses the existing scan-history store — no setup needed beyond scanning more than once)
- Coverage & provenance — per-arm breakdown (considered / emitted / parse-failures for each of A1–A4) plus the same scan-history list
CI / automation
gadriel scan --cryptoexits like any othergadriel scanrun — wire it into existing CI the same way..security/cbom.cyclonedx.jsonis a standard CycloneDX document — any CycloneDX-consuming tool downstream (dependency-track, etc.) can ingest it directly..security/cbom-register.csvis plain CSV for spreadsheet-based tracking/triage workflows.gadriel:crypto:tieris the CycloneDX component property to filter or alert on for "anything Tier 0 or Tier 1 just appeared."- A private key linked to its certificate (see above) shows up as a
real CycloneDX
dependencies[]edge — useful for "which certificates does this key's compromise actually affect" queries downstream. --git-historyis expensive on a large repo (measured: +30s on a real ~7,000-commit history in CI) — run it on a schedule or before a release, not on every push, the same cost tradeoffgadriel scan --git-history's secrets sweep already makes.
Troubleshooting / FAQ
"My quantum-vulnerable asset shows quantum-vulnerable-unscored,
not a tier 1/2/3." — No overlay entry exists for that asset's
bom_ref, or the entry is missing one of the four required factors.
Add a complete entry to .gadriel/crypto-overlay.yaml.
"An EC key I expected to be flagged quantum-vulnerable shows
Unrated instead." — Known current limitation: a naming mismatch
between the material arm's curve-name formatting and the policy
registry's key format can leave EC-curve keys in Unrated rather than
Monitor. Tracked in the ADR; not silently worked around.
"The same algorithm appears to be found twice." — It isn't — check
the component's occurrences[] in the CBOM JSON; multiple real
occurrences of one algorithm correctly collapse into one component
(fixed; previously a duplicate-bom-ref defect, now merged — see the
CHANGELOG for the fix date).
"I want to test this against known-good/known-bad fixtures before
trusting it on my real codebase." — See
gadriel-vulnerable-cbom,
a vulnerable-by-design reference corpus built specifically for this
feature, with a published ground-truth manifest and an automated
scorer. Its tier-9-cicd-and-containers/ covers C4/container/managed-key/
literal-key-material fixtures; tier-5-composite-and-chains/linked-key-cert/
covers the private-key-to-certificate linking.
"A hardcoded Go []byte{...} key literal isn't being flagged." —
Known, documented gap: the Go source-code extractor doesn't currently
classify Go's idiomatic byte-slice literal syntax as a literal (only a
bare string literal in the same argument slot is recognized today).
Tracked, not silently worked around.
"Why does --declared only update the CSV, not the HTML report?"
— See "Declared-inventory reconciliation" above; it's a known,
documented scope cut, not an oversight.
