A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 822 lines 23 kB view raw
1// image-advisor fetches OCI image config, SBOM, and vulnerability data from 2// the ATCR system and outputs a structured markdown report suitable for LLM 3// analysis of container image improvements. 4// 5// Usage: 6// 7// go run ./cmd/image-advisor --url https://seamark.dev/r/therobbiedavis.com/listenarr --tag latest 8// go run ./cmd/image-advisor --url https://seamark.dev/r/therobbiedavis.com/listenarr --digest sha256:abc... 9// go run ./cmd/image-advisor --url https://seamark.dev/r/therobbiedavis.com/listenarr --platform linux/arm64 10package main 11 12import ( 13 "context" 14 "encoding/json" 15 "flag" 16 "fmt" 17 "io" 18 "log" 19 "net/http" 20 "net/url" 21 "os" 22 "sort" 23 "strings" 24 "time" 25 26 "atcr.io/pkg/atproto" 27) 28 29// OCI config types (full config, not just history) 30type ociFullConfig struct { 31 Architecture string `json:"architecture"` 32 OS string `json:"os"` 33 Config ociContainerConfig `json:"config"` 34 History []ociHistoryEntry `json:"history"` 35 RootFS ociRootFS `json:"rootfs"` 36} 37 38type ociContainerConfig struct { 39 Env []string `json:"Env"` 40 Cmd []string `json:"Cmd"` 41 Entrypoint []string `json:"Entrypoint"` 42 WorkingDir string `json:"WorkingDir"` 43 ExposedPorts map[string]struct{} `json:"ExposedPorts"` 44 Labels map[string]string `json:"Labels"` 45 User string `json:"User"` 46 Volumes map[string]struct{} `json:"Volumes"` 47} 48 49type ociHistoryEntry struct { 50 Created string `json:"created"` 51 CreatedBy string `json:"created_by"` 52 EmptyLayer bool `json:"empty_layer"` 53 Comment string `json:"comment"` 54} 55 56type ociRootFS struct { 57 Type string `json:"type"` 58 DiffIDs []string `json:"diff_ids"` 59} 60 61// Grype vulnerability report types 62type grypeReport struct { 63 Matches []grypeMatch `json:"matches"` 64} 65 66type grypeMatch struct { 67 Vulnerability grypeVuln `json:"Vulnerability"` 68 Package grypePackage `json:"Package"` 69} 70 71type grypeVuln struct { 72 ID string `json:"ID"` 73 Metadata grypeMetadata `json:"Metadata"` 74 Fix grypeFix `json:"Fix"` 75} 76 77type grypeMetadata struct { 78 Severity string `json:"Severity"` 79} 80 81type grypeFix struct { 82 Versions []string `json:"Versions"` 83 State string `json:"State"` 84} 85 86type grypePackage struct { 87 Name string `json:"Name"` 88 Version string `json:"Version"` 89 Type string `json:"Type"` 90} 91 92// SPDX SBOM types 93type spdxDocument struct { 94 Packages []spdxPackage `json:"packages"` 95} 96 97type spdxPackage struct { 98 SPDXID string `json:"SPDXID"` 99 Name string `json:"name"` 100 VersionInfo string `json:"versionInfo"` 101 Supplier string `json:"supplier"` 102 LicenseConcluded string `json:"licenseConcluded"` 103} 104 105// reportData holds all fetched data for markdown generation 106type reportData struct { 107 Handle string 108 Repository string 109 Tag string 110 Digest string 111 Platform string 112 HoldURL string 113 ScannedAt string 114 115 Config *ociFullConfig 116 ConfigErr string 117 Layers []atproto.BlobReference // from manifest record 118 VulnReport *grypeReport 119 VulnErr string 120 ScanRecord *atproto.ScanRecord 121 SBOM *spdxDocument 122 SBOMErr string 123} 124 125func main() { 126 registryURL := flag.String("url", "", "Registry URL (e.g., https://seamark.dev/r/therobbiedavis.com/listenarr)") 127 tag := flag.String("tag", "latest", "Image tag to look up") 128 digest := flag.String("digest", "", "Manifest digest (overrides --tag)") 129 platform := flag.String("platform", "linux/amd64", "Platform to select from manifest index (os/arch)") 130 holdURL := flag.String("hold", "https://us-chi1.cove.seamark.dev", "Hold service URL") 131 flag.Parse() 132 133 if *registryURL == "" { 134 fmt.Fprintln(os.Stderr, "error: --url is required") 135 flag.Usage() 136 os.Exit(1) 137 } 138 139 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) 140 defer cancel() 141 142 handle, repository, err := parseRegistryURL(*registryURL) 143 if err != nil { 144 log.Fatalf("Failed to parse URL: %v", err) 145 } 146 147 fmt.Fprintf(os.Stderr, "Resolving identity for %s...\n", handle) 148 did, resolvedHandle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, handle) 149 if err != nil { 150 log.Fatalf("Failed to resolve identity %q: %v", handle, err) 151 } 152 fmt.Fprintf(os.Stderr, " DID: %s\n Handle: %s\n PDS: %s\n", did, resolvedHandle, pdsEndpoint) 153 154 // Resolve hold DID 155 fmt.Fprintf(os.Stderr, "Resolving hold DID for %s...\n", *holdURL) 156 holdDID, err := atproto.ResolveHoldDID(ctx, *holdURL) 157 if err != nil { 158 log.Fatalf("Failed to resolve hold DID: %v", err) 159 } 160 fmt.Fprintf(os.Stderr, " Hold DID: %s\n", holdDID) 161 162 // Resolve manifest digest 163 manifestDigest := *digest 164 tagName := *tag 165 if manifestDigest == "" { 166 fmt.Fprintf(os.Stderr, "Looking up tag %q for %s/%s...\n", tagName, resolvedHandle, repository) 167 tagRecord, err := fetchTagRecord(ctx, pdsEndpoint, did, repository, tagName) 168 if err != nil { 169 log.Fatalf("Failed to fetch tag record: %v", err) 170 } 171 manifestDigest, err = tagRecord.GetManifestDigest() 172 if err != nil { 173 log.Fatalf("Failed to get manifest digest from tag: %v", err) 174 } 175 fmt.Fprintf(os.Stderr, " Digest: %s\n", manifestDigest) 176 } else { 177 tagName = "" 178 } 179 180 // Fetch manifest record 181 fmt.Fprintf(os.Stderr, "Fetching manifest record...\n") 182 manifest, err := fetchManifestRecord(ctx, pdsEndpoint, did, manifestDigest) 183 if err != nil { 184 log.Fatalf("Failed to fetch manifest record: %v", err) 185 } 186 187 // Handle manifest index 188 if len(manifest.Manifests) > 0 { 189 fmt.Fprintf(os.Stderr, "Manifest is an index with %d platforms:\n", len(manifest.Manifests)) 190 for _, m := range manifest.Manifests { 191 if m.Platform != nil { 192 p := m.Platform 193 platStr := p.OS + "/" + p.Architecture 194 if p.Variant != "" { 195 platStr += "/" + p.Variant 196 } 197 fmt.Fprintf(os.Stderr, " - %s (%s)\n", platStr, truncate(m.Digest, 24)) 198 } 199 } 200 201 child, err := selectPlatform(manifest.Manifests, *platform) 202 if err != nil { 203 fmt.Fprintf(os.Stderr, "Warning: %v, using first platform\n", err) 204 child = &manifest.Manifests[0] 205 } 206 207 manifestDigest = child.Digest 208 if child.Platform != nil { 209 *platform = child.Platform.OS + "/" + child.Platform.Architecture 210 if child.Platform.Variant != "" { 211 *platform += "/" + child.Platform.Variant 212 } 213 } 214 fmt.Fprintf(os.Stderr, "Selected platform %s → %s\n", *platform, truncate(manifestDigest, 24)) 215 216 // Re-fetch the child manifest record 217 manifest, err = fetchManifestRecord(ctx, pdsEndpoint, did, manifestDigest) 218 if err != nil { 219 log.Fatalf("Failed to fetch child manifest: %v", err) 220 } 221 } 222 223 report := &reportData{ 224 Handle: resolvedHandle, 225 Repository: repository, 226 Tag: tagName, 227 Digest: manifestDigest, 228 Platform: *platform, 229 HoldURL: *holdURL, 230 Layers: manifest.Layers, 231 } 232 233 // Fetch image config 234 fmt.Fprintf(os.Stderr, "Fetching image config...\n") 235 config, err := fetchFullImageConfig(ctx, *holdURL, manifestDigest) 236 if err != nil { 237 fmt.Fprintf(os.Stderr, " Warning: %v\n", err) 238 report.ConfigErr = err.Error() 239 } else { 240 report.Config = config 241 } 242 243 // Fetch scan data (scan record + SBOM blob + vuln blob) 244 fmt.Fprintf(os.Stderr, "Fetching scan data...\n") 245 scanRecord, sbom, vulnReport, scanErr := fetchScanData(ctx, *holdURL, holdDID, manifestDigest) 246 if scanErr != nil { 247 fmt.Fprintf(os.Stderr, " Warning: %v\n", scanErr) 248 report.VulnErr = scanErr.Error() 249 report.SBOMErr = scanErr.Error() 250 } else { 251 report.ScanRecord = scanRecord 252 report.ScannedAt = scanRecord.ScannedAt 253 if vulnReport != nil { 254 report.VulnReport = vulnReport 255 } else { 256 report.VulnErr = "No vulnerability report blob available" 257 } 258 if sbom != nil { 259 report.SBOM = sbom 260 } else { 261 report.SBOMErr = "No SBOM blob available" 262 } 263 } 264 265 fmt.Fprintf(os.Stderr, "Generating prompt...\n") 266 generatePrompt(os.Stdout, report) 267} 268 269func parseRegistryURL(rawURL string) (handle, repository string, err error) { 270 u, err := url.Parse(rawURL) 271 if err != nil { 272 return "", "", fmt.Errorf("invalid URL: %w", err) 273 } 274 275 path := strings.TrimPrefix(u.Path, "/") 276 path = strings.TrimPrefix(path, "r/") 277 path = strings.TrimSuffix(path, "/") 278 279 parts := strings.SplitN(path, "/", 2) 280 if len(parts) < 2 { 281 return "", "", fmt.Errorf("URL must be in format: https://domain/r/<handle>/<repository>") 282 } 283 284 return parts[0], parts[1], nil 285} 286 287func fetchTagRecord(ctx context.Context, pdsEndpoint, did, repository, tag string) (*atproto.TagRecord, error) { 288 rkey := atproto.RepositoryTagToRKey(repository, tag) 289 reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 290 strings.TrimSuffix(pdsEndpoint, "/"), 291 url.QueryEscape(did), 292 url.QueryEscape(atproto.TagCollection), 293 url.QueryEscape(rkey), 294 ) 295 296 resp, err := httpGet(ctx, reqURL) 297 if err != nil { 298 return nil, err 299 } 300 defer resp.Body.Close() 301 302 if resp.StatusCode != http.StatusOK { 303 return nil, fmt.Errorf("tag %q not found (HTTP %d)", tag, resp.StatusCode) 304 } 305 306 var envelope struct { 307 Value json.RawMessage `json:"value"` 308 } 309 if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { 310 return nil, fmt.Errorf("parse response: %w", err) 311 } 312 313 var tagRecord atproto.TagRecord 314 if err := json.Unmarshal(envelope.Value, &tagRecord); err != nil { 315 return nil, fmt.Errorf("parse tag record: %w", err) 316 } 317 318 return &tagRecord, nil 319} 320 321func fetchManifestRecord(ctx context.Context, pdsEndpoint, did, digest string) (*atproto.ManifestRecord, error) { 322 rkey := strings.TrimPrefix(digest, "sha256:") 323 reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 324 strings.TrimSuffix(pdsEndpoint, "/"), 325 url.QueryEscape(did), 326 url.QueryEscape(atproto.ManifestCollection), 327 url.QueryEscape(rkey), 328 ) 329 330 resp, err := httpGet(ctx, reqURL) 331 if err != nil { 332 return nil, err 333 } 334 defer resp.Body.Close() 335 336 if resp.StatusCode != http.StatusOK { 337 return nil, fmt.Errorf("manifest not found (HTTP %d)", resp.StatusCode) 338 } 339 340 var envelope struct { 341 Value json.RawMessage `json:"value"` 342 } 343 if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { 344 return nil, fmt.Errorf("parse response: %w", err) 345 } 346 347 var manifest atproto.ManifestRecord 348 if err := json.Unmarshal(envelope.Value, &manifest); err != nil { 349 return nil, fmt.Errorf("parse manifest record: %w", err) 350 } 351 352 return &manifest, nil 353} 354 355func selectPlatform(manifests []atproto.ManifestReference, platform string) (*atproto.ManifestReference, error) { 356 parts := strings.Split(platform, "/") 357 wantOS := parts[0] 358 wantArch := "" 359 wantVariant := "" 360 if len(parts) > 1 { 361 wantArch = parts[1] 362 } 363 if len(parts) > 2 { 364 wantVariant = parts[2] 365 } 366 367 for i := range manifests { 368 m := &manifests[i] 369 if m.Platform == nil { 370 continue 371 } 372 if m.Platform.OS == wantOS && m.Platform.Architecture == wantArch { 373 if wantVariant == "" || m.Platform.Variant == wantVariant { 374 return m, nil 375 } 376 } 377 } 378 379 return nil, fmt.Errorf("no platform matching %s found", platform) 380} 381 382func fetchFullImageConfig(ctx context.Context, holdURL, manifestDigest string) (*ociFullConfig, error) { 383 reqURL := fmt.Sprintf("%s%s?digest=%s", 384 strings.TrimSuffix(holdURL, "/"), 385 atproto.HoldGetImageConfig, 386 url.QueryEscape(manifestDigest), 387 ) 388 389 resp, err := httpGet(ctx, reqURL) 390 if err != nil { 391 return nil, err 392 } 393 defer resp.Body.Close() 394 395 if resp.StatusCode != http.StatusOK { 396 return nil, fmt.Errorf("image config not found (HTTP %d)", resp.StatusCode) 397 } 398 399 var record struct { 400 ConfigJSON string `json:"configJson"` 401 } 402 if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { 403 return nil, fmt.Errorf("parse response: %w", err) 404 } 405 406 var config ociFullConfig 407 if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil { 408 return nil, fmt.Errorf("parse OCI config: %w", err) 409 } 410 411 return &config, nil 412} 413 414func fetchScanData(ctx context.Context, holdURL, holdDID, manifestDigest string) (*atproto.ScanRecord, *spdxDocument, *grypeReport, error) { 415 rkey := strings.TrimPrefix(manifestDigest, "sha256:") 416 417 // Fetch scan record 418 scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 419 strings.TrimSuffix(holdURL, "/"), 420 url.QueryEscape(holdDID), 421 url.QueryEscape(atproto.ScanCollection), 422 url.QueryEscape(rkey), 423 ) 424 425 resp, err := httpGet(ctx, scanURL) 426 if err != nil { 427 return nil, nil, nil, fmt.Errorf("fetch scan record: %w", err) 428 } 429 defer resp.Body.Close() 430 431 if resp.StatusCode != http.StatusOK { 432 return nil, nil, nil, fmt.Errorf("no scan record found (HTTP %d)", resp.StatusCode) 433 } 434 435 var envelope struct { 436 Value json.RawMessage `json:"value"` 437 } 438 if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { 439 return nil, nil, nil, fmt.Errorf("parse scan response: %w", err) 440 } 441 442 var scanRecord atproto.ScanRecord 443 if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil { 444 return nil, nil, nil, fmt.Errorf("parse scan record: %w", err) 445 } 446 447 // Fetch SBOM blob 448 var sbom *spdxDocument 449 if scanRecord.SbomBlob != nil && scanRecord.SbomBlob.Ref.String() != "" { 450 blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 451 strings.TrimSuffix(holdURL, "/"), 452 url.QueryEscape(holdDID), 453 url.QueryEscape(scanRecord.SbomBlob.Ref.String()), 454 ) 455 blobResp, err := httpGet(ctx, blobURL) 456 if err == nil { 457 defer blobResp.Body.Close() 458 if blobResp.StatusCode == http.StatusOK { 459 var doc spdxDocument 460 if err := json.NewDecoder(blobResp.Body).Decode(&doc); err == nil { 461 sbom = &doc 462 } else { 463 fmt.Fprintf(os.Stderr, " Warning: failed to parse SBOM: %v\n", err) 464 } 465 } 466 } 467 } 468 469 // Fetch vuln report blob 470 var vulnReport *grypeReport 471 if scanRecord.VulnReportBlob != nil && scanRecord.VulnReportBlob.Ref.String() != "" { 472 blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 473 strings.TrimSuffix(holdURL, "/"), 474 url.QueryEscape(holdDID), 475 url.QueryEscape(scanRecord.VulnReportBlob.Ref.String()), 476 ) 477 blobResp, err := httpGet(ctx, blobURL) 478 if err == nil { 479 defer blobResp.Body.Close() 480 if blobResp.StatusCode == http.StatusOK { 481 var report grypeReport 482 if err := json.NewDecoder(blobResp.Body).Decode(&report); err == nil { 483 vulnReport = &report 484 } else { 485 fmt.Fprintf(os.Stderr, " Warning: failed to parse vuln report: %v\n", err) 486 } 487 } 488 } 489 } 490 491 return &scanRecord, sbom, vulnReport, nil 492} 493 494func httpGet(ctx context.Context, rawURL string) (*http.Response, error) { 495 req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) 496 if err != nil { 497 return nil, fmt.Errorf("build request: %w", err) 498 } 499 return http.DefaultClient.Do(req) 500} 501 502// --- Output generation --- 503 504func generatePrompt(w io.Writer, r *reportData) { 505 // System instruction 506 fmt.Fprintln(w, `Respond ONLY with raw YAML. No markdown fences, no explanation, no preamble. 507Analyze the container image data below. Output a list of actionable suggestions sorted by impact (highest first). 508 509schema: 510 suggestions: 511 - action: "<specific actionable step>" 512 category: vulnerability|size|cache|security|best-practice 513 impact: high|medium|low 514 effort: low|medium|high 515 cves_fixed: <int or 0> 516 size_saved_mb: <int or 0> 517 detail: "<one sentence with specific package names, versions, or commands>" 518---`) 519 520 // Compact data block - no markdown formatting, just facts 521 ref := r.Handle + "/" + r.Repository 522 if r.Tag != "" { 523 ref += ":" + r.Tag 524 } 525 totalSize := int64(0) 526 for _, l := range r.Layers { 527 totalSize += l.Size 528 } 529 530 fmt.Fprintf(w, "\nimage: %s\ndigest: %s\nplatform: %s\ntotal_size: %s\nlayers: %d\n", 531 ref, r.Digest, r.Platform, humanSize(totalSize), len(r.Layers)) 532 533 // Config 534 if r.Config != nil { 535 c := r.Config.Config 536 user := c.User 537 if user == "" { 538 user = "root" 539 } 540 fmt.Fprintf(w, "user: %s\n", user) 541 if c.WorkingDir != "" { 542 fmt.Fprintf(w, "workdir: %s\n", c.WorkingDir) 543 } 544 if len(c.Entrypoint) > 0 { 545 fmt.Fprintf(w, "entrypoint: %s\n", strings.Join(c.Entrypoint, " ")) 546 } 547 if len(c.Cmd) > 0 { 548 fmt.Fprintf(w, "cmd: %s\n", strings.Join(c.Cmd, " ")) 549 } 550 if len(c.ExposedPorts) > 0 { 551 ports := make([]string, 0, len(c.ExposedPorts)) 552 for p := range c.ExposedPorts { 553 ports = append(ports, p) 554 } 555 fmt.Fprintf(w, "ports: %s\n", strings.Join(ports, ",")) 556 } 557 if len(c.Env) > 0 { 558 fmt.Fprintln(w, "env:") 559 for _, env := range c.Env { 560 parts := strings.SplitN(env, "=", 2) 561 if shouldRedact(parts[0]) { 562 fmt.Fprintf(w, " - %s=[REDACTED]\n", parts[0]) 563 } else { 564 fmt.Fprintf(w, " - %s\n", env) 565 } 566 } 567 } 568 if len(c.Labels) > 0 { 569 fmt.Fprintln(w, "labels:") 570 keys := make([]string, 0, len(c.Labels)) 571 for k := range c.Labels { 572 keys = append(keys, k) 573 } 574 sort.Strings(keys) 575 for _, k := range keys { 576 v := c.Labels[k] 577 if len(v) > 80 { 578 v = v[:77] + "..." 579 } 580 fmt.Fprintf(w, " %s: %s\n", k, v) 581 } 582 } 583 584 // History as compact list 585 fmt.Fprintln(w, "history:") 586 layerIdx := 0 587 for _, h := range r.Config.History { 588 cmd := cleanCommand(h.CreatedBy) 589 if len(cmd) > 100 { 590 cmd = cmd[:97] + "..." 591 } 592 if !h.EmptyLayer && layerIdx < len(r.Layers) { 593 fmt.Fprintf(w, " - [%s] %s\n", humanSize(r.Layers[layerIdx].Size), cmd) 594 layerIdx++ 595 } else { 596 fmt.Fprintf(w, " - %s\n", cmd) 597 } 598 } 599 } 600 601 // Vuln summary 602 if r.ScanRecord != nil { 603 sr := r.ScanRecord 604 fmt.Fprintf(w, "vulns: {critical: %d, high: %d, medium: %d, low: %d, total: %d}\n", 605 sr.Critical, sr.High, sr.Medium, sr.Low, sr.Total) 606 } 607 608 // Fixable vulns - compact list 609 if r.VulnReport != nil { 610 // Group by package: name -> {version, type, fixes[], cves[]} 611 type pkgInfo struct { 612 version string 613 typ string 614 fixes map[string]bool 615 cves []string 616 maxSev int 617 } 618 pkgs := map[string]*pkgInfo{} 619 620 for _, m := range r.VulnReport.Matches { 621 sev := m.Vulnerability.Metadata.Severity 622 if sev != "Critical" && sev != "High" { 623 continue 624 } 625 key := m.Package.Name 626 p, ok := pkgs[key] 627 if !ok { 628 p = &pkgInfo{version: m.Package.Version, typ: m.Package.Type, fixes: map[string]bool{}, maxSev: 5} 629 pkgs[key] = p 630 } 631 p.cves = append(p.cves, m.Vulnerability.ID) 632 for _, f := range m.Vulnerability.Fix.Versions { 633 p.fixes[f] = true 634 } 635 if s := severityOrder(sev); s < p.maxSev { 636 p.maxSev = s 637 } 638 } 639 640 if len(pkgs) > 0 { 641 fmt.Fprintln(w, "fixable_critical_high:") 642 // Sort by severity then CVE count 643 type entry struct { 644 name string 645 info *pkgInfo 646 } 647 sorted := make([]entry, 0, len(pkgs)) 648 for n, p := range pkgs { 649 sorted = append(sorted, entry{n, p}) 650 } 651 sort.Slice(sorted, func(i, j int) bool { 652 if sorted[i].info.maxSev != sorted[j].info.maxSev { 653 return sorted[i].info.maxSev < sorted[j].info.maxSev 654 } 655 return len(sorted[i].info.cves) > len(sorted[j].info.cves) 656 }) 657 658 for _, e := range sorted { 659 fixes := make([]string, 0, len(e.info.fixes)) 660 for f := range e.info.fixes { 661 fixes = append(fixes, f) 662 } 663 sort.Strings(fixes) 664 fmt.Fprintf(w, " - pkg: %s@%s (%s) cves: %d fix: %s\n", 665 e.name, e.info.version, e.info.typ, len(e.info.cves), strings.Join(fixes, ",")) 666 } 667 } 668 669 // Unfixable counts 670 unfixable := map[string]int{} 671 for _, m := range r.VulnReport.Matches { 672 if len(m.Vulnerability.Fix.Versions) == 0 { 673 unfixable[m.Vulnerability.Metadata.Severity]++ 674 } 675 } 676 if len(unfixable) > 0 { 677 fmt.Fprintf(w, "unfixable:") 678 for _, sev := range []string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"} { 679 if c, ok := unfixable[sev]; ok { 680 fmt.Fprintf(w, " %s=%d", strings.ToLower(sev), c) 681 } 682 } 683 fmt.Fprintln(w) 684 } 685 } 686 687 // SBOM summary - just type counts 688 if r.SBOM != nil { 689 typeCounts := map[string]int{} 690 total := 0 691 for _, p := range r.SBOM.Packages { 692 if strings.HasPrefix(p.SPDXID, "SPDXRef-DocumentRoot") || p.SPDXID == "SPDXRef-DOCUMENT" { 693 continue 694 } 695 total++ 696 pkgType := extractPackageType(p.Supplier) 697 if pkgType == "" { 698 pkgType = "other" 699 } 700 typeCounts[pkgType]++ 701 } 702 fmt.Fprintf(w, "sbom_packages: %d", total) 703 for t, c := range typeCounts { 704 fmt.Fprintf(w, " %s=%d", t, c) 705 } 706 fmt.Fprintln(w) 707 708 // Top vulnerable packages 709 if r.VulnReport != nil { 710 vulnPkgs := map[string]int{} 711 for _, m := range r.VulnReport.Matches { 712 vulnPkgs[m.Package.Name]++ 713 } 714 type pv struct { 715 name string 716 count int 717 } 718 sorted := make([]pv, 0, len(vulnPkgs)) 719 for n, c := range vulnPkgs { 720 sorted = append(sorted, pv{n, c}) 721 } 722 sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count }) 723 // Top 10 only 724 if len(sorted) > 10 { 725 sorted = sorted[:10] 726 } 727 fmt.Fprintln(w, "top_vulnerable_packages:") 728 for _, p := range sorted { 729 fmt.Fprintf(w, " - %s: %d\n", p.name, p.count) 730 } 731 } 732 } 733} 734 735// --- Helpers --- 736 737func severityOrder(s string) int { 738 switch s { 739 case "Critical": 740 return 0 741 case "High": 742 return 1 743 case "Medium": 744 return 2 745 case "Low": 746 return 3 747 case "Negligible": 748 return 4 749 default: 750 return 5 751 } 752} 753 754func humanSize(bytes int64) string { 755 const ( 756 KB = 1024 757 MB = 1024 * KB 758 GB = 1024 * MB 759 ) 760 switch { 761 case bytes >= GB: 762 return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB)) 763 case bytes >= MB: 764 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) 765 case bytes >= KB: 766 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) 767 default: 768 return fmt.Sprintf("%d B", bytes) 769 } 770} 771 772func cleanCommand(cmd string) string { 773 // Remove common prefixes that add noise 774 cmd = strings.TrimPrefix(cmd, "/bin/sh -c ") 775 cmd = strings.TrimPrefix(cmd, "#(nop) ") 776 return strings.TrimSpace(cmd) 777} 778 779func shouldRedact(envName string) bool { 780 upper := strings.ToUpper(envName) 781 for _, suffix := range []string{"_KEY", "_SECRET", "_PASSWORD", "_TOKEN", "_CREDENTIALS", "_API_KEY"} { 782 if strings.HasSuffix(upper, suffix) { 783 return true 784 } 785 } 786 return false 787} 788 789func truncate(s string, n int) string { 790 if len(s) <= n { 791 return s 792 } 793 return s[:n] + "..." 794} 795 796func extractPackageType(supplier string) string { 797 s := strings.ToLower(supplier) 798 switch { 799 case strings.Contains(s, "npmjs") || strings.Contains(s, "npm"): 800 return "npm" 801 case strings.Contains(s, "pypi") || strings.Contains(s, "python"): 802 return "python" 803 case strings.Contains(s, "rubygems"): 804 return "gem" 805 case strings.Contains(s, "golang") || strings.Contains(s, "go"): 806 return "go" 807 case strings.Contains(s, "debian") || strings.Contains(s, "ubuntu"): 808 return "deb" 809 case strings.Contains(s, "alpine"): 810 return "apk" 811 case strings.Contains(s, "redhat") || strings.Contains(s, "fedora") || strings.Contains(s, "centos"): 812 return "rpm" 813 case strings.Contains(s, "maven") || strings.Contains(s, "java"): 814 return "java" 815 case strings.Contains(s, "nuget") || strings.Contains(s, ".net"): 816 return "nuget" 817 case strings.Contains(s, "cargo") || strings.Contains(s, "rust"): 818 return "rust" 819 default: 820 return "" 821 } 822}