Files
Evan JarrettandClaude Opus 5 a63f668de0 scanner: fix five crash and halt classes found by a pipeline audit
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
2026-09-05 15:01:10 -05:00

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 ""
}
}