|
| 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