Skip to content

Commit c0fa116

Browse files
committed
feat(cbom): add Cryptography BOM subcommand with post-quantum posture
Add `vulnetix cbom` — a catalog-driven Cryptography Bill of Materials that discovers cryptographic usage across source code, configuration, certificates/keys and crypto libraries and emits a CycloneDX CBOM (cryptographic-asset components). Each algorithm is classified quantum-safe / quantum-vulnerable / deprecated / hybrid with its NIST quantum-security level and a per-country approval matrix (NIST, CNSA 2.0, BSI, ACSC, CCCS, NUKIB, AIVD, NCSC, KpqC); an opt-in --fail-on gate exits non-zero for CI. Algorithm spellings are case/separator-insensitive, normalized to canonical SPDX names. Builder + types live in vdb-cyclonedx v0.3.0; docs generated via just gen-cbom.
1 parent 45a9bcf commit c0fa116

25 files changed

Lines changed: 3070 additions & 140 deletions

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ The `aibom` command discovers AI coding agents/assistants and AI usage in a proj
3030

3131
All detection is driven by the catalog in `internal/aibom/catalog/*.json` (`tools.json`, `libraries.json`, `families.json`) — the single source of truth. After editing the catalog, run `just gen-aibom` to regenerate the docs under `website/content/docs/aibom/`. The catalog is embedded and overridable at runtime with `--catalog`. Output maps tools→`application`, SDKs→`library`, models→`machine-learning-model` (+`modelCard`) components, validated against the bundled CycloneDX schema.
3232

33+
### CBOM Subcommand
34+
35+
The `cbom` command discovers cryptographic usage in code and config and emits a CycloneDX Cryptography Bill of Materials (CBOM, `cryptographic-asset` components, spec 1.6+), classifying each algorithm for post-quantum posture (`quantum-safe`/`quantum-vulnerable`/`deprecated`/`hybrid`) with `nistQuantumSecurityLevel` and a per-country approval matrix. Four passes: source code (per-language crypto API usage + generic call extractors), config (TLS cipher suites/versions, SSH Ciphers/Kex/MACs, JWT `alg`, OpenSSL/IPsec), certificates (X.509 certs/keys on disk via `crypto/x509` — metadata only, never key bytes), and dependencies (declared crypto libraries). Algorithm spellings are **case/separator-insensitive and stored under the canonical SPDX name** — `SHA256`/`Sha256`/`sha256`/`SHA_256` collapse to one asset (see `internal/cbom/normalize.go`). Flags: `--no-source`, `--no-config`, `--no-certs`, `--no-deps`, `--fail-on` (opt-in CI gate on a PQC status; default `none`/exit 0), `--catalog`, `--no-builtin-catalog`, `--spec-version`, `--output-file` (default `.vulnetix/cbom.cdx.json`), `-o {pretty,json,cyclonedx-json}`, `--no-upload`.
36+
37+
Detection is driven by the catalog in `internal/cbom/catalog/*.json` (`algorithms.json` with aliases/primitive/`pqc_status`/`standards`/`source_patterns`/`config_patterns` + top-level `call_extractors`; `libraries.json`) — the single source of truth, validated (regex + CycloneDX enum ranges) by `Compile()`. After editing, run `just gen-cbom` to regenerate docs under `website/content/docs/cbom/`. The `CryptoDetections` types + `BuildCBOM`/`ParseCBOM` builder live in the shared `vdb-cyclonedx` module (parity with `BuildAIBOM`); the CLI calls `cyclonedx.BuildCBOM`. Authenticated runs upload to `/v2/cli.cbom` (best-effort; backend endpoint is a follow-up).
38+
3339
### Malscan Subcommand
3440

3541
The `malscan` command runs the `malscan-engine` (module `github.com/vulnetix/malscan-engine`, consumed like `vdb-cyclonedx`) **in-process** against the project's locally-installed dependencies — unlike `--block-malware` on the `sca` path, which defers to the backend's periodic pipelines. It runs the full engine over each resolved ecosystem target: `iocscan.Scan` (STIX IOC filesystem scan — known-bad domains/IPs/URLs in text + extracted binary strings, with file+line+context), `detect.Detect` (manifest/install-script pattern + shell-obfuscation detectors), `ioc.ExtractIOCs`, and `badhash` (known-bad artifact-hash blocklist over declared/candidate hashes).

PLAN.md

Lines changed: 301 additions & 137 deletions
Large diffs are not rendered by default.

