mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
An audit of the scan pipeline and the hold side of scanning found several ways scanning stops without saying so. Each fix here was written test-first: a test expressing the wanted behaviour, confirmed failing for the right reason, then the change. A summary-less result crash-looped both processes. worker.go dereferenced result.Summary unconditionally, but processJob only sets it when Grype runs, and SendResult puts the nil on the wire before the scanner dies on it, so handleResult's unguarded log killed the hold too. A nil Summary now means "not scanned for vulnerabilities", deliberately distinct from "scanned, found zero" — inventing a zeroed summary would report every image as clean when Grype never ran. The hold writes a record rather than orphaning the uploaded SBOM, and the appview renders an "SBOM only" state instead of a green Clean badge. The Grype database could wedge with no way back short of a restart. All three throttles in loadVulnDatabase were guarded by vulnDB != nil, so a scanner holding no provider retried a full download on every scan under the exclusive lock. Two earlier attempts at this bug each added one more condition to the same chain; this replaces the chain with a single decision function over a state snapshot, consulted by both call sites so they cannot disagree. That disagreement was itself a bug: the 50-scan reload had never once executed. Two independent halts. An unparseable frame was dropped in silence, stranding a row that held the hold's only dispatch slot forever; it is now answered "skipped" on first delivery. The 10-minute sweep leaked the in-flight digest and wrote no record, permanently retiring one image per timeout. A digest went unvalidated into filepath.Join and os.Create, so a layer digest of sha256:../../../x wrote outside the scan directory, and nothing verified that downloaded bytes hashed to the digest naming them. Digests come from records in a user's own PDS. Both are fixed together: verification is what makes an escaping write self-defeating. Concurrency did not work on either axis. The proactive capacity gate was depth-one hold-wide, so neither extra workers nor extra scanner processes received work. Depth is now the sum of the worker counts scanners advertise on connect, the gate is scoped to proactive work, and dispatch prefers the least-loaded scanner. Disconnects no longer hand a running scan to someone else: a scanner keeps a stable per-process identity and reclaims its own rows within a grace window, while a process that truly restarted returns with a new identity and has its work reclaimed, which is correct because the restart did lose it. The hold's scanning deadline measured queueing rather than scanning, because the scanner acks on receipt and handleAck never refreshed assigned_at. A new "started" message, sent by the worker that dequeues the job, separates the two budgets. An older scanner never sends it and falls under the queueing budget, which is more forgiving than the deadline it gets today. Adds an in-process mock hold and an e2e harness that runs the real client, queue and worker pool, seeded with 84 real manifest records fetched from a live PDS. Real image layouts and the Grype database are fetched by scripts and gitignored; suites needing them skip cleanly, so the default run stays offline and fast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
778 lines
22 KiB
Go
778 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/middleware"
|
|
"atcr.io/pkg/appview/storage"
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// ImageAdvisorHandler returns AI-powered suggestions for improving a container image.
|
|
// Returns an HTML fragment (image-advisor-results partial) via HTMX.
|
|
type ImageAdvisorHandler struct {
|
|
BaseUIHandler
|
|
ClaudeAPIKey string
|
|
}
|
|
|
|
type advisorSuggestion struct {
|
|
Action string `json:"action"`
|
|
Category string `json:"category"`
|
|
Impact string `json:"impact"`
|
|
Effort string `json:"effort"`
|
|
CVEsFixed int `json:"cves_fixed"`
|
|
SizeSavedMB int `json:"size_saved_mb"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
|
|
type imageAdvisorData struct {
|
|
Suggestions []advisorSuggestion
|
|
Error string
|
|
// Model is shown in the results footer so users can attribute the
|
|
// suggestions to a specific model without us hardcoding it in the template.
|
|
Model string
|
|
}
|
|
|
|
// advisorModel is the Claude model used for image suggestions. Kept in one
|
|
// place so the API call and the template footer stay in sync.
|
|
const advisorModel = "claude-haiku-4-5-20251001"
|
|
const advisorModelDisplay = "Claude Haiku 4.5"
|
|
|
|
// OCI config types for full image config parsing
|
|
type advisorOCIConfig struct {
|
|
Architecture string `json:"architecture"`
|
|
OS string `json:"os"`
|
|
Config advisorOCIContainerConfig `json:"config"`
|
|
History []advisorOCIHistory `json:"history"`
|
|
}
|
|
|
|
type advisorOCIContainerConfig struct {
|
|
Env []string `json:"Env"`
|
|
Cmd []string `json:"Cmd"`
|
|
Entrypoint []string `json:"Entrypoint"`
|
|
WorkingDir string `json:"WorkingDir"`
|
|
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
|
|
Labels map[string]string `json:"Labels"`
|
|
User string `json:"User"`
|
|
}
|
|
|
|
type advisorOCIHistory struct {
|
|
CreatedBy string `json:"created_by"`
|
|
EmptyLayer bool `json:"empty_layer"`
|
|
}
|
|
|
|
// SPDX types for SBOM parsing
|
|
type advisorSPDX struct {
|
|
Packages []advisorSPDXPackage `json:"packages"`
|
|
}
|
|
|
|
type advisorSPDXPackage struct {
|
|
SPDXID string `json:"SPDXID"`
|
|
Name string `json:"name"`
|
|
VersionInfo string `json:"versionInfo"`
|
|
Supplier string `json:"supplier"`
|
|
}
|
|
|
|
// advisorReportData holds all fetched data for prompt generation
|
|
type advisorReportData struct {
|
|
Handle string
|
|
Repository string
|
|
Digest string
|
|
Platform string
|
|
|
|
Config *advisorOCIConfig
|
|
Layers []db.Layer
|
|
ScanRecord *atproto.ScanRecord
|
|
VulnReport *grypeReport
|
|
SBOM *advisorSPDX
|
|
}
|
|
|
|
func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if h.ClaudeAPIKey == "" {
|
|
h.renderResults(w, imageAdvisorData{Error: "AI advisor is not configured"})
|
|
return
|
|
}
|
|
|
|
identifier := chi.URLParam(r, "handle")
|
|
wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
|
|
digest := r.URL.Query().Get("digest")
|
|
|
|
if wildcard == "" || digest == "" {
|
|
h.renderResults(w, imageAdvisorData{Error: "Missing required parameters"})
|
|
return
|
|
}
|
|
|
|
// Verify the logged-in user owns this image
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
h.renderResults(w, imageAdvisorData{Error: "Login required"})
|
|
return
|
|
}
|
|
|
|
// Check billing access. Paid features require a managed default hold, so a
|
|
// paid user on a self-hosted hold also fails here — show them the right
|
|
// message (switch holds) rather than "upgrade".
|
|
if h.BillingManager != nil && !h.BillingManager.HasAIAdvisor(user.DID) {
|
|
errCode := "upgrade_required"
|
|
if !h.IsManagedHold(db.GetUserDefaultHoldDID(h.DB, user.DID)) {
|
|
errCode = "managed_hold_required"
|
|
}
|
|
h.renderResults(w, imageAdvisorData{Error: errCode})
|
|
return
|
|
}
|
|
|
|
// Check user preference
|
|
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
|
profile, err := storage.GetProfile(r.Context(), client)
|
|
if err == nil && profile != nil && profile.AIAdvisorEnabled != nil && !*profile.AIAdvisorEnabled {
|
|
h.renderResults(w, imageAdvisorData{Error: "AI advisor is disabled in your settings"})
|
|
return
|
|
}
|
|
|
|
// Resolve identity
|
|
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
|
|
if err != nil {
|
|
h.renderResults(w, imageAdvisorData{Error: "Could not resolve identity"})
|
|
return
|
|
}
|
|
|
|
if user.DID != did {
|
|
h.renderResults(w, imageAdvisorData{Error: "You can only generate suggestions for your own images"})
|
|
return
|
|
}
|
|
|
|
// Fetch manifest
|
|
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, wildcard, digest)
|
|
if err != nil {
|
|
h.renderResults(w, imageAdvisorData{Error: "Manifest not found"})
|
|
return
|
|
}
|
|
|
|
// For manifest lists, the caller should pass a platform-specific child digest.
|
|
// If they somehow pass the list digest itself, resolve to the first platform.
|
|
if manifest.IsManifestList {
|
|
if len(manifest.Platforms) == 0 {
|
|
h.renderResults(w, imageAdvisorData{Error: "No platforms found in manifest list"})
|
|
return
|
|
}
|
|
childDigest := manifest.Platforms[0].Digest
|
|
childManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, wildcard, childDigest)
|
|
if err != nil {
|
|
h.renderResults(w, imageAdvisorData{Error: "Could not resolve platform manifest"})
|
|
return
|
|
}
|
|
manifest = childManifest
|
|
digest = childDigest
|
|
}
|
|
|
|
// Check cache first
|
|
if cachedJSON, _, err := db.GetAdvisorSuggestions(h.ReadOnlyDB, digest); err == nil {
|
|
suggestions, err := parseAdvisorResponse(cachedJSON)
|
|
if err == nil {
|
|
slog.Debug("Serving cached advisor suggestions", "digest", digest)
|
|
h.renderResults(w, imageAdvisorData{Suggestions: suggestions, Model: advisorModelDisplay})
|
|
return
|
|
}
|
|
slog.Debug("Cached advisor data unparseable, fetching fresh", "digest", digest)
|
|
}
|
|
|
|
// Resolve hold
|
|
hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
|
|
if err != nil {
|
|
h.renderResults(w, imageAdvisorData{Error: "Could not resolve hold endpoint"})
|
|
return
|
|
}
|
|
|
|
// Build report data
|
|
report := &advisorReportData{
|
|
Handle: resolvedHandle,
|
|
Repository: wildcard,
|
|
Digest: digest,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// Fetch full OCI image config
|
|
config, err := fetchAdvisorImageConfig(ctx, hold.URL, digest)
|
|
if err != nil {
|
|
slog.Debug("Failed to fetch image config for advisor", "error", err)
|
|
} else {
|
|
report.Config = config
|
|
report.Platform = config.OS + "/" + config.Architecture
|
|
}
|
|
|
|
// Fetch layers for size info
|
|
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.Key)
|
|
if err != nil {
|
|
slog.Debug("Failed to fetch layers for advisor", "error", err)
|
|
}
|
|
report.Layers = dbLayers
|
|
|
|
// Fetch scan data (scan record + vuln blob + SBOM blob)
|
|
scanRecord, vulnReport, sbom := fetchAdvisorScanData(ctx, hold.URL, hold.DID, digest)
|
|
report.ScanRecord = scanRecord
|
|
report.VulnReport = vulnReport
|
|
report.SBOM = sbom
|
|
|
|
// Generate prompt
|
|
var promptBuf strings.Builder
|
|
generateAdvisorPrompt(&promptBuf, report)
|
|
|
|
// Call Claude API. The raw error often contains upstream HTTP body text
|
|
// which we must not surface to the user (potential secrets/PII). Log the
|
|
// detail; show a stable, sanitized message.
|
|
responseText, err := callClaudeAPI(ctx, h.ClaudeAPIKey, promptBuf.String())
|
|
if err != nil {
|
|
slog.Warn("Claude API call failed", "error", err)
|
|
h.renderResults(w, imageAdvisorData{Error: "The AI service couldn't generate suggestions right now. Please try again in a minute."})
|
|
return
|
|
}
|
|
|
|
// Parse JSON response
|
|
suggestions, err := parseAdvisorResponse(responseText)
|
|
if err != nil {
|
|
slog.Warn("Failed to parse advisor response", "error", err, "response", responseText)
|
|
h.renderResults(w, imageAdvisorData{Error: "We got a response from the AI service but couldn't read it. Please try again."})
|
|
return
|
|
}
|
|
|
|
// Cache the response
|
|
if err := db.UpsertAdvisorSuggestions(h.DB, digest, responseText); err != nil {
|
|
slog.Warn("Failed to cache advisor suggestions", "error", err)
|
|
}
|
|
|
|
h.renderResults(w, imageAdvisorData{Suggestions: suggestions, Model: advisorModelDisplay})
|
|
}
|
|
|
|
func (h *ImageAdvisorHandler) renderResults(w http.ResponseWriter, data imageAdvisorData) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
if err := h.Templates.ExecuteTemplate(w, "image-advisor-results", data); err != nil {
|
|
slog.Warn("Failed to render image advisor results", "error", err)
|
|
}
|
|
}
|
|
|
|
// fetchAdvisorImageConfig fetches the full OCI image config from the hold.
|
|
func fetchAdvisorImageConfig(ctx context.Context, holdURL, manifestDigest string) (*advisorOCIConfig, error) {
|
|
reqURL := fmt.Sprintf("%s%s?digest=%s",
|
|
strings.TrimSuffix(holdURL, "/"),
|
|
atproto.HoldGetImageConfig,
|
|
url.QueryEscape(manifestDigest),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("hold returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var record struct {
|
|
ConfigJSON string `json:"configJson"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&record); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var config advisorOCIConfig
|
|
if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil {
|
|
return nil, err
|
|
}
|
|
return &config, nil
|
|
}
|
|
|
|
// fetchAdvisorScanData fetches the scan record plus vuln and SBOM blobs.
|
|
func fetchAdvisorScanData(ctx context.Context, holdURL, holdDID, digest string) (*atproto.ScanRecord, *grypeReport, *advisorSPDX) {
|
|
rkey := strings.TrimPrefix(digest, "sha256:")
|
|
|
|
scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
|
|
strings.TrimSuffix(holdURL, "/"),
|
|
url.QueryEscape(holdDID),
|
|
url.QueryEscape(atproto.ScanCollection),
|
|
url.QueryEscape(rkey),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil)
|
|
if err != nil {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, nil, nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
var envelope struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
var scanRecord atproto.ScanRecord
|
|
if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// Fetch vuln report blob
|
|
var vulnReport *grypeReport
|
|
if scanRecord.VulnReportBlob != nil && scanRecord.VulnReportBlob.Ref.String() != "" {
|
|
vulnReport = fetchAdvisorBlob[grypeReport](ctx, holdURL, holdDID, scanRecord.VulnReportBlob.Ref.String())
|
|
}
|
|
|
|
// Fetch SBOM blob
|
|
var sbom *advisorSPDX
|
|
if scanRecord.SbomBlob != nil && scanRecord.SbomBlob.Ref.String() != "" {
|
|
sbom = fetchAdvisorBlob[advisorSPDX](ctx, holdURL, holdDID, scanRecord.SbomBlob.Ref.String())
|
|
}
|
|
|
|
return &scanRecord, vulnReport, sbom
|
|
}
|
|
|
|
// fetchAdvisorBlob fetches and JSON-decodes a blob from the hold.
|
|
func fetchAdvisorBlob[T any](ctx context.Context, holdURL, holdDID, cid string) *T {
|
|
blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
|
|
strings.TrimSuffix(holdURL, "/"),
|
|
url.QueryEscape(holdDID),
|
|
url.QueryEscape(cid),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil
|
|
}
|
|
|
|
var result T
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil
|
|
}
|
|
return &result
|
|
}
|
|
|
|
// generateAdvisorPrompt writes the system+data prompt for the AI advisor.
|
|
func generateAdvisorPrompt(w io.Writer, r *advisorReportData) {
|
|
// Data block
|
|
ref := r.Handle + "/" + r.Repository
|
|
totalSize := int64(0)
|
|
for _, l := range r.Layers {
|
|
totalSize += l.Size
|
|
}
|
|
|
|
fmt.Fprintf(w, "image: %s\ndigest: %s\n", ref, r.Digest)
|
|
if r.Platform != "" {
|
|
fmt.Fprintf(w, "platform: %s\n", r.Platform)
|
|
}
|
|
fmt.Fprintf(w, "total_size: %s\nlayers: %d\n", advisorHumanSize(totalSize), len(r.Layers))
|
|
|
|
if r.Config != nil {
|
|
c := r.Config.Config
|
|
user := c.User
|
|
if user == "" {
|
|
user = "root"
|
|
}
|
|
fmt.Fprintf(w, "user: %s\n", user)
|
|
if c.WorkingDir != "" {
|
|
fmt.Fprintf(w, "workdir: %s\n", c.WorkingDir)
|
|
}
|
|
if len(c.Entrypoint) > 0 {
|
|
fmt.Fprintf(w, "entrypoint: %s\n", strings.Join(c.Entrypoint, " "))
|
|
}
|
|
if len(c.Cmd) > 0 {
|
|
fmt.Fprintf(w, "cmd: %s\n", strings.Join(c.Cmd, " "))
|
|
}
|
|
if len(c.ExposedPorts) > 0 {
|
|
ports := make([]string, 0, len(c.ExposedPorts))
|
|
for p := range c.ExposedPorts {
|
|
ports = append(ports, p)
|
|
}
|
|
fmt.Fprintf(w, "ports: %s\n", strings.Join(ports, ","))
|
|
}
|
|
if len(c.Env) > 0 {
|
|
fmt.Fprintln(w, "env:")
|
|
for _, env := range c.Env {
|
|
parts := strings.SplitN(env, "=", 2)
|
|
if advisorShouldRedact(parts[0]) {
|
|
fmt.Fprintf(w, " - %s=[REDACTED]\n", parts[0])
|
|
} else {
|
|
fmt.Fprintf(w, " - %s\n", env)
|
|
}
|
|
}
|
|
}
|
|
if len(c.Labels) > 0 {
|
|
fmt.Fprintln(w, "labels:")
|
|
keys := make([]string, 0, len(c.Labels))
|
|
for k := range c.Labels {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
v := c.Labels[k]
|
|
if len(v) > 80 {
|
|
v = v[:77] + "..."
|
|
}
|
|
fmt.Fprintf(w, " %s: %s\n", k, v)
|
|
}
|
|
}
|
|
|
|
// History with layer sizes
|
|
fmt.Fprintln(w, "history:")
|
|
layerIdx := 0
|
|
for _, h := range r.Config.History {
|
|
cmd := advisorCleanCommand(h.CreatedBy)
|
|
if len(cmd) > 100 {
|
|
cmd = cmd[:97] + "..."
|
|
}
|
|
if !h.EmptyLayer && layerIdx < len(r.Layers) {
|
|
fmt.Fprintf(w, " - [%s] %s\n", advisorHumanSize(r.Layers[layerIdx].Size), cmd)
|
|
layerIdx++
|
|
} else {
|
|
fmt.Fprintf(w, " - %s\n", cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vulnerability summary
|
|
if r.ScanRecord != nil {
|
|
sr := r.ScanRecord
|
|
if vulnScanDidNotRun(sr) {
|
|
// Counts of zero here would be read as "no vulnerabilities", but
|
|
// this record was written by a scan that never ran a vulnerability
|
|
// database against the image.
|
|
fmt.Fprintf(w, "vulns: not scanned (SBOM only, no vulnerability data)\n")
|
|
} else {
|
|
fmt.Fprintf(w, "vulns: {critical: %d, high: %d, medium: %d, low: %d, total: %d}\n",
|
|
sr.Critical, sr.High, sr.Medium, sr.Low, sr.Total)
|
|
}
|
|
}
|
|
|
|
// Fixable critical/high vulns
|
|
if r.VulnReport != nil {
|
|
type pkgInfo struct {
|
|
version string
|
|
typ string
|
|
fixes map[string]bool
|
|
cves []string
|
|
maxSev int
|
|
}
|
|
pkgs := map[string]*pkgInfo{}
|
|
|
|
for _, m := range r.VulnReport.Matches {
|
|
sev := m.Vulnerability.Metadata.Severity
|
|
if sev != "Critical" && sev != "High" {
|
|
continue
|
|
}
|
|
key := m.Package.Name
|
|
p, ok := pkgs[key]
|
|
if !ok {
|
|
p = &pkgInfo{version: m.Package.Version, typ: m.Package.Type, fixes: map[string]bool{}, maxSev: 5}
|
|
pkgs[key] = p
|
|
}
|
|
p.cves = append(p.cves, m.Vulnerability.ID)
|
|
for _, f := range m.Vulnerability.Fix.Versions {
|
|
p.fixes[f] = true
|
|
}
|
|
if s := advisorSeverityRank(sev); s < p.maxSev {
|
|
p.maxSev = s
|
|
}
|
|
}
|
|
|
|
if len(pkgs) > 0 {
|
|
fmt.Fprintln(w, "fixable_critical_high:")
|
|
type entry struct {
|
|
name string
|
|
info *pkgInfo
|
|
}
|
|
sorted := make([]entry, 0, len(pkgs))
|
|
for n, p := range pkgs {
|
|
sorted = append(sorted, entry{n, p})
|
|
}
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
if sorted[i].info.maxSev != sorted[j].info.maxSev {
|
|
return sorted[i].info.maxSev < sorted[j].info.maxSev
|
|
}
|
|
return len(sorted[i].info.cves) > len(sorted[j].info.cves)
|
|
})
|
|
|
|
for _, e := range sorted {
|
|
fixes := make([]string, 0, len(e.info.fixes))
|
|
for f := range e.info.fixes {
|
|
fixes = append(fixes, f)
|
|
}
|
|
sort.Strings(fixes)
|
|
fmt.Fprintf(w, " - pkg: %s@%s (%s) cves: %d fix: %s\n",
|
|
e.name, e.info.version, e.info.typ, len(e.info.cves), strings.Join(fixes, ","))
|
|
}
|
|
}
|
|
|
|
// Unfixable counts
|
|
unfixable := map[string]int{}
|
|
for _, m := range r.VulnReport.Matches {
|
|
if len(m.Vulnerability.Fix.Versions) == 0 {
|
|
unfixable[m.Vulnerability.Metadata.Severity]++
|
|
}
|
|
}
|
|
if len(unfixable) > 0 {
|
|
fmt.Fprintf(w, "unfixable:")
|
|
for _, sev := range []string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"} {
|
|
if c, ok := unfixable[sev]; ok {
|
|
fmt.Fprintf(w, " %s=%d", strings.ToLower(sev), c)
|
|
}
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
}
|
|
|
|
// SBOM summary
|
|
if r.SBOM != nil {
|
|
typeCounts := map[string]int{}
|
|
total := 0
|
|
for _, p := range r.SBOM.Packages {
|
|
if strings.HasPrefix(p.SPDXID, "SPDXRef-DocumentRoot") || p.SPDXID == "SPDXRef-DOCUMENT" {
|
|
continue
|
|
}
|
|
total++
|
|
pkgType := advisorExtractPackageType(p.Supplier)
|
|
if pkgType == "" {
|
|
pkgType = "other"
|
|
}
|
|
typeCounts[pkgType]++
|
|
}
|
|
fmt.Fprintf(w, "sbom_packages: %d", total)
|
|
for t, c := range typeCounts {
|
|
fmt.Fprintf(w, " %s=%d", t, c)
|
|
}
|
|
fmt.Fprintln(w)
|
|
|
|
if r.VulnReport != nil {
|
|
vulnPkgs := map[string]int{}
|
|
for _, m := range r.VulnReport.Matches {
|
|
vulnPkgs[m.Package.Name]++
|
|
}
|
|
type pv struct {
|
|
name string
|
|
count int
|
|
}
|
|
sorted := make([]pv, 0, len(vulnPkgs))
|
|
for n, c := range vulnPkgs {
|
|
sorted = append(sorted, pv{n, c})
|
|
}
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count })
|
|
if len(sorted) > 10 {
|
|
sorted = sorted[:10]
|
|
}
|
|
fmt.Fprintln(w, "top_vulnerable_packages:")
|
|
for _, p := range sorted {
|
|
fmt.Fprintf(w, " - %s: %d\n", p.name, p.count)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// callClaudeAPI sends the prompt to Claude Haiku using tool use and returns the structured JSON.
|
|
func callClaudeAPI(ctx context.Context, apiKey, prompt string) (string, error) {
|
|
reqBody := map[string]any{
|
|
"model": advisorModel,
|
|
"max_tokens": 2048,
|
|
"system": "Analyze the container image data. Provide actionable suggestions sorted by impact (highest first).",
|
|
"tools": []map[string]any{{
|
|
"name": "suggest_fixes",
|
|
"description": "Return actionable suggestions for improving a container image, sorted by impact.",
|
|
"input_schema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"suggestions": map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"action": map[string]any{"type": "string", "description": "Specific actionable step"},
|
|
"category": map[string]any{"type": "string", "enum": []string{"vulnerability", "size", "cache", "security", "best-practice"}},
|
|
"impact": map[string]any{"type": "string", "enum": []string{"high", "medium", "low"}},
|
|
"effort": map[string]any{"type": "string", "enum": []string{"low", "medium", "high"}},
|
|
"cves_fixed": map[string]any{"type": "integer", "description": "Number of CVEs fixed, or 0"},
|
|
"size_saved_mb": map[string]any{"type": "integer", "description": "Estimated MB saved, or 0"},
|
|
"detail": map[string]any{"type": "string", "description": "One sentence with specific package names, versions, or commands"},
|
|
},
|
|
"required": []string{"action", "category", "impact", "effort", "cves_fixed", "size_saved_mb", "detail"},
|
|
},
|
|
},
|
|
},
|
|
"required": []string{"suggestions"},
|
|
},
|
|
}},
|
|
"tool_choice": map[string]any{"type": "tool", "name": "suggest_fixes"},
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": prompt},
|
|
},
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return "", fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("x-api-key", apiKey)
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
req.Header.Set("content-type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("API request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var apiResp struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Input json.RawMessage `json:"input"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
return "", fmt.Errorf("parse response: %w", err)
|
|
}
|
|
|
|
for _, c := range apiResp.Content {
|
|
if c.Type == "tool_use" && c.Name == "suggest_fixes" {
|
|
return string(c.Input), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no tool_use content in response")
|
|
}
|
|
|
|
// parseAdvisorResponse parses the JSON suggestions (from API or cache) into suggestions.
|
|
func parseAdvisorResponse(jsonStr string) ([]advisorSuggestion, error) {
|
|
var result struct {
|
|
Suggestions []advisorSuggestion `json:"suggestions"`
|
|
}
|
|
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result.Suggestions, nil
|
|
}
|
|
|
|
// Prompt helper functions
|
|
|
|
func advisorHumanSize(bytes int64) string {
|
|
const (
|
|
KB = 1024
|
|
MB = 1024 * KB
|
|
GB = 1024 * MB
|
|
)
|
|
switch {
|
|
case bytes >= GB:
|
|
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB))
|
|
case bytes >= MB:
|
|
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
|
|
case bytes >= KB:
|
|
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
|
|
default:
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
}
|
|
|
|
func advisorCleanCommand(cmd string) string {
|
|
cmd = strings.TrimPrefix(cmd, "/bin/sh -c ")
|
|
cmd = strings.TrimPrefix(cmd, "#(nop) ")
|
|
return strings.TrimSpace(cmd)
|
|
}
|
|
|
|
func advisorShouldRedact(envName string) bool {
|
|
upper := strings.ToUpper(envName)
|
|
for _, suffix := range []string{"_KEY", "_SECRET", "_PASSWORD", "_TOKEN", "_CREDENTIALS", "_API_KEY"} {
|
|
if strings.HasSuffix(upper, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func advisorSeverityRank(s string) int {
|
|
switch s {
|
|
case "Critical":
|
|
return 0
|
|
case "High":
|
|
return 1
|
|
case "Medium":
|
|
return 2
|
|
case "Low":
|
|
return 3
|
|
case "Negligible":
|
|
return 4
|
|
default:
|
|
return 5
|
|
}
|
|
}
|
|
|
|
func advisorExtractPackageType(supplier string) string {
|
|
s := strings.ToLower(supplier)
|
|
switch {
|
|
case strings.Contains(s, "npmjs") || strings.Contains(s, "npm"):
|
|
return "npm"
|
|
case strings.Contains(s, "pypi") || strings.Contains(s, "python"):
|
|
return "python"
|
|
case strings.Contains(s, "rubygems"):
|
|
return "gem"
|
|
case strings.Contains(s, "golang") || strings.Contains(s, "go"):
|
|
return "go"
|
|
case strings.Contains(s, "debian") || strings.Contains(s, "ubuntu"):
|
|
return "deb"
|
|
case strings.Contains(s, "alpine"):
|
|
return "apk"
|
|
case strings.Contains(s, "redhat") || strings.Contains(s, "fedora") || strings.Contains(s, "centos"):
|
|
return "rpm"
|
|
case strings.Contains(s, "maven") || strings.Contains(s, "java"):
|
|
return "java"
|
|
case strings.Contains(s, "nuget") || strings.Contains(s, ".net"):
|
|
return "nuget"
|
|
case strings.Contains(s, "cargo") || strings.Contains(s, "rust"):
|
|
return "rust"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|