Files
George Karr a25ae3ddfc Adding initial tool - dibble the tapir for seeding data (#46122)
## Overview

`dibble` is a one-stop CLI for seeding a Fleet server with test data —
users, teams, policies, reports, labels, scripts, MDM profiles,
software, secrets, CAs, and vulns — replacing ~8 ad-hoc seeding tools
with a single binary.

It makes it easy to:
- **Spin up a populated dev/test server in one command** — `dibble all`
plants everything with sensible, idempotent defaults.
- **Skip the flag-memorization** — running `dibble` with no args
launches an interactive wizard that prompts for Fleet URL, API token,
theme, and which entities to seed, and offers to save the config to
`~/.dibble.yaml`.
- **Seed individual entity types** — `dibble users`, `dibble teams`,
`dibble policies`, etc., when you only need one slice.
- **Get themed, recognizable test data** — pick a theme (hitchhikers,
tng, lotr, ghibli, parksrec, …) so seeded names are easy to eyeball in
the UI.

Hosts are intentionally out of scope — `cmd/osquery-perf` still owns
that. `dibble hosts` is a thin convenience wrapper that picks a fleet,
fetches its enroll secret, and prints/runs the osquery-perf invocation
for you.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Dibble: a CLI tool to seed realistic test data, including an
interactive wizard and subcommands for
teams/users/software/policies/scripts/reports/profiles/labels/activities/enroll-secrets/hosts/vulns,
plus theme-driven “cas” and “ping”.
* Theme system: multiple curated themes to generate consistent seeded
identities, policies, software, labels, and scripts.
* **Chores**
* Ignored the built dibble binary and added a Makefile build target to
compile the dibble tool.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-17 11:07:05 -05:00

215 lines
6.8 KiB
Go

package seed
import (
"context"
"crypto/md5" //nolint:gosec // matches fleet.Software.ComputeRawChecksum
"database/sql"
"embed"
"encoding/csv"
"errors"
"fmt"
"strings"
"github.com/go-sql-driver/mysql"
)
//go:embed data/software-macos.csv data/software-ubuntu.csv data/software-win.csv
var vulnCSVs embed.FS
// VulnsOptions configures the vuln seeder. Counts are per-platform; pass 0
// to skip a platform. DSN is a MySQL connection string.
type VulnsOptions struct {
DSN string
MacOS int
Ubuntu int
Windows int
BatchSiz int
}
// Vulns writes plausible-looking software rows directly to MySQL so the
// background vulnerability scanner has inventory to chew on. Each row gets
// a fleet-compatible checksum and is inserted with INSERT IGNORE, so
// re-runs are idempotent against the unique software-checksum index.
//
// Scope (intentional): this seeder only writes the `software` table. It
// does NOT create hosts, `host_software` rows, or `software_cpe`
// associations — the legacy tools/software/vulnerabilities/seed_data tool
// did all of those, but dibble keeps the surface minimal and leaves
// host/CPE wiring to the real ingest path (or a future, opt-in flag).
// Vulnerabilities themselves are derived by Fleet's vuln scanner after
// CPE matching, so an empty `software_cpe` means no CVEs will surface
// from these rows on their own.
func Vulns(ctx context.Context, log Logger, opt VulnsOptions) Result {
res := Result{Entity: "vulns"}
if opt.BatchSiz <= 0 {
opt.BatchSiz = 500
}
dsn, err := mysqlDSN(opt.DSN, true)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("parse DSN: %w", err))
return res
}
db, err := sql.Open("mysql", dsn)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("open mysql: %w", err))
return res
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
res.Errors = append(res.Errors, fmt.Errorf("mysql ping: %w", err))
return res
}
plans := []struct {
platform string
file string
count int
}{
{"darwin", "data/software-macos.csv", opt.MacOS},
{"ubuntu", "data/software-ubuntu.csv", opt.Ubuntu},
{"windows", "data/software-win.csv", opt.Windows},
}
for _, p := range plans {
if p.count <= 0 {
continue
}
rows, err := readCSV(p.file)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("read %s: %w", p.file, err))
continue
}
if err := insertSoftware(ctx, db, p.platform, rows, p.count, opt.BatchSiz); err != nil {
res.Errors = append(res.Errors, fmt.Errorf("insert %s: %w", p.platform, err))
continue
}
log.Printf("vulns: %d %s rows inserted from %s", p.count, p.platform, p.file)
res.Created += p.count
}
return res
}
// mysqlDSN parses dsn with the MySQL driver, enables ParseTime and
// (optionally) MultiStatements, and returns the re-formatted DSN. Building
// the DSN this way instead of `dsn + "?parseTime=true"` preserves any
// query params the caller already set (e.g. tls=true, charset=utf8mb4).
func mysqlDSN(dsn string, multiStatements bool) (string, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return "", err
}
cfg.ParseTime = true
if multiStatements {
cfg.MultiStatements = true
}
return cfg.FormatDSN(), nil
}
// readCSV returns the data rows of an embedded CSV, with the header row
// stripped. Returns an error if the file has no data rows.
func readCSV(name string) ([][]string, error) {
f, err := vulnCSVs.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
all, err := csv.NewReader(f).ReadAll()
if err != nil {
return nil, err
}
if len(all) <= 1 {
return nil, errors.New("csv has no data rows after header")
}
return all[1:], nil
}
// softwareChecksum mirrors fleet.Software.ComputeRawChecksum so that rows
// inserted here satisfy the unique idx_software_checksum index. The order
// of fields here MUST match server/fleet/software.go.
func softwareChecksum(name, version, source, bundleID, release, arch, vendor, extensionFor, extensionID string) []byte {
h := md5.New() //nolint:gosec // DB lookup optimization, not security
cols := []string{version, source, bundleID, release, arch, vendor, extensionFor, extensionID, name}
_, _ = fmt.Fprint(h, strings.Join(cols, "\x00"))
return h.Sum(nil)
}
// insertSoftware appends `count` rows to the software table. Each row gets
// a checksum that matches fleet.Software.ComputeRawChecksum so the unique
// idx_software_checksum index is satisfied. Idempotency here is
// intentionally weak — INSERT IGNORE will drop duplicate-checksum rows on
// re-runs, which matches the legacy tool's expectations.
//
// The platform argument is unused by the INSERT itself — source values in
// the CSVs (e.g. "apps", "deb_packages", "programs") already encode the
// platform. It's kept on the signature so the caller can log it.
func insertSoftware(ctx context.Context, db *sql.DB, _platform string, rows [][]string, count, batch int) error {
if len(rows) == 0 {
return errors.New("empty csv")
}
// SET FOREIGN_KEY_CHECKS=0 is a session variable. Pin everything below
// to a single connection so the disable, the inserts, and the restore
// all hit the same MySQL session — otherwise the pool can hand the
// FK-disabled connection to an unrelated caller.
conn, err := db.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire dedicated conn: %w", err)
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return err
}
defer func() {
// Use a fresh context so the restore still runs even if ctx was
// cancelled mid-insert.
_, _ = conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS=1")
}()
for i := 0; i < count; i += batch {
end := i + batch
if end > count {
end = count
}
size := end - i
placeholders := make([]string, 0, size)
args := make([]any, 0, size*6)
for k := 0; k < size; k++ {
row := rows[(i+k)%len(rows)]
if len(row) < 3 {
continue
}
// CSV columns: name, version, source, bundle_identifier,
// release, vendor_old, arch, vendor. Older rows may be
// short; fall back to empty strings for missing fields.
name, version, source := row[0], row[1], row[2]
bundleID := csvField(row, 3)
release := csvField(row, 4)
arch := csvField(row, 6)
vendor := csvField(row, 7)
sum := softwareChecksum(name, version, source, bundleID, release, arch, vendor, "", "")
placeholders = append(placeholders, "(?,?,?,?,?,?,?,?,?)")
args = append(args,
name, version, source, bundleID, release, arch, vendor, "", sum,
)
}
if len(placeholders) == 0 {
continue
}
stmt := "INSERT IGNORE INTO software " +
"(name, version, source, bundle_identifier, `release`, arch, vendor, extension_for, checksum) " +
"VALUES " + strings.Join(placeholders, ",")
if _, err := conn.ExecContext(ctx, stmt, args...); err != nil {
return err
}
}
return nil
}
func csvField(row []string, i int) string {
if i < len(row) {
return row[i]
}
return ""
}