Skip to content

Commit 4981136

Browse files
committed
feat(aibom): discover AI coding agents and emit/submit a CycloneDX AIBOM
New 'vulnetix aibom' command: catalog-driven detection of AI coding agents, AI SDK usage and the model names invoked, across four passes (env, filesystem, source-code with param-anchored model extraction, git commit history). Emits a schema-validated CycloneDX 1.7 AIBOM (machine-learning-model components + modelCard) and auto-submits it to /v2/cli.ai-bom when authenticated (--no-upload opt-out; community creds skip). Catalog (159 tools, 101 SDKs, 68 model families) is embedded, --catalog overridable, and the single source for the generated website docs (just gen-aibom).
1 parent 18f8d47 commit 4981136

29 files changed

Lines changed: 8228 additions & 2 deletions

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ The `vdb` command queries the Vulnetix Vulnerability Database API. Commands defa
2424
**V2-only commands** (require v2, which is now the default — do not pass `-V v1`): `scorecard` (+ `search` subcommand), `timeline`, `affected`, `kev`, `advisories`, `workarounds`, `cwe` (+ `guidance`), `remediation` (+ `plan`), `cloud-locators`, `fixes` (V2 fetches registry/distributions/source in parallel), tree-sitter reachability (`x_treeSitterQueries`)
2525
**Utility**: `status`, `cache` (+ `clear`)
2626

27+
### AIBOM Subcommand
28+
29+
The `aibom` command discovers AI coding agents/assistants and AI usage in a project and emits a CycloneDX AI Bill of Materials. It has four passes — environment (tool/provider env-var *names* only; values are never read), filesystem (tool config dirs, instructions, ignore files, skills, hooks, plugins, steering, memory, prompts, agents, commands, marketplace manifests), source code (AI SDK usage + model-name literals extracted by anchoring on the SDK parameter, so unknown/future models are captured), and commit history (commits authored by an AI agent, via `commit_patterns` matched against author/committer identity + message — Co-Authored-By trailers, session markers, agent bot authors; catches agents like Devin/Jules that leave no working-tree trace). Flags: `--no-env`, `--no-source`, `--no-commits` (default on), `--commit-scan-max`, `--include-home`, `--catalog`.
30+
31+
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.
32+
2733
## Build and Development Commands
2834

2935
Use the justfile for all development tasks:

