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.

ai advisor poc

+822
+822
cmd/image-advisor/main.go
··· 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 10 + package main 11 + 12 + import ( 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) 30 + type 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 + 38 + type 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 + 49 + type 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 + 56 + type ociRootFS struct { 57 + Type string `json:"type"` 58 + DiffIDs []string `json:"diff_ids"` 59 + } 60 + 61 + // Grype vulnerability report types 62 + type grypeReport struct { 63 + Matches []grypeMatch `json:"matches"` 64 + } 65 + 66 + type grypeMatch struct { 67 + Vulnerability grypeVuln `json:"Vulnerability"` 68 + Package grypePackage `json:"Package"` 69 + } 70 + 71 + type grypeVuln struct { 72 + ID string `json:"ID"` 73 + Metadata grypeMetadata `json:"Metadata"` 74 + Fix grypeFix `json:"Fix"` 75 + } 76 + 77 + type grypeMetadata struct { 78 + Severity string `json:"Severity"` 79 + } 80 + 81 + type grypeFix struct { 82 + Versions []string `json:"Versions"` 83 + State string `json:"State"` 84 + } 85 + 86 + type grypePackage struct { 87 + Name string `json:"Name"` 88 + Version string `json:"Version"` 89 + Type string `json:"Type"` 90 + } 91 + 92 + // SPDX SBOM types 93 + type spdxDocument struct { 94 + Packages []spdxPackage `json:"packages"` 95 + } 96 + 97 + type 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 106 + type 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 + 125 + func 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 + 269 + func 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 + 287 + func 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 + 321 + func 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 + 355 + func 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 + 382 + func 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 + 414 + func 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 + 494 + func 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 + 504 + func 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. 507 + Analyze the container image data below. Output a list of actionable suggestions sorted by impact (highest first). 508 + 509 + schema: 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 + 737 + func 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 + 754 + func 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 + 772 + func 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 + 779 + func 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 + 789 + func truncate(s string, n int) string { 790 + if len(s) <= n { 791 + return s 792 + } 793 + return s[:n] + "..." 794 + } 795 + 796 + func 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 + }