A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}