cmd/aibom.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/spf13/cobra"
11+
"github.com/vulnetix/cli/v3/internal/aibom"
12+
"github.com/vulnetix/cli/v3/internal/cdx"
13+
"github.com/vulnetix/cli/v3/internal/display"
14+
"github.com/vulnetix/cli/v3/internal/gitctx"
15+
"github.com/vulnetix/cli/v3/pkg/auth"
16+
"github.com/vulnetix/cli/v3/pkg/vdb"
17+
)
18+
19+
var aibomCmd = &cobra.Command{
20+
Use: "aibom [path]",
21+
Short: "Discover AI coding agents and AI usage, and emit a CycloneDX AIBOM",
22+
Long: `Discover evidence of AI coding agents/assistants and AI usage in a project
23+
and produce an AI Bill of Materials (AIBOM) in CycloneDX format.
24+
25+
Three detection passes, all driven by a maintainable catalog:
26+
• environment — known AI tool / provider env-var NAMES (values are never read)
27+
• filesystem — tool config dirs, instructions, ignore files, skills, hooks,
28+
plugins, steering, memory, prompts, agents, commands and
29+
marketplace manifests
30+
• source code — AI SDK usage per language (OpenAI, Anthropic, Bedrock, Azure,
31+
Vertex, LiteLLM, LangChain, …) and the model-name literals
32+
passed to them. Model names are extracted by anchoring on the
33+
SDK parameter (model=, modelId=, deployment_name=), so future
34+
/ unknown model names are still captured.
35+
36+
The catalog is embedded and can be extended or replaced at runtime with
37+
--catalog. No source content is ever uploaded.
38+
39+
Examples:
40+
vulnetix aibom # scan ., emit CycloneDX AIBOM
41+
vulnetix aibom ./myproject -o table # human-readable summary
42+
vulnetix aibom --output-file aibom.cdx.json # write the AIBOM to a file
43+
vulnetix aibom --no-env --no-source # filesystem evidence only
44+
vulnetix aibom --catalog ./extra-rules.json # extend the builtin catalog`,
45+
Args: cobra.MaximumNArgs(1),
46+
RunE: runAIBOM,
47+
}
48+
49+
func init() {
50+
aibomCmd.Flags().String("path", ".", "Directory to scan")
51+
aibomCmd.Flags().Int("depth", 25, "Maximum recursion depth for file discovery")
52+
aibomCmd.Flags().StringArray("ignore", nil, "Exclude paths matching glob pattern (repeatable)")
53+
aibomCmd.Flags().StringP("output", "o", "cyclonedx-json", "Output format: cyclonedx-json, json, table")
54+
aibomCmd.Flags().String("output-file", "", "Write output to this file instead of stdout")
55+
aibomCmd.Flags().String("spec-version", "1.7", "CycloneDX spec version: 1.6 or 1.7")
56+
aibomCmd.Flags().String("catalog", "", "Path to a catalog file to merge over (or replace) the builtin catalog")
57+
aibomCmd.Flags().Bool("no-builtin-catalog", false, "Do not load the embedded catalog (use only --catalog)")
58+
aibomCmd.Flags().Bool("no-env", false, "Skip the environment-variable detection pass")
59+
aibomCmd.Flags().Bool("include-home", false, "Also probe the user's home directory for tool config dirs")
60+
aibomCmd.Flags().Bool("no-source", false, "Skip the source-code SDK / model detection pass")
61+
aibomCmd.Flags().Bool("no-commits", false, "Skip the git commit-history detection pass")
62+
aibomCmd.Flags().Int("commit-scan-max", 2000, "Maximum number of commits (from HEAD) the commit-history pass inspects")
63+
aibomCmd.Flags().Bool("no-upload", false, "Do not submit the AIBOM to Vulnetix (it is submitted automatically when authenticated)")
64+
rootCmd.AddCommand(aibomCmd)
65+
}
66+
67+
func runAIBOM(cmd *cobra.Command, args []string) error {
68+
rootPath, _ := cmd.Flags().GetString("path")
69+
if len(args) == 1 && args[0] != "" {
70+
rootPath = args[0]
71+
}
72+
depth, _ := cmd.Flags().GetInt("depth")
73+
ignore, _ := cmd.Flags().GetStringArray("ignore")
74+
outputFmt, _ := cmd.Flags().GetString("output")
75+
outputFile, _ := cmd.Flags().GetString("output-file")
76+
specVersion, _ := cmd.Flags().GetString("spec-version")
77+
catalogPath, _ := cmd.Flags().GetString("catalog")
78+
noBuiltin, _ := cmd.Flags().GetBool("no-builtin-catalog")
79+
noEnv, _ := cmd.Flags().GetBool("no-env")
80+
includeHome, _ := cmd.Flags().GetBool("include-home")
81+
noSource, _ := cmd.Flags().GetBool("no-source")
82+
noCommits, _ := cmd.Flags().GetBool("no-commits")
83+
commitMax, _ := cmd.Flags().GetInt("commit-scan-max")
84+
noUpload, _ := cmd.Flags().GetBool("no-upload")
85+
86+
switch outputFmt {
87+
case "cyclonedx-json", "json", "table":
88+
default:
89+
return fmt.Errorf("--output must be one of: cyclonedx-json, json, table")
90+
}
91+
switch specVersion {
92+
case "1.6", "1.7":
93+
default:
94+
return fmt.Errorf("--spec-version must be one of: 1.6, 1.7")
95+
}
96+
97+
cat, err := aibom.LoadCatalog(catalogPath, noBuiltin)
98+
if err != nil {
99+
return err
100+
}
101+
compiled, err := cat.Compile()
102+
if err != nil {
103+
return fmt.Errorf("invalid AIBOM catalog: %w", err)
104+
}
105+
106+
det, err := aibom.Detect(aibom.Options{
107+
Root: rootPath,
108+
MaxDepth: depth,
109+
Ignore: ignore,
110+
ScanEnv: !noEnv,
111+
IncludeHome: includeHome,
112+
ScanSource: !noSource,
113+
ScanCommits: !noCommits,
114+
CommitMax: commitMax,
115+
Catalog: compiled,
116+
})
117+
if err != nil {
118+
return err
119+
}
120+
121+
// Build the CycloneDX AIBOM once — used both for cyclonedx-json output and
122+
// for the backend submission below.
123+
ctx := &cdx.ScanContext{
124+
Git: gitctx.Collect(rootPath),
125+
System: gitctx.CollectSystemInfo(),
126+
ToolVersion: version,
127+
ToolName: "vulnetix-aibom",
128+
}
129+
bom, err := cdx.BuildAIBOM(det, specVersion, ctx)
130+
if err != nil {
131+
return err
132+
}
133+
bomData, err := bom.MarshalValidatedJSON()
134+
if err != nil {
135+
return err
136+
}
137+
138+
// Auto-submit to the Vulnetix backend when authenticated. Best-effort: never
139+
// fails the command, and community/unauthenticated callers are skipped (the
140+
// server would not persist their data anyway).
141+
if !noUpload {
142+
uploadAIBOM(specVersion, det, bomData, ctx.Git)
143+
}
144+
145+
switch outputFmt {
146+
case "table":
147+
return renderAIBOMTable(cmd, det)
148+
case "json":
149+
data, err := json.MarshalIndent(det, "", " ")
150+
if err != nil {
151+
return err
152+
}
153+
return writeAIBOMOutput(outputFile, append(data, '\n'))
154+
default: // cyclonedx-json
155+
return writeAIBOMOutput(outputFile, bomData)
156+
}
157+
}
158+
159+
// uploadAIBOM submits the AIBOM to POST /v2/cli.ai-bom. It is best-effort:
160+
// community/unauthenticated callers are skipped (the server does not persist
161+
// their data — see the community no-persist gate) and any error is non-fatal.
162+
func uploadAIBOM(specVersion string, det cdx.AIDetections, bomData []byte, git *gitctx.GitContext) {
163+
creds, err := auth.LoadCredentials()
164+
if err != nil || creds == nil || auth.IsCommunity(creds) {
165+
return
166+
}
167+
client := newCliClient()
168+
if client == nil {
169+
return
170+
}
171+
detJSON, err := json.Marshal(det)
172+
if err != nil {
173+
return
174+
}
175+
env := envForCliWithGit(git)
176+
env.ToolMetadata = &vdb.CliSBOMToolMetadata{
177+
ToolName: "vulnetix-aibom",
178+
ToolVersion: version,
179+
ToolVendor: "Vulnetix",
180+
ToolHash: commit,
181+
}
182+
resp, err := client.CliAIBOM(env, vdb.CliAIBOMRequest{
183+
SpecVersion: specVersion,
184+
CatalogVersion: det.CatalogVersion,
185+
BomJSON: string(bomData),
186+
Detections: detJSON,
187+
})
188+
if err != nil {
189+
if verbose {
190+
fmt.Fprintf(os.Stderr, "aibom: upload failed: %v\n", err)
191+
}
192+
return
193+
}
194+
if resp != nil && resp.Data.Aibom != nil && resp.Data.Aibom.URL != "" && !silent {
195+
fmt.Fprintf(os.Stderr, "AI Inventory: %s\n", resp.Data.Aibom.URL)
196+
}
197+
}
198+
199+
func writeAIBOMOutput(path string, data []byte) error {
200+
if path == "" {
201+
_, err := os.Stdout.Write(data)
202+
return err
203+
}
204+
if err := os.WriteFile(path, data, 0o644); err != nil {
205+
return fmt.Errorf("writing %s: %w", path, err)
206+
}
207+
fmt.Fprintf(os.Stderr, "Wrote AIBOM to %s\n", path)
208+
return nil
209+
}
210+
211+
func renderAIBOMTable(cmd *cobra.Command, det cdx.AIDetections) error {
212+
dctx := display.FromCommand(cmd)
213+
if dctx.IsJSON() {
214+
data, err := json.MarshalIndent(det, "", " ")
215+
if err != nil {
216+
return err
217+
}
218+
fmt.Println(string(data))
219+
return nil
220+
}
221+
222+
t := display.NewTerminal()
223+
var b strings.Builder
224+
b.WriteString(display.Header(t, "AI Bill of Materials"))
225+
b.WriteByte('\n')
226+
fmt.Fprintf(&b, " Catalog %s — %d tool(s), %d SDK(s), %d model(s)\n\n",
227+
det.CatalogVersion, len(det.Tools), len(det.Libraries), len(det.Models))
228+
229+
if len(det.Tools) > 0 {
230+
b.WriteString(display.Header(t, "AI Coding Agents & Services"))
231+
b.WriteByte('\n')
232+
rows := make([][]string, 0, len(det.Tools))
233+
for _, x := range det.Tools {
234+
rows = append(rows, []string{x.Name, x.Vendor, x.Type, x.Confidence, strconv.Itoa(len(x.Evidence))})
235+
}
236+
b.WriteString(display.Table(t, []display.Column{
237+
{Header: "Tool"}, {Header: "Vendor"}, {Header: "Type"},
238+
{Header: "Confidence"}, {Header: "Evidence", Align: display.AlignRight},
239+
}, rows))
240+
b.WriteString("\n\n")
241+
}
242+
243+
if len(det.Libraries) > 0 {
244+
b.WriteString(display.Header(t, "AI SDKs / Frameworks"))
245+
b.WriteByte('\n')
246+
rows := make([][]string, 0, len(det.Libraries))
247+
for _, x := range det.Libraries {
248+
rows = append(rows, []string{x.Name, x.Provider, strings.Join(x.Languages, ", "), x.Confidence})
249+
}
250+
b.WriteString(display.Table(t, []display.Column{
251+
{Header: "Library"}, {Header: "Provider"}, {Header: "Languages"}, {Header: "Confidence"},
252+
}, rows))
253+
b.WriteString("\n\n")
254+
}
255+
256+
if len(det.Models) > 0 {
257+
b.WriteString(display.Header(t, "Models"))
258+
b.WriteByte('\n')
259+
rows := make([][]string, 0, len(det.Models))
260+
for _, x := range det.Models {
261+
rows = append(rows, []string{x.Name, x.Provider, x.Family, x.ViaSDK, strconv.Itoa(x.Occurrences), x.Confidence})
262+
}
263+
b.WriteString(display.Table(t, []display.Column{
264+
{Header: "Model"}, {Header: "Provider"}, {Header: "Family"}, {Header: "Via SDK"},
265+
{Header: "Uses", Align: display.AlignRight}, {Header: "Confidence"},
266+
}, rows))
267+
b.WriteString("\n")
268+
}
269+
270+
if len(det.Tools) == 0 && len(det.Libraries) == 0 && len(det.Models) == 0 {
271+
b.WriteString(" No AI coding agents or AI usage detected.\n")
272+
}
273+
274+
fmt.Print(b.String())
275+
return nil
276+
}

0 commit comments

Comments
 (0)