From ca69c0d1c221745583e103ab6522a784179b7a2d Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 29 Mar 2026 22:31:45 -0700 Subject: [PATCH] ai advisor poc --- cmd/image-advisor/main.go | 822 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 822 insertions(+) create mode 100644 cmd/image-advisor/main.go diff --git a/cmd/image-advisor/main.go b/cmd/image-advisor/main.go new file mode 100644 index 0000000..53a37ee --- /dev/null +++ b/cmd/image-advisor/main.go @@ -0,0 +1,822 @@ +// image-advisor fetches OCI image config, SBOM, and vulnerability data from +// the ATCR system and outputs a structured markdown report suitable for LLM +// analysis of container image improvements. +// +// Usage: +// +// go run ./cmd/image-advisor --url https://seamark.dev/r/therobbiedavis.com/listenarr --tag latest +// go run ./cmd/image-advisor --url https://seamark.dev/r/therobbiedavis.com/listenarr --digest sha256:abc... +// go run ./cmd/image-advisor --url https://seamark.dev/r/therobbiedavis.com/listenarr --platform linux/arm64 +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" + + "atcr.io/pkg/atproto" +) + +// OCI config types (full config, not just history) +type ociFullConfig struct { + Architecture string `json:"architecture"` + OS string `json:"os"` + Config ociContainerConfig `json:"config"` + History []ociHistoryEntry `json:"history"` + RootFS ociRootFS `json:"rootfs"` +} + +type ociContainerConfig struct { + Env []string `json:"Env"` + Cmd []string `json:"Cmd"` + Entrypoint []string `json:"Entrypoint"` + WorkingDir string `json:"WorkingDir"` + ExposedPorts map[string]struct{} `json:"ExposedPorts"` + Labels map[string]string `json:"Labels"` + User string `json:"User"` + Volumes map[string]struct{} `json:"Volumes"` +} + +type ociHistoryEntry struct { + Created string `json:"created"` + CreatedBy string `json:"created_by"` + EmptyLayer bool `json:"empty_layer"` + Comment string `json:"comment"` +} + +type ociRootFS struct { + Type string `json:"type"` + DiffIDs []string `json:"diff_ids"` +} + +// Grype vulnerability report types +type grypeReport struct { + Matches []grypeMatch `json:"matches"` +} + +type grypeMatch struct { + Vulnerability grypeVuln `json:"Vulnerability"` + Package grypePackage `json:"Package"` +} + +type grypeVuln struct { + ID string `json:"ID"` + Metadata grypeMetadata `json:"Metadata"` + Fix grypeFix `json:"Fix"` +} + +type grypeMetadata struct { + Severity string `json:"Severity"` +} + +type grypeFix struct { + Versions []string `json:"Versions"` + State string `json:"State"` +} + +type grypePackage struct { + Name string `json:"Name"` + Version string `json:"Version"` + Type string `json:"Type"` +} + +// SPDX SBOM types +type spdxDocument struct { + Packages []spdxPackage `json:"packages"` +} + +type spdxPackage struct { + SPDXID string `json:"SPDXID"` + Name string `json:"name"` + VersionInfo string `json:"versionInfo"` + Supplier string `json:"supplier"` + LicenseConcluded string `json:"licenseConcluded"` +} + +// reportData holds all fetched data for markdown generation +type reportData struct { + Handle string + Repository string + Tag string + Digest string + Platform string + HoldURL string + ScannedAt string + + Config *ociFullConfig + ConfigErr string + Layers []atproto.BlobReference // from manifest record + VulnReport *grypeReport + VulnErr string + ScanRecord *atproto.ScanRecord + SBOM *spdxDocument + SBOMErr string +} + +func main() { + registryURL := flag.String("url", "", "Registry URL (e.g., https://seamark.dev/r/therobbiedavis.com/listenarr)") + tag := flag.String("tag", "latest", "Image tag to look up") + digest := flag.String("digest", "", "Manifest digest (overrides --tag)") + platform := flag.String("platform", "linux/amd64", "Platform to select from manifest index (os/arch)") + holdURL := flag.String("hold", "https://us-chi1.cove.seamark.dev", "Hold service URL") + flag.Parse() + + if *registryURL == "" { + fmt.Fprintln(os.Stderr, "error: --url is required") + flag.Usage() + os.Exit(1) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + handle, repository, err := parseRegistryURL(*registryURL) + if err != nil { + log.Fatalf("Failed to parse URL: %v", err) + } + + fmt.Fprintf(os.Stderr, "Resolving identity for %s...\n", handle) + did, resolvedHandle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, handle) + if err != nil { + log.Fatalf("Failed to resolve identity %q: %v", handle, err) + } + fmt.Fprintf(os.Stderr, " DID: %s\n Handle: %s\n PDS: %s\n", did, resolvedHandle, pdsEndpoint) + + // Resolve hold DID + fmt.Fprintf(os.Stderr, "Resolving hold DID for %s...\n", *holdURL) + holdDID, err := atproto.ResolveHoldDID(ctx, *holdURL) + if err != nil { + log.Fatalf("Failed to resolve hold DID: %v", err) + } + fmt.Fprintf(os.Stderr, " Hold DID: %s\n", holdDID) + + // Resolve manifest digest + manifestDigest := *digest + tagName := *tag + if manifestDigest == "" { + fmt.Fprintf(os.Stderr, "Looking up tag %q for %s/%s...\n", tagName, resolvedHandle, repository) + tagRecord, err := fetchTagRecord(ctx, pdsEndpoint, did, repository, tagName) + if err != nil { + log.Fatalf("Failed to fetch tag record: %v", err) + } + manifestDigest, err = tagRecord.GetManifestDigest() + if err != nil { + log.Fatalf("Failed to get manifest digest from tag: %v", err) + } + fmt.Fprintf(os.Stderr, " Digest: %s\n", manifestDigest) + } else { + tagName = "" + } + + // Fetch manifest record + fmt.Fprintf(os.Stderr, "Fetching manifest record...\n") + manifest, err := fetchManifestRecord(ctx, pdsEndpoint, did, manifestDigest) + if err != nil { + log.Fatalf("Failed to fetch manifest record: %v", err) + } + + // Handle manifest index + if len(manifest.Manifests) > 0 { + fmt.Fprintf(os.Stderr, "Manifest is an index with %d platforms:\n", len(manifest.Manifests)) + for _, m := range manifest.Manifests { + if m.Platform != nil { + p := m.Platform + platStr := p.OS + "/" + p.Architecture + if p.Variant != "" { + platStr += "/" + p.Variant + } + fmt.Fprintf(os.Stderr, " - %s (%s)\n", platStr, truncate(m.Digest, 24)) + } + } + + child, err := selectPlatform(manifest.Manifests, *platform) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v, using first platform\n", err) + child = &manifest.Manifests[0] + } + + manifestDigest = child.Digest + if child.Platform != nil { + *platform = child.Platform.OS + "/" + child.Platform.Architecture + if child.Platform.Variant != "" { + *platform += "/" + child.Platform.Variant + } + } + fmt.Fprintf(os.Stderr, "Selected platform %s → %s\n", *platform, truncate(manifestDigest, 24)) + + // Re-fetch the child manifest record + manifest, err = fetchManifestRecord(ctx, pdsEndpoint, did, manifestDigest) + if err != nil { + log.Fatalf("Failed to fetch child manifest: %v", err) + } + } + + report := &reportData{ + Handle: resolvedHandle, + Repository: repository, + Tag: tagName, + Digest: manifestDigest, + Platform: *platform, + HoldURL: *holdURL, + Layers: manifest.Layers, + } + + // Fetch image config + fmt.Fprintf(os.Stderr, "Fetching image config...\n") + config, err := fetchFullImageConfig(ctx, *holdURL, manifestDigest) + if err != nil { + fmt.Fprintf(os.Stderr, " Warning: %v\n", err) + report.ConfigErr = err.Error() + } else { + report.Config = config + } + + // Fetch scan data (scan record + SBOM blob + vuln blob) + fmt.Fprintf(os.Stderr, "Fetching scan data...\n") + scanRecord, sbom, vulnReport, scanErr := fetchScanData(ctx, *holdURL, holdDID, manifestDigest) + if scanErr != nil { + fmt.Fprintf(os.Stderr, " Warning: %v\n", scanErr) + report.VulnErr = scanErr.Error() + report.SBOMErr = scanErr.Error() + } else { + report.ScanRecord = scanRecord + report.ScannedAt = scanRecord.ScannedAt + if vulnReport != nil { + report.VulnReport = vulnReport + } else { + report.VulnErr = "No vulnerability report blob available" + } + if sbom != nil { + report.SBOM = sbom + } else { + report.SBOMErr = "No SBOM blob available" + } + } + + fmt.Fprintf(os.Stderr, "Generating prompt...\n") + generatePrompt(os.Stdout, report) +} + +func parseRegistryURL(rawURL string) (handle, repository string, err error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", "", fmt.Errorf("invalid URL: %w", err) + } + + path := strings.TrimPrefix(u.Path, "/") + path = strings.TrimPrefix(path, "r/") + path = strings.TrimSuffix(path, "/") + + parts := strings.SplitN(path, "/", 2) + if len(parts) < 2 { + return "", "", fmt.Errorf("URL must be in format: https://domain/r//") + } + + return parts[0], parts[1], nil +} + +func fetchTagRecord(ctx context.Context, pdsEndpoint, did, repository, tag string) (*atproto.TagRecord, error) { + rkey := atproto.RepositoryTagToRKey(repository, tag) + reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", + strings.TrimSuffix(pdsEndpoint, "/"), + url.QueryEscape(did), + url.QueryEscape(atproto.TagCollection), + url.QueryEscape(rkey), + ) + + resp, err := httpGet(ctx, reqURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tag %q not found (HTTP %d)", tag, resp.StatusCode) + } + + var envelope struct { + Value json.RawMessage `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + var tagRecord atproto.TagRecord + if err := json.Unmarshal(envelope.Value, &tagRecord); err != nil { + return nil, fmt.Errorf("parse tag record: %w", err) + } + + return &tagRecord, nil +} + +func fetchManifestRecord(ctx context.Context, pdsEndpoint, did, digest string) (*atproto.ManifestRecord, error) { + rkey := strings.TrimPrefix(digest, "sha256:") + reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", + strings.TrimSuffix(pdsEndpoint, "/"), + url.QueryEscape(did), + url.QueryEscape(atproto.ManifestCollection), + url.QueryEscape(rkey), + ) + + resp, err := httpGet(ctx, reqURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("manifest not found (HTTP %d)", resp.StatusCode) + } + + var envelope struct { + Value json.RawMessage `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + var manifest atproto.ManifestRecord + if err := json.Unmarshal(envelope.Value, &manifest); err != nil { + return nil, fmt.Errorf("parse manifest record: %w", err) + } + + return &manifest, nil +} + +func selectPlatform(manifests []atproto.ManifestReference, platform string) (*atproto.ManifestReference, error) { + parts := strings.Split(platform, "/") + wantOS := parts[0] + wantArch := "" + wantVariant := "" + if len(parts) > 1 { + wantArch = parts[1] + } + if len(parts) > 2 { + wantVariant = parts[2] + } + + for i := range manifests { + m := &manifests[i] + if m.Platform == nil { + continue + } + if m.Platform.OS == wantOS && m.Platform.Architecture == wantArch { + if wantVariant == "" || m.Platform.Variant == wantVariant { + return m, nil + } + } + } + + return nil, fmt.Errorf("no platform matching %s found", platform) +} + +func fetchFullImageConfig(ctx context.Context, holdURL, manifestDigest string) (*ociFullConfig, error) { + reqURL := fmt.Sprintf("%s%s?digest=%s", + strings.TrimSuffix(holdURL, "/"), + atproto.HoldGetImageConfig, + url.QueryEscape(manifestDigest), + ) + + resp, err := httpGet(ctx, reqURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("image config not found (HTTP %d)", resp.StatusCode) + } + + var record struct { + ConfigJSON string `json:"configJson"` + } + if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + var config ociFullConfig + if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil { + return nil, fmt.Errorf("parse OCI config: %w", err) + } + + return &config, nil +} + +func fetchScanData(ctx context.Context, holdURL, holdDID, manifestDigest string) (*atproto.ScanRecord, *spdxDocument, *grypeReport, error) { + rkey := strings.TrimPrefix(manifestDigest, "sha256:") + + // Fetch scan record + scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", + strings.TrimSuffix(holdURL, "/"), + url.QueryEscape(holdDID), + url.QueryEscape(atproto.ScanCollection), + url.QueryEscape(rkey), + ) + + resp, err := httpGet(ctx, scanURL) + if err != nil { + return nil, nil, nil, fmt.Errorf("fetch scan record: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, nil, nil, fmt.Errorf("no scan record found (HTTP %d)", resp.StatusCode) + } + + var envelope struct { + Value json.RawMessage `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, nil, nil, fmt.Errorf("parse scan response: %w", err) + } + + var scanRecord atproto.ScanRecord + if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil { + return nil, nil, nil, fmt.Errorf("parse scan record: %w", err) + } + + // Fetch SBOM blob + var sbom *spdxDocument + if scanRecord.SbomBlob != nil && scanRecord.SbomBlob.Ref.String() != "" { + blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", + strings.TrimSuffix(holdURL, "/"), + url.QueryEscape(holdDID), + url.QueryEscape(scanRecord.SbomBlob.Ref.String()), + ) + blobResp, err := httpGet(ctx, blobURL) + if err == nil { + defer blobResp.Body.Close() + if blobResp.StatusCode == http.StatusOK { + var doc spdxDocument + if err := json.NewDecoder(blobResp.Body).Decode(&doc); err == nil { + sbom = &doc + } else { + fmt.Fprintf(os.Stderr, " Warning: failed to parse SBOM: %v\n", err) + } + } + } + } + + // Fetch vuln report blob + var vulnReport *grypeReport + if scanRecord.VulnReportBlob != nil && scanRecord.VulnReportBlob.Ref.String() != "" { + blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", + strings.TrimSuffix(holdURL, "/"), + url.QueryEscape(holdDID), + url.QueryEscape(scanRecord.VulnReportBlob.Ref.String()), + ) + blobResp, err := httpGet(ctx, blobURL) + if err == nil { + defer blobResp.Body.Close() + if blobResp.StatusCode == http.StatusOK { + var report grypeReport + if err := json.NewDecoder(blobResp.Body).Decode(&report); err == nil { + vulnReport = &report + } else { + fmt.Fprintf(os.Stderr, " Warning: failed to parse vuln report: %v\n", err) + } + } + } + } + + return &scanRecord, sbom, vulnReport, nil +} + +func httpGet(ctx context.Context, rawURL string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + return http.DefaultClient.Do(req) +} + +// --- Output generation --- + +func generatePrompt(w io.Writer, r *reportData) { + // System instruction + fmt.Fprintln(w, `Respond ONLY with raw YAML. No markdown fences, no explanation, no preamble. +Analyze the container image data below. Output a list of actionable suggestions sorted by impact (highest first). + +schema: + suggestions: + - action: "" + category: vulnerability|size|cache|security|best-practice + impact: high|medium|low + effort: low|medium|high + cves_fixed: + size_saved_mb: + detail: "" +---`) + + // Compact data block - no markdown formatting, just facts + ref := r.Handle + "/" + r.Repository + if r.Tag != "" { + ref += ":" + r.Tag + } + totalSize := int64(0) + for _, l := range r.Layers { + totalSize += l.Size + } + + fmt.Fprintf(w, "\nimage: %s\ndigest: %s\nplatform: %s\ntotal_size: %s\nlayers: %d\n", + ref, r.Digest, r.Platform, humanSize(totalSize), len(r.Layers)) + + // Config + if r.Config != nil { + c := r.Config.Config + user := c.User + if user == "" { + user = "root" + } + fmt.Fprintf(w, "user: %s\n", user) + if c.WorkingDir != "" { + fmt.Fprintf(w, "workdir: %s\n", c.WorkingDir) + } + if len(c.Entrypoint) > 0 { + fmt.Fprintf(w, "entrypoint: %s\n", strings.Join(c.Entrypoint, " ")) + } + if len(c.Cmd) > 0 { + fmt.Fprintf(w, "cmd: %s\n", strings.Join(c.Cmd, " ")) + } + if len(c.ExposedPorts) > 0 { + ports := make([]string, 0, len(c.ExposedPorts)) + for p := range c.ExposedPorts { + ports = append(ports, p) + } + fmt.Fprintf(w, "ports: %s\n", strings.Join(ports, ",")) + } + if len(c.Env) > 0 { + fmt.Fprintln(w, "env:") + for _, env := range c.Env { + parts := strings.SplitN(env, "=", 2) + if shouldRedact(parts[0]) { + fmt.Fprintf(w, " - %s=[REDACTED]\n", parts[0]) + } else { + fmt.Fprintf(w, " - %s\n", env) + } + } + } + if len(c.Labels) > 0 { + fmt.Fprintln(w, "labels:") + keys := make([]string, 0, len(c.Labels)) + for k := range c.Labels { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := c.Labels[k] + if len(v) > 80 { + v = v[:77] + "..." + } + fmt.Fprintf(w, " %s: %s\n", k, v) + } + } + + // History as compact list + fmt.Fprintln(w, "history:") + layerIdx := 0 + for _, h := range r.Config.History { + cmd := cleanCommand(h.CreatedBy) + if len(cmd) > 100 { + cmd = cmd[:97] + "..." + } + if !h.EmptyLayer && layerIdx < len(r.Layers) { + fmt.Fprintf(w, " - [%s] %s\n", humanSize(r.Layers[layerIdx].Size), cmd) + layerIdx++ + } else { + fmt.Fprintf(w, " - %s\n", cmd) + } + } + } + + // Vuln summary + if r.ScanRecord != nil { + sr := r.ScanRecord + fmt.Fprintf(w, "vulns: {critical: %d, high: %d, medium: %d, low: %d, total: %d}\n", + sr.Critical, sr.High, sr.Medium, sr.Low, sr.Total) + } + + // Fixable vulns - compact list + if r.VulnReport != nil { + // Group by package: name -> {version, type, fixes[], cves[]} + type pkgInfo struct { + version string + typ string + fixes map[string]bool + cves []string + maxSev int + } + pkgs := map[string]*pkgInfo{} + + for _, m := range r.VulnReport.Matches { + sev := m.Vulnerability.Metadata.Severity + if sev != "Critical" && sev != "High" { + continue + } + key := m.Package.Name + p, ok := pkgs[key] + if !ok { + p = &pkgInfo{version: m.Package.Version, typ: m.Package.Type, fixes: map[string]bool{}, maxSev: 5} + pkgs[key] = p + } + p.cves = append(p.cves, m.Vulnerability.ID) + for _, f := range m.Vulnerability.Fix.Versions { + p.fixes[f] = true + } + if s := severityOrder(sev); s < p.maxSev { + p.maxSev = s + } + } + + if len(pkgs) > 0 { + fmt.Fprintln(w, "fixable_critical_high:") + // Sort by severity then CVE count + type entry struct { + name string + info *pkgInfo + } + sorted := make([]entry, 0, len(pkgs)) + for n, p := range pkgs { + sorted = append(sorted, entry{n, p}) + } + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].info.maxSev != sorted[j].info.maxSev { + return sorted[i].info.maxSev < sorted[j].info.maxSev + } + return len(sorted[i].info.cves) > len(sorted[j].info.cves) + }) + + for _, e := range sorted { + fixes := make([]string, 0, len(e.info.fixes)) + for f := range e.info.fixes { + fixes = append(fixes, f) + } + sort.Strings(fixes) + fmt.Fprintf(w, " - pkg: %s@%s (%s) cves: %d fix: %s\n", + e.name, e.info.version, e.info.typ, len(e.info.cves), strings.Join(fixes, ",")) + } + } + + // Unfixable counts + unfixable := map[string]int{} + for _, m := range r.VulnReport.Matches { + if len(m.Vulnerability.Fix.Versions) == 0 { + unfixable[m.Vulnerability.Metadata.Severity]++ + } + } + if len(unfixable) > 0 { + fmt.Fprintf(w, "unfixable:") + for _, sev := range []string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"} { + if c, ok := unfixable[sev]; ok { + fmt.Fprintf(w, " %s=%d", strings.ToLower(sev), c) + } + } + fmt.Fprintln(w) + } + } + + // SBOM summary - just type counts + if r.SBOM != nil { + typeCounts := map[string]int{} + total := 0 + for _, p := range r.SBOM.Packages { + if strings.HasPrefix(p.SPDXID, "SPDXRef-DocumentRoot") || p.SPDXID == "SPDXRef-DOCUMENT" { + continue + } + total++ + pkgType := extractPackageType(p.Supplier) + if pkgType == "" { + pkgType = "other" + } + typeCounts[pkgType]++ + } + fmt.Fprintf(w, "sbom_packages: %d", total) + for t, c := range typeCounts { + fmt.Fprintf(w, " %s=%d", t, c) + } + fmt.Fprintln(w) + + // Top vulnerable packages + if r.VulnReport != nil { + vulnPkgs := map[string]int{} + for _, m := range r.VulnReport.Matches { + vulnPkgs[m.Package.Name]++ + } + type pv struct { + name string + count int + } + sorted := make([]pv, 0, len(vulnPkgs)) + for n, c := range vulnPkgs { + sorted = append(sorted, pv{n, c}) + } + sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count }) + // Top 10 only + if len(sorted) > 10 { + sorted = sorted[:10] + } + fmt.Fprintln(w, "top_vulnerable_packages:") + for _, p := range sorted { + fmt.Fprintf(w, " - %s: %d\n", p.name, p.count) + } + } + } +} + +// --- Helpers --- + +func severityOrder(s string) int { + switch s { + case "Critical": + return 0 + case "High": + return 1 + case "Medium": + return 2 + case "Low": + return 3 + case "Negligible": + return 4 + default: + return 5 + } +} + +func humanSize(bytes int64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + ) + switch { + case bytes >= GB: + return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +func cleanCommand(cmd string) string { + // Remove common prefixes that add noise + cmd = strings.TrimPrefix(cmd, "/bin/sh -c ") + cmd = strings.TrimPrefix(cmd, "#(nop) ") + return strings.TrimSpace(cmd) +} + +func shouldRedact(envName string) bool { + upper := strings.ToUpper(envName) + for _, suffix := range []string{"_KEY", "_SECRET", "_PASSWORD", "_TOKEN", "_CREDENTIALS", "_API_KEY"} { + if strings.HasSuffix(upper, suffix) { + return true + } + } + return false +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func extractPackageType(supplier string) string { + s := strings.ToLower(supplier) + switch { + case strings.Contains(s, "npmjs") || strings.Contains(s, "npm"): + return "npm" + case strings.Contains(s, "pypi") || strings.Contains(s, "python"): + return "python" + case strings.Contains(s, "rubygems"): + return "gem" + case strings.Contains(s, "golang") || strings.Contains(s, "go"): + return "go" + case strings.Contains(s, "debian") || strings.Contains(s, "ubuntu"): + return "deb" + case strings.Contains(s, "alpine"): + return "apk" + case strings.Contains(s, "redhat") || strings.Contains(s, "fedora") || strings.Contains(s, "centos"): + return "rpm" + case strings.Contains(s, "maven") || strings.Contains(s, "java"): + return "java" + case strings.Contains(s, "nuget") || strings.Contains(s, ".net"): + return "nuget" + case strings.Contains(s, "cargo") || strings.Contains(s, "rust"): + return "rust" + default: + return "" + } +}