cmd/cbom.go

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sort"
9+
"strconv"
10+
"strings"
11+
"time"
12+
13+
cyclonedx "github.com/Vulnetix/vdb-cyclonedx"
14+
"github.com/spf13/cobra"
15+
"github.com/vulnetix/cli/v3/internal/cbom"
16+
"github.com/vulnetix/cli/v3/internal/display"
17+
"github.com/vulnetix/cli/v3/internal/gitctx"
18+
"github.com/vulnetix/cli/v3/pkg/auth"
19+
"github.com/vulnetix/cli/v3/pkg/vdb"
20+
)
21+
22+
var cbomCmd = &cobra.Command{
23+
Use: "cbom [path]",
24+
Short: "Discover cryptographic usage and emit a CycloneDX CBOM with PQC posture",
25+
Long: `Discover cryptographic algorithms, certificates and crypto libraries used in a
26+
project — in source code and configuration — and produce a Cryptography Bill of
27+
Materials (CBOM) in CycloneDX format. Each algorithm is classified for
28+
post-quantum posture (quantum-safe, quantum-vulnerable, deprecated or hybrid),
29+
carries its NIST quantum-security level and an annotated per-country approval
30+
matrix (NIST, CNSA 2.0, BSI, ACSC, CCCS, NUKIB, AIVD, NCSC, KpqC).
31+
32+
Four detection passes, all driven by a maintainable catalog:
33+
• source code — per-language crypto API usage (Go crypto/*, Python hashlib /
34+
pyca, Java JCA, Node crypto, …) plus generic call extractors.
35+
Algorithm spellings are case/separator-insensitive: SHA256,
36+
Sha256, sha256 and SHA_256 all resolve to one SPDX algorithm.
37+
• config — TLS cipher suites & versions, SSH Ciphers/KexAlgorithms/MACs,
38+
JWT alg, OpenSSL/IPsec settings.
39+
• certificates— X.509 certificates and keys on disk (signature algorithm, key
40+
type & size, validity). Only metadata is read, never key bytes.
41+
• dependencies— declared crypto libraries (OpenSSL, Bouncy Castle, libsodium,
42+
liboqs, ring, Tink, pyca/cryptography, …).
43+
44+
The catalog is embedded and can be extended or replaced at runtime with
45+
--catalog. No source content is ever uploaded.
46+
47+
The CycloneDX CBOM is always written to .vulnetix/cbom.cdx.json (override the
48+
path with --output-file). The terminal output format is set by -o. Use --fail-on
49+
to make CI exit non-zero when quantum-vulnerable or deprecated crypto is found.
50+
51+
Examples:
52+
vulnetix cbom # pretty summary; writes .vulnetix/cbom.cdx.json
53+
vulnetix cbom ./service -o json # print detections as JSON
54+
vulnetix cbom -o cyclonedx-json # print CycloneDX to stdout (still saved to file)
55+
vulnetix cbom --no-certs --no-deps # source + config only
56+
vulnetix cbom --fail-on quantum-vulnerable # gate CI on quantum-vulnerable crypto
57+
vulnetix cbom --catalog ./extra-algos.json # extend the builtin catalog`,
58+
Args: cobra.MaximumNArgs(1),
59+
RunE: runCBOM,
60+
SilenceUsage: true,
61+
}
62+
63+
func init() {
64+
cbomCmd.Flags().String("path", ".", "Directory to scan")
65+
cbomCmd.Flags().Int("depth", 25, "Maximum recursion depth for file discovery")
66+
cbomCmd.Flags().StringArray("ignore", nil, "Exclude paths matching glob pattern (repeatable)")
67+
cbomCmd.Flags().StringP("output", "o", "pretty", "Terminal output format: pretty, json, cyclonedx-json")
68+
cbomCmd.Flags().String("output-file", "", "Path to write the CycloneDX CBOM (default: <path>/.vulnetix/cbom.cdx.json)")
69+
cbomCmd.Flags().String("spec-version", "1.7", "CycloneDX spec version: 1.6 or 1.7")
70+
cbomCmd.Flags().String("catalog", "", "Path to a catalog file to merge over (or replace) the builtin catalog")
71+
cbomCmd.Flags().Bool("no-builtin-catalog", false, "Do not load the embedded catalog (use only --catalog)")
72+
cbomCmd.Flags().Bool("no-source", false, "Skip the source-code crypto API detection pass")
73+
cbomCmd.Flags().Bool("no-config", false, "Skip the config & protocol detection pass")
74+
cbomCmd.Flags().Bool("no-certs", false, "Skip the certificate / key detection pass")
75+
cbomCmd.Flags().Bool("no-deps", false, "Skip the crypto-library detection pass")
76+
cbomCmd.Flags().String("fail-on", "none", "Exit non-zero when crypto of these PQC statuses is found: none, quantum-vulnerable, deprecated (comma-separated)")
77+
cbomCmd.Flags().Bool("no-upload", false, "Do not submit the CBOM to Vulnetix (it is submitted automatically when authenticated)")
78+
rootCmd.AddCommand(cbomCmd)
79+
}
80+
81+
func runCBOM(cmd *cobra.Command, args []string) error {
82+
rootPath, _ := cmd.Flags().GetString("path")
83+
if len(args) == 1 && args[0] != "" {
84+
rootPath = args[0]
85+
}
86+
depth, _ := cmd.Flags().GetInt("depth")
87+
ignore, _ := cmd.Flags().GetStringArray("ignore")
88+
outputFmt, _ := cmd.Flags().GetString("output")
89+
outputFile, _ := cmd.Flags().GetString("output-file")
90+
specVersion, _ := cmd.Flags().GetString("spec-version")
91+
catalogPath, _ := cmd.Flags().GetString("catalog")
92+
noBuiltin, _ := cmd.Flags().GetBool("no-builtin-catalog")
93+
noSource, _ := cmd.Flags().GetBool("no-source")
94+
noConfig, _ := cmd.Flags().GetBool("no-config")
95+
noCerts, _ := cmd.Flags().GetBool("no-certs")
96+
noDeps, _ := cmd.Flags().GetBool("no-deps")
97+
failOnRaw, _ := cmd.Flags().GetString("fail-on")
98+
noUpload, _ := cmd.Flags().GetBool("no-upload")
99+
100+
switch outputFmt {
101+
case "pretty", "table", "json", "cyclonedx-json":
102+
default:
103+
return fmt.Errorf("--output must be one of: pretty, json, cyclonedx-json")
104+
}
105+
switch specVersion {
106+
case "1.6", "1.7":
107+
default:
108+
return fmt.Errorf("--spec-version must be one of: 1.6, 1.7")
109+
}
110+
failOn, err := parseFailOn(failOnRaw)
111+
if err != nil {
112+
return err
113+
}
114+
115+
cat, err := cbom.LoadCatalog(catalogPath, noBuiltin)
116+
if err != nil {
117+
return err
118+
}
119+
compiled, err := cat.Compile()
120+
if err != nil {
121+
return fmt.Errorf("invalid CBOM catalog: %w", err)
122+
}
123+
124+
det, err := cbom.Detect(cbom.Options{
125+
Root: rootPath,
126+
MaxDepth: depth,
127+
Ignore: ignore,
128+
ScanSource: !noSource,
129+
ScanConfig: !noConfig,
130+
ScanCerts: !noCerts,
131+
ScanDeps: !noDeps,
132+
Catalog: compiled,
133+
})
134+
if err != nil {
135+
return err
136+
}
137+
138+
// Build the CycloneDX CBOM once — used for both cyclonedx-json output and the
139+
// backend submission. Build + validate live in the shared vdb-cyclonedx module.
140+
gitCtx := gitctx.Collect(rootPath)
141+
bomData, err := cyclonedx.BuildCBOM(det, cyclonedx.CBOMOptions{
142+
SpecVersion: specVersion,
143+
ToolName: "vulnetix-cbom",
144+
ToolVersion: version,
145+
Project: aibomProject(gitCtx, gitctx.CollectSystemInfo()),
146+
})
147+
if err != nil {
148+
return err
149+
}
150+
151+
if !noUpload {
152+
uploadCBOM(specVersion, det, bomData, gitCtx)
153+
}
154+
155+
outFile := outputFile
156+
if outFile == "" {
157+
outFile = filepath.Join(rootPath, ".vulnetix", "cbom.cdx.json")
158+
}
159+
if err := writeCBOMFile(outFile, bomData); err != nil {
160+
return err
161+
}
162+
163+
switch outputFmt {
164+
case "json":
165+
data, err := json.MarshalIndent(det, "", " ")
166+
if err != nil {
167+
return err
168+
}
169+
fmt.Fprintln(os.Stdout, string(data))
170+
case "cyclonedx-json":
171+
fmt.Fprintln(os.Stdout, string(bomData))
172+
default: // pretty / table
173+
if err := renderCBOMTable(cmd, det); err != nil {
174+
return err
175+
}
176+
}
177+
178+
return evaluateFailOn(det.Summary, failOn)
179+
}
180+
181+
// parseFailOn validates the --fail-on selection.
182+
func parseFailOn(raw string) (map[string]bool, error) {
183+
out := map[string]bool{}
184+
for _, tok := range strings.Split(raw, ",") {
185+
tok = strings.TrimSpace(strings.ToLower(tok))
186+
switch tok {
187+
case "", "none":
188+
continue
189+
case cyclonedx.PQCQuantumVulnerable, cyclonedx.PQCDeprecated, cyclonedx.PQCHybrid, cyclonedx.PQCQuantumSafe:
190+
out[tok] = true
191+
default:
192+
return nil, fmt.Errorf("--fail-on: unknown status %q (want none, quantum-vulnerable, deprecated, hybrid, quantum-safe)", tok)
193+
}
194+
}
195+
return out, nil
196+
}
197+
198+
// evaluateFailOn returns a non-zero (error) result when the summary contains any
199+
// of the selected PQC statuses.
200+
func evaluateFailOn(s cyclonedx.CryptoSummary, failOn map[string]bool) error {
201+
counts := map[string]int{
202+
cyclonedx.PQCQuantumVulnerable: s.QuantumVulnerable,
203+
cyclonedx.PQCDeprecated: s.Deprecated,
204+
cyclonedx.PQCHybrid: s.Hybrid,
205+
cyclonedx.PQCQuantumSafe: s.QuantumSafe,
206+
}
207+
var breached []string
208+
for status := range failOn {
209+
if counts[status] > 0 {
210+
breached = append(breached, fmt.Sprintf("%d %s", counts[status], status))
211+
}
212+
}
213+
if len(breached) == 0 {
214+
return nil
215+
}
216+
sort.Strings(breached)
217+
return fmt.Errorf("cbom gate failed: found %s", strings.Join(breached, ", "))
218+
}
219+
220+
// uploadCBOM submits the CBOM to POST /v2/cli.cbom. Best-effort: community /
221+
// unauthenticated callers are skipped and any error is non-fatal.
222+
func uploadCBOM(specVersion string, det cyclonedx.CryptoDetections, bomData []byte, git *gitctx.GitContext) {
223+
creds, err := auth.LoadCredentials()
224+
if err != nil || creds == nil || auth.IsCommunity(creds) {
225+
return
226+
}
227+
client := vdb.NewClientFromCredentials(creds)
228+
client.APIVersion = "/v2"
229+
if client.HTTPClient != nil {
230+
client.HTTPClient.Timeout = 180 * time.Second
231+
}
232+
detJSON, err := json.Marshal(det)
233+
if err != nil {
234+
return
235+
}
236+
env := envForCliWithGit(git)
237+
env.ToolMetadata = &vdb.CliSBOMToolMetadata{
238+
ToolName: "vulnetix-cbom",
239+
ToolVersion: version,
240+
ToolVendor: "Vulnetix",
241+
ToolHash: commit,
242+
}
243+
resp, err := client.CliCBOM(env, vdb.CliCBOMRequest{
244+
SpecVersion: specVersion,
245+
CatalogVersion: det.CatalogVersion,
246+
BomJSON: string(bomData),
247+
Detections: detJSON,
248+
})
249+
if err != nil {
250+
if verbose {
251+
fmt.Fprintf(os.Stderr, "cbom: upload failed: %v\n", err)
252+
}
253+
return
254+
}
255+
if resp != nil && resp.Data.Cbom != nil && resp.Data.Cbom.URL != "" && !silent {
256+
fmt.Fprintf(os.Stderr, "Cryptography Inventory: %s\n", resp.Data.Cbom.URL)
257+
}
258+
}
259+
260+
func writeCBOMFile(path string, data []byte) error {
261+
if dir := filepath.Dir(path); dir != "" && dir != "." {
262+
if err := os.MkdirAll(dir, 0o755); err != nil {
263+
return fmt.Errorf("creating %s: %w", dir, err)
264+
}
265+
}
266+
if err := os.WriteFile(path, data, 0o644); err != nil {
267+
return fmt.Errorf("writing %s: %w", path, err)
268+
}
269+
if !silent {
270+
fmt.Fprintf(os.Stderr, "Wrote CBOM to %s\n", path)
271+
}
272+
return nil
273+
}
274+
275+
func renderCBOMTable(cmd *cobra.Command, det cyclonedx.CryptoDetections) error {
276+
dctx := display.FromCommand(cmd)
277+
if dctx.IsJSON() {
278+
data, err := json.MarshalIndent(det, "", " ")
279+
if err != nil {
280+
return err
281+
}
282+
fmt.Println(string(data))
283+
return nil
284+
}
285+
286+
t := display.NewTerminal()
287+
var b strings.Builder
288+
b.WriteString(display.Header(t, "Cryptography Bill of Materials"))
289+
b.WriteByte('\n')
290+
s := det.Summary
291+
fmt.Fprintf(&b, " Catalog %s — %d algorithm(s), %d certificate(s), %d library(ies)\n",
292+
det.CatalogVersion, len(det.Assets), len(det.Certificates), len(det.Libraries))
293+
fmt.Fprintf(&b, " PQC posture: %d quantum-vulnerable, %d deprecated, %d hybrid, %d quantum-safe\n\n",
294+
s.QuantumVulnerable, s.Deprecated, s.Hybrid, s.QuantumSafe)
295+
296+
if len(det.Assets) > 0 {
297+
b.WriteString(display.Header(t, "Algorithms"))
298+
b.WriteByte('\n')
299+
rows := make([][]string, 0, len(det.Assets))
300+
for _, a := range det.Assets {
301+
rows = append(rows, []string{
302+
a.Name, a.Primitive, a.PQCStatus, strconv.Itoa(a.NISTQuantumSecurityLevel),
303+
standardsSummary(a.Standards), strconv.Itoa(a.Occurrences),
304+
})
305+
}
306+
b.WriteString(display.Table(t, []display.Column{
307+
{Header: "Algorithm"}, {Header: "Primitive"}, {Header: "PQC Status"},
308+
{Header: "Q-Level", Align: display.AlignRight}, {Header: "Standards"},
309+
{Header: "Uses", Align: display.AlignRight},
310+
}, rows))
311+
b.WriteString("\n\n")
312+
}
313+
314+
if len(det.Certificates) > 0 {
315+
b.WriteString(display.Header(t, "Certificates"))
316+
b.WriteByte('\n')
317+
rows := make([][]string, 0, len(det.Certificates))
318+
for _, c := range det.Certificates {
319+
rows = append(rows, []string{c.Name, c.PublicKeyAlgorithm, keySize(c.KeySize), c.PQCStatus, c.NotAfter})
320+
}
321+
b.WriteString(display.Table(t, []display.Column{
322+
{Header: "File"}, {Header: "Key Algorithm"}, {Header: "Key Size", Align: display.AlignRight},
323+
{Header: "PQC Status"}, {Header: "Not After"},
324+
}, rows))
325+
b.WriteString("\n\n")
326+
}
327+
328+
if len(det.Libraries) > 0 {
329+
b.WriteString(display.Header(t, "Crypto Libraries"))
330+
b.WriteByte('\n')
331+
rows := make([][]string, 0, len(det.Libraries))
332+
for _, l := range det.Libraries {
333+
rows = append(rows, []string{l.Name, l.Provider, strings.Join(l.Languages, ", ")})
334+
}
335+
b.WriteString(display.Table(t, []display.Column{
336+
{Header: "Library"}, {Header: "Provider"}, {Header: "Languages"},
337+
}, rows))
338+
b.WriteString("\n")
339+
}
340+
341+
if len(det.Assets) == 0 && len(det.Certificates) == 0 && len(det.Libraries) == 0 {
342+
b.WriteString(" No cryptographic usage detected.\n")
343+
}
344+
345+
fmt.Print(b.String())
346+
return nil
347+
}
348+
349+
func standardsSummary(m map[string]string) string {
350+
if len(m) == 0 {
351+
return "-"
352+
}
353+
bodies := make([]string, 0, len(m))
354+
for k := range m {
355+
bodies = append(bodies, k)
356+
}
357+
sort.Strings(bodies)
358+
parts := make([]string, 0, len(bodies))
359+
for _, b := range bodies {
360+
parts = append(parts, b+":"+m[b])
361+
}
362+
return strings.Join(parts, " ")
363+
}
364+
365+
func keySize(n int) string {
366+
if n <= 0 {
367+
return "-"
368+
}
369+
return strconv.Itoa(n)
370+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.25.0
55
require (
66
github.com/BurntSushi/toml v1.6.0
77
github.com/Masterminds/semver/v3 v3.5.0
8-
github.com/Vulnetix/vdb-cyclonedx v0.2.0
8+
github.com/Vulnetix/vdb-cyclonedx v0.3.0
99
github.com/alecthomas/chroma/v2 v2.26.1
1010
github.com/charmbracelet/bubbles v1.0.0
1111
github.com/charmbracelet/bubbletea v1.3.10

0 commit comments

Comments
 (0)