A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
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 + }