1 line
138 KiB
JSON
1 line
138 KiB
JSON
{"repo": "balmha/tf-triage", "files": {"examples/bitbucket-pipelines.yml": {"5e0b96cbe7d1f0d21a6070ddc1f8bcfd3f4be987": {"t": 1785745252.067487, "n": 788, "h": "297e58a64aa7f74c", "c": "# Bitbucket Pipelines configuration for tf-triage\n# Posts a security and architecture analysis as a Pull Request comment.\n#\n# Requirements:\n# - Terraform/OpenTofu configured in the repo\n# - DEEPSEEK_API_KEY set as a repository variable\n# - BITBUCKET_TOKEN set as a repository variable (App Password or Repository Access Token)\n\nimage: hashicorp/terraform:1.5\n\npipelines:\n pull-requests:\n '**':\n - step:\n name: Triage Terraform Plan\n script:\n - cd infra\n - terraform init\n - terraform plan -json > ../plan.json\n - cd ..\n - curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n - tf-triage --file plan.json --provider deepseek\n - tf-triage comment\n"}, "cd922f6c4f6ac0768cf32fba16c121dc23fe73e5": {"t": 1785749253.9593003, "n": 788, "h": "297e58a64aa7f74c", "c": "# Bitbucket Pipelines configuration for tf-triage\n# Posts a security and architecture analysis as a Pull Request comment.\n#\n# Requirements:\n# - Terraform/OpenTofu configured in the repo\n# - DEEPSEEK_API_KEY set as a repository variable\n# - BITBUCKET_TOKEN set as a repository variable (App Password or Repository Access Token)\n\nimage: hashicorp/terraform:1.5\n\npipelines:\n pull-requests:\n '**':\n - step:\n name: Triage Terraform Plan\n script:\n - cd infra\n - terraform init\n - terraform plan -json > ../plan.json\n - cd ..\n - curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n - tf-triage --file plan.json --provider deepseek\n - tf-triage comment\n"}, "bee08befd1a926ee4602f26b914dc2aebd6b185f": {"t": 1785749255.0498252, "n": 1064, "h": "6297049bbfbd3ca6", "c": "# Bitbucket Pipelines configuration for tf-triage\n# Posts a security and architecture analysis as a Pull Request comment.\n#\n# Requirements:\n# - Terraform/OpenTofu configured in the repo\n# - GROQ_API_KEY set as a repository variable\n# - BITBUCKET_TOKEN set as a repository variable (App Password or Repository Access Token)\n\nimage: hashicorp/terraform:1.5\n\npipelines:\n pull-requests:\n '**':\n - step:\n name: Terraform Plan\n script:\n - cd infra\n - terraform init\n - terraform plan -out=plan.tfplan\n - terraform show -json plan.tfplan > ../plan.json\n artifacts:\n - plan.json\n\n - step:\n name: tf-triage Analysis\n image: golang:1.21-alpine\n script:\n - apk add --no-cache curl bash\n - curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n - tf-triage -f plan.json -p groq\n - tf-triage comment\n after-script:\n - echo \"tf-triage analysis complete\"\n"}}, "pkg/llm/client.go": {"5e0b96cbe7d1f0d21a6070ddc1f8bcfd3f4be987": {"t": 1785745626.5249548, "n": 13986, "h": "f9aeb30ea961f65b", "c": "// Package llm provides a provider-agnostic interface for sending Terraform plan\n// analysis requests to LLM APIs (Anthropic Claude, OpenAI GPT, Ollama, Groq, DeepSeek, Gemini).\n//\n// It uses a factory pattern to instantiate the correct provider client based on\n// configuration, and enforces timeout contexts on all HTTP calls.\npackage llm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tf-triage/tf-triage/pkg/parser\"\n)\n\n// ---------------------------------------------------------------------------\n// Typed errors\n// ---------------------------------------------------------------------------\n\n// ErrUnsupportedProvider indicates the requested provider is not implemented.\nvar ErrUnsupportedProvider = errors.New(\"unsupported LLM provider\")\n\n// ErrAPIKeyMissing indicates the required API key was not provided.\nvar ErrAPIKeyMissing = errors.New(\"API key not configured\")\n\n// ErrAPITimeout indicates the LLM API did not respond within the deadline.\nvar ErrAPITimeout = errors.New(\"LLM API request timed out\")\n\n// ErrAPIFailure indicates the LLM API returned a non-200 response.\nvar ErrAPIFailure = errors.New(\"LLM API returned an error\")\n\n// ErrOllamaConnRefused indicates the local Ollama daemon is not reachable.\nvar ErrOllamaConnRefused = errors.New(\"could not connect to local Ollama instance. Is Ollama running on http://localhost:11434?\")\n\n// ---------------------------------------------------------------------------\n// Configuration\n// ---------------------------------------------------------------------------\n\n// Config holds all settings needed to call an LLM provider.\ntype Config struct {\n\tProvider string // \"ollama\", \"groq\", \"deepseek\", \"gemini\", \"anthropic\", or \"openai\"\n\tModel string // Model identifier\n\tAPIKey string // Provider API key (empty for ollama)\n\tTimeout time.Duration // HTTP request timeout (default: 120s)\n\tSystemPrompt string // Custom system prompt (uses default if empty)\n}\n\n// DefaultTimeout is applied when Config.Timeout is zero.\nconst DefaultTimeout = 120 * time.Second\n\n// ---------------------------------------------------------------------------\n// Provider interface & factory\n// ---------------------------------------------------------------------------\n\n// Provider defines the contract for an LLM backend.\ntype Provider interface {\n\t// Analyze sends the optimized plan to the LLM and returns Markdown output.\n\tAnalyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error)\n}\n\n// NewProvider is the factory function that returns the appropriate Provider\n// implementation based on the config.\nfunc NewProvider(cfg Config) (Provider, error) {\n\ttimeout := cfg.Timeout\n\tif timeout == 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\tswitch strings.ToLower(cfg.Provider) {\n\tcase \"ollama\":\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"http://localhost:11434/v1/chat/completions\",\n\t\t\tapiKey: \"\", // No auth required\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"ollama\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"groq\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set GROQ_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.groq.com/openai/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"groq\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"deepseek\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set DEEPSEEK_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.deepseek.com/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"deepseek\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"gemini\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set GEMINI_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"gemini\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"anthropic\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set ANTHROPIC_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &anthropicProvider{\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"openai\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set OPENAI_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.openai.com/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"openai\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"%w: %q (supported: ollama, groq, deepseek, gemini, anthropic, openai)\",\n\t\t\tErrUnsupportedProvider, cfg.Provider)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// System prompt (shared across providers)\n// ---------------------------------------------------------------------------\n\nconst systemPrompt = `You are an elite Cloud Security Architect and Platform Engineer.\n\nObjective: Analyze the provided Terraform/OpenTofu plan changes.\n\nGenerate a clean Markdown report with the following three sections:\n\n## Executive Summary\nProvide a 2-3 sentence explanation of what this infrastructure change accomplishes in plain English.\nFor example: \"This plan provisions public-facing web servers and locks down backend databases.\"\nFocus on conveying the semantic intent of the change to a non-expert reviewer.\n\n## Security & Architectural Audit\nHighlight potential risks with a focus on:\n- Defense-in-depth gaps\n- IAM role privilege escalation or over-scoping\n- Over-exposed S3 buckets or security groups (0.0.0.0/0 ingress, public ACLs)\n- Missing encryption at rest (EBS, RDS, S3, DynamoDB)\n- Lacking resource-level boundaries or blast radius containment\n- Absent logging, monitoring, or audit trails\n\nDo not just cite static rules. Evaluate the semantic intent of the resource layout and flag where the architecture diverges from security best practices. Reference resource addresses directly.\n\n## Blast Radius Assessment\nClassify the overall impact as LOW, MEDIUM, or HIGH:\n- LOW: Additive changes only, no destructive actions, minimal scope.\n- MEDIUM: Updates to existing resources, moderate scope, or changes to networking/IAM.\n- HIGH: Destructive changes (delete/recreate), structural migrations, high-cost modifications, or broad permission changes.\n\nProvide a brief justification for the classification.\n\nRules:\n- Be concise, specific, and actionable.\n- Use Markdown formatting: headers, bullet points, and code blocks where appropriate.\n- If no security issues are found, explicitly state that the plan looks safe.\n- Do NOT include any preamble, conversational text, or sign-off. Output ONLY the Markdown report.`\n\n// buildUserMessage serializes the optimized plan into the user prompt.\nfunc buildUserMessage(plan *parser.OptimizedPlan) (string, error) {\n\tpayload, err := json.Marshal(plan)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to serialize plan for LLM: %w\", err)\n\t}\n\treturn fmt.Sprintf(\"Analyze the following Terraform/OpenTofu plan changes:\\n\\n```json\\n%s\\n```\", string(payload)), nil\n}\n\n// ---------------------------------------------------------------------------\n// OpenAI-compatible provider (used by OpenAI, Ollama, and Groq)\n// ---------------------------------------------------------------------------\n\ntype openaiCompatProvider struct {\n\tbaseURL string\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n\tname string // \"openai\", \"ollama\", \"groq\", \"deepseek\", or \"gemini\" — for error messages\n\tcustomPrompt string // overrides systemPrompt if set\n}\n\ntype openaiReq struct {\n\tModel string `json:\"model\"`\n\tMessages []openaiMsg `json:\"messages\"`\n}\n\ntype openaiMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype openaiResp struct {\n\tChoices []struct {\n\t\tMessage struct {\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"message\"`\n\t} `json:\"choices\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *openaiCompatProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tprompt := systemPrompt\n\tif p.customPrompt != \"\" {\n\t\tprompt = p.customPrompt\n\t}\n\n\treqBody := openaiReq{\n\t\tModel: p.model,\n\t\tMessages: []openaiMsg{\n\t\t\t{Role: \"system\", Content: prompt},\n\t\t\t{Role: \"user\", Content: userMsg},\n\t\t},\n\t}\n\n\t// Build headers\n\theaders := map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t}\n\tif p.apiKey != \"\" {\n\t\theaders[\"Authorization\"] = \"Bearer \" + p.apiKey\n\t}\n\n\trespBytes, err := doPost(ctx, p.client, p.baseURL, headers, reqBody)\n\tif err != nil {\n\t\t// Check for Ollama connection refused\n\t\tif p.name == \"ollama\" && isConnectionRefused(err) {\n\t\t\treturn \"\", ErrOllamaConnRefused\n\t\t}\n\t\treturn \"\", err\n\t}\n\n\tvar resp openaiResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode %s response: %w\", p.name, err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: %s returned no choices\", ErrAPIFailure, p.name)\n\t}\n\n\treturn resp.Choices[0].Message.Content, nil\n}\n\n// ---------------------------------------------------------------------------\n// Anthropic (Claude) Messages API\n// ---------------------------------------------------------------------------\n\nconst anthropicURL = \"https://api.anthropic.com/v1/messages\"\n\ntype anthropicProvider struct {\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n\tcustomPrompt string\n}\n\ntype anthropicReq struct {\n\tModel string `json:\"model\"`\n\tMaxTokens int `json:\"max_tokens\"`\n\tSystem string `json:\"system\"`\n\tMessages []anthropicMsg `json:\"messages\"`\n}\n\ntype anthropicMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype anthropicResp struct {\n\tContent []struct {\n\t\tType string `json:\"type\"`\n\t\tText string `json:\"text\"`\n\t} `json:\"content\"`\n\tError *struct {\n\t\tType string `json:\"type\"`\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *anthropicProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tprompt := systemPrompt\n\tif p.customPrompt != \"\" {\n\t\tprompt = p.customPrompt\n\t}\n\n\treqBody := anthropicReq{\n\t\tModel: p.model,\n\t\tMaxTokens: 4096,\n\t\tSystem: prompt,\n\t\tMessages: []anthropicMsg{{Role: \"user\", Content: userMsg}},\n\t}\n\n\trespBytes, err := p.doRequest(ctx, reqBody)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar resp anthropicResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode Anthropic response: %w\", err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Content) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: Anthropic returned empty content\", ErrAPIFailure)\n\t}\n\n\tvar sb strings.Builder\n\tfor _, block := range resp.Content {\n\t\tif block.Type == \"text\" {\n\t\t\tsb.WriteString(block.Text)\n\t\t}\n\t}\n\treturn sb.String(), nil\n}\n\nfunc (p *anthropicProvider) doRequest(ctx context.Context, body interface{}) ([]byte, error) {\n\treturn doPost(ctx, p.client, anthropicURL, map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t\t\"x-api-key\": p.apiKey,\n\t\t\"anthropic-version\": \"2023-06-01\",\n\t}, body)\n}\n\n// ---------------------------------------------------------------------------\n// Shared HTTP helper with context support\n// ---------------------------------------------------------------------------\n\nfunc doPost(ctx context.Context, client *http.Client, url string, headers map[string]string, payload interface{}) ([]byte, error) {\n\tbodyBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build HTTP request: %w\", err)\n\t}\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: the request exceeded the configured timeout\", ErrAPITimeout)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%w (HTTP %d): %s\", ErrAPIFailure, resp.StatusCode, truncateBody(data, 500))\n\t}\n\n\treturn data, nil\n}\n\n// truncateBody limits error response bodies to avoid flooding the terminal.\nfunc truncateBody(data []byte, maxLen int) string {\n\ts := string(data)\n\tif len(s) > maxLen {\n\t\treturn s[:maxLen] + \"... (truncated)\"\n\t}\n\treturn s\n}\n\n// isConnectionRefused checks if the error is a TCP connection refused.\nfunc isConnectionRefused(err error) bool {\n\tvar netErr *net.OpError\n\tif errors.As(err, &netErr) {\n\t\treturn true\n\t}\n\t// Also check the stringified error for wrapped cases\n\treturn strings.Contains(err.Error(), \"connection refused\") ||\n\t\tstrings.Contains(err.Error(), \"connect: connection refused\")\n}\n"}, "067ed6f5226bcb1287e44784d4717d18818738e4": {"t": 1785749253.7166193, "n": 13986, "h": "f9aeb30ea961f65b", "c": "// Package llm provides a provider-agnostic interface for sending Terraform plan\n// analysis requests to LLM APIs (Anthropic Claude, OpenAI GPT, Ollama, Groq, DeepSeek, Gemini).\n//\n// It uses a factory pattern to instantiate the correct provider client based on\n// configuration, and enforces timeout contexts on all HTTP calls.\npackage llm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tf-triage/tf-triage/pkg/parser\"\n)\n\n// ---------------------------------------------------------------------------\n// Typed errors\n// ---------------------------------------------------------------------------\n\n// ErrUnsupportedProvider indicates the requested provider is not implemented.\nvar ErrUnsupportedProvider = errors.New(\"unsupported LLM provider\")\n\n// ErrAPIKeyMissing indicates the required API key was not provided.\nvar ErrAPIKeyMissing = errors.New(\"API key not configured\")\n\n// ErrAPITimeout indicates the LLM API did not respond within the deadline.\nvar ErrAPITimeout = errors.New(\"LLM API request timed out\")\n\n// ErrAPIFailure indicates the LLM API returned a non-200 response.\nvar ErrAPIFailure = errors.New(\"LLM API returned an error\")\n\n// ErrOllamaConnRefused indicates the local Ollama daemon is not reachable.\nvar ErrOllamaConnRefused = errors.New(\"could not connect to local Ollama instance. Is Ollama running on http://localhost:11434?\")\n\n// ---------------------------------------------------------------------------\n// Configuration\n// ---------------------------------------------------------------------------\n\n// Config holds all settings needed to call an LLM provider.\ntype Config struct {\n\tProvider string // \"ollama\", \"groq\", \"deepseek\", \"gemini\", \"anthropic\", or \"openai\"\n\tModel string // Model identifier\n\tAPIKey string // Provider API key (empty for ollama)\n\tTimeout time.Duration // HTTP request timeout (default: 120s)\n\tSystemPrompt string // Custom system prompt (uses default if empty)\n}\n\n// DefaultTimeout is applied when Config.Timeout is zero.\nconst DefaultTimeout = 120 * time.Second\n\n// ---------------------------------------------------------------------------\n// Provider interface & factory\n// ---------------------------------------------------------------------------\n\n// Provider defines the contract for an LLM backend.\ntype Provider interface {\n\t// Analyze sends the optimized plan to the LLM and returns Markdown output.\n\tAnalyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error)\n}\n\n// NewProvider is the factory function that returns the appropriate Provider\n// implementation based on the config.\nfunc NewProvider(cfg Config) (Provider, error) {\n\ttimeout := cfg.Timeout\n\tif timeout == 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\tswitch strings.ToLower(cfg.Provider) {\n\tcase \"ollama\":\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"http://localhost:11434/v1/chat/completions\",\n\t\t\tapiKey: \"\", // No auth required\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"ollama\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"groq\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set GROQ_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.groq.com/openai/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"groq\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"deepseek\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set DEEPSEEK_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.deepseek.com/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"deepseek\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"gemini\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set GEMINI_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"gemini\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"anthropic\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set ANTHROPIC_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &anthropicProvider{\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tcase \"openai\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set OPENAI_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.openai.com/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"openai\",\n\t\t\tcustomPrompt: cfg.SystemPrompt,\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"%w: %q (supported: ollama, groq, deepseek, gemini, anthropic, openai)\",\n\t\t\tErrUnsupportedProvider, cfg.Provider)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// System prompt (shared across providers)\n// ---------------------------------------------------------------------------\n\nconst systemPrompt = `You are an elite Cloud Security Architect and Platform Engineer.\n\nObjective: Analyze the provided Terraform/OpenTofu plan changes.\n\nGenerate a clean Markdown report with the following three sections:\n\n## Executive Summary\nProvide a 2-3 sentence explanation of what this infrastructure change accomplishes in plain English.\nFor example: \"This plan provisions public-facing web servers and locks down backend databases.\"\nFocus on conveying the semantic intent of the change to a non-expert reviewer.\n\n## Security & Architectural Audit\nHighlight potential risks with a focus on:\n- Defense-in-depth gaps\n- IAM role privilege escalation or over-scoping\n- Over-exposed S3 buckets or security groups (0.0.0.0/0 ingress, public ACLs)\n- Missing encryption at rest (EBS, RDS, S3, DynamoDB)\n- Lacking resource-level boundaries or blast radius containment\n- Absent logging, monitoring, or audit trails\n\nDo not just cite static rules. Evaluate the semantic intent of the resource layout and flag where the architecture diverges from security best practices. Reference resource addresses directly.\n\n## Blast Radius Assessment\nClassify the overall impact as LOW, MEDIUM, or HIGH:\n- LOW: Additive changes only, no destructive actions, minimal scope.\n- MEDIUM: Updates to existing resources, moderate scope, or changes to networking/IAM.\n- HIGH: Destructive changes (delete/recreate), structural migrations, high-cost modifications, or broad permission changes.\n\nProvide a brief justification for the classification.\n\nRules:\n- Be concise, specific, and actionable.\n- Use Markdown formatting: headers, bullet points, and code blocks where appropriate.\n- If no security issues are found, explicitly state that the plan looks safe.\n- Do NOT include any preamble, conversational text, or sign-off. Output ONLY the Markdown report.`\n\n// buildUserMessage serializes the optimized plan into the user prompt.\nfunc buildUserMessage(plan *parser.OptimizedPlan) (string, error) {\n\tpayload, err := json.Marshal(plan)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to serialize plan for LLM: %w\", err)\n\t}\n\treturn fmt.Sprintf(\"Analyze the following Terraform/OpenTofu plan changes:\\n\\n```json\\n%s\\n```\", string(payload)), nil\n}\n\n// ---------------------------------------------------------------------------\n// OpenAI-compatible provider (used by OpenAI, Ollama, and Groq)\n// ---------------------------------------------------------------------------\n\ntype openaiCompatProvider struct {\n\tbaseURL string\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n\tname string // \"openai\", \"ollama\", \"groq\", \"deepseek\", or \"gemini\" — for error messages\n\tcustomPrompt string // overrides systemPrompt if set\n}\n\ntype openaiReq struct {\n\tModel string `json:\"model\"`\n\tMessages []openaiMsg `json:\"messages\"`\n}\n\ntype openaiMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype openaiResp struct {\n\tChoices []struct {\n\t\tMessage struct {\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"message\"`\n\t} `json:\"choices\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *openaiCompatProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tprompt := systemPrompt\n\tif p.customPrompt != \"\" {\n\t\tprompt = p.customPrompt\n\t}\n\n\treqBody := openaiReq{\n\t\tModel: p.model,\n\t\tMessages: []openaiMsg{\n\t\t\t{Role: \"system\", Content: prompt},\n\t\t\t{Role: \"user\", Content: userMsg},\n\t\t},\n\t}\n\n\t// Build headers\n\theaders := map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t}\n\tif p.apiKey != \"\" {\n\t\theaders[\"Authorization\"] = \"Bearer \" + p.apiKey\n\t}\n\n\trespBytes, err := doPost(ctx, p.client, p.baseURL, headers, reqBody)\n\tif err != nil {\n\t\t// Check for Ollama connection refused\n\t\tif p.name == \"ollama\" && isConnectionRefused(err) {\n\t\t\treturn \"\", ErrOllamaConnRefused\n\t\t}\n\t\treturn \"\", err\n\t}\n\n\tvar resp openaiResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode %s response: %w\", p.name, err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: %s returned no choices\", ErrAPIFailure, p.name)\n\t}\n\n\treturn resp.Choices[0].Message.Content, nil\n}\n\n// ---------------------------------------------------------------------------\n// Anthropic (Claude) Messages API\n// ---------------------------------------------------------------------------\n\nconst anthropicURL = \"https://api.anthropic.com/v1/messages\"\n\ntype anthropicProvider struct {\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n\tcustomPrompt string\n}\n\ntype anthropicReq struct {\n\tModel string `json:\"model\"`\n\tMaxTokens int `json:\"max_tokens\"`\n\tSystem string `json:\"system\"`\n\tMessages []anthropicMsg `json:\"messages\"`\n}\n\ntype anthropicMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype anthropicResp struct {\n\tContent []struct {\n\t\tType string `json:\"type\"`\n\t\tText string `json:\"text\"`\n\t} `json:\"content\"`\n\tError *struct {\n\t\tType string `json:\"type\"`\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *anthropicProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tprompt := systemPrompt\n\tif p.customPrompt != \"\" {\n\t\tprompt = p.customPrompt\n\t}\n\n\treqBody := anthropicReq{\n\t\tModel: p.model,\n\t\tMaxTokens: 4096,\n\t\tSystem: prompt,\n\t\tMessages: []anthropicMsg{{Role: \"user\", Content: userMsg}},\n\t}\n\n\trespBytes, err := p.doRequest(ctx, reqBody)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar resp anthropicResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode Anthropic response: %w\", err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Content) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: Anthropic returned empty content\", ErrAPIFailure)\n\t}\n\n\tvar sb strings.Builder\n\tfor _, block := range resp.Content {\n\t\tif block.Type == \"text\" {\n\t\t\tsb.WriteString(block.Text)\n\t\t}\n\t}\n\treturn sb.String(), nil\n}\n\nfunc (p *anthropicProvider) doRequest(ctx context.Context, body interface{}) ([]byte, error) {\n\treturn doPost(ctx, p.client, anthropicURL, map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t\t\"x-api-key\": p.apiKey,\n\t\t\"anthropic-version\": \"2023-06-01\",\n\t}, body)\n}\n\n// ---------------------------------------------------------------------------\n// Shared HTTP helper with context support\n// ---------------------------------------------------------------------------\n\nfunc doPost(ctx context.Context, client *http.Client, url string, headers map[string]string, payload interface{}) ([]byte, error) {\n\tbodyBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build HTTP request: %w\", err)\n\t}\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: the request exceeded the configured timeout\", ErrAPITimeout)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%w (HTTP %d): %s\", ErrAPIFailure, resp.StatusCode, truncateBody(data, 500))\n\t}\n\n\treturn data, nil\n}\n\n// truncateBody limits error response bodies to avoid flooding the terminal.\nfunc truncateBody(data []byte, maxLen int) string {\n\ts := string(data)\n\tif len(s) > maxLen {\n\t\treturn s[:maxLen] + \"... (truncated)\"\n\t}\n\treturn s\n}\n\n// isConnectionRefused checks if the error is a TCP connection refused.\nfunc isConnectionRefused(err error) bool {\n\tvar netErr *net.OpError\n\tif errors.As(err, &netErr) {\n\t\treturn true\n\t}\n\t// Also check the stringified error for wrapped cases\n\treturn strings.Contains(err.Error(), \"connection refused\") ||\n\t\tstrings.Contains(err.Error(), \"connect: connection refused\")\n}\n"}, "cfcbbf02c695c8d931951ed4fbc9f25d5e9e845e": {"t": 1785749254.8507247, "n": 13273, "h": "4699ce09f71cbbab", "c": "// Package llm provides a provider-agnostic interface for sending Terraform plan\n// analysis requests to LLM APIs (Anthropic Claude, OpenAI GPT, Ollama, Groq, DeepSeek, Gemini).\n//\n// It uses a factory pattern to instantiate the correct provider client based on\n// configuration, and enforces timeout contexts on all HTTP calls.\npackage llm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tf-triage/tf-triage/pkg/parser\"\n)\n\n// ---------------------------------------------------------------------------\n// Typed errors\n// ---------------------------------------------------------------------------\n\n// ErrUnsupportedProvider indicates the requested provider is not implemented.\nvar ErrUnsupportedProvider = errors.New(\"unsupported LLM provider\")\n\n// ErrAPIKeyMissing indicates the required API key was not provided.\nvar ErrAPIKeyMissing = errors.New(\"API key not configured\")\n\n// ErrAPITimeout indicates the LLM API did not respond within the deadline.\nvar ErrAPITimeout = errors.New(\"LLM API request timed out\")\n\n// ErrAPIFailure indicates the LLM API returned a non-200 response.\nvar ErrAPIFailure = errors.New(\"LLM API returned an error\")\n\n// ErrOllamaConnRefused indicates the local Ollama daemon is not reachable.\nvar ErrOllamaConnRefused = errors.New(\"could not connect to local Ollama instance. Is Ollama running on http://localhost:11434?\")\n\n// ---------------------------------------------------------------------------\n// Configuration\n// ---------------------------------------------------------------------------\n\n// Config holds all settings needed to call an LLM provider.\ntype Config struct {\n\tProvider string // \"ollama\", \"groq\", \"deepseek\", \"gemini\", \"anthropic\", or \"openai\"\n\tModel string // Model identifier\n\tAPIKey string // Provider API key (empty for ollama)\n\tTimeout time.Duration // HTTP request timeout (default: 120s)\n}\n\n// DefaultTimeout is applied when Config.Timeout is zero.\nconst DefaultTimeout = 120 * time.Second\n\n// ---------------------------------------------------------------------------\n// Provider interface & factory\n// ---------------------------------------------------------------------------\n\n// Provider defines the contract for an LLM backend.\ntype Provider interface {\n\t// Analyze sends the optimized plan to the LLM and returns Markdown output.\n\tAnalyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error)\n}\n\n// NewProvider is the factory function that returns the appropriate Provider\n// implementation based on the config.\nfunc NewProvider(cfg Config) (Provider, error) {\n\ttimeout := cfg.Timeout\n\tif timeout == 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\tswitch strings.ToLower(cfg.Provider) {\n\tcase \"ollama\":\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"http://localhost:11434/v1/chat/completions\",\n\t\t\tapiKey: \"\", // No auth required\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"ollama\",\n\t\t}, nil\n\n\tcase \"groq\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set GROQ_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.groq.com/openai/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"groq\",\n\t\t}, nil\n\n\tcase \"deepseek\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set DEEPSEEK_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.deepseek.com/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"deepseek\",\n\t\t}, nil\n\n\tcase \"gemini\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set GEMINI_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"gemini\",\n\t\t}, nil\n\n\tcase \"anthropic\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set ANTHROPIC_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &anthropicProvider{\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t}, nil\n\n\tcase \"openai\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set OPENAI_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.openai.com/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"openai\",\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"%w: %q (supported: ollama, groq, deepseek, gemini, anthropic, openai)\",\n\t\t\tErrUnsupportedProvider, cfg.Provider)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// System prompt (shared across providers)\n// ---------------------------------------------------------------------------\n\nconst systemPrompt = `You are an elite Cloud Security Architect and Platform Engineer.\n\nObjective: Analyze the provided Terraform/OpenTofu plan changes.\n\nGenerate a clean Markdown report with the following three sections:\n\n## Executive Summary\nProvide a 2-3 sentence explanation of what this infrastructure change accomplishes in plain English.\nFor example: \"This plan provisions public-facing web servers and locks down backend databases.\"\nFocus on conveying the semantic intent of the change to a non-expert reviewer.\n\n## Security & Architectural Audit\nHighlight potential risks with a focus on:\n- Defense-in-depth gaps\n- IAM role privilege escalation or over-scoping\n- Over-exposed S3 buckets or security groups (0.0.0.0/0 ingress, public ACLs)\n- Missing encryption at rest (EBS, RDS, S3, DynamoDB)\n- Lacking resource-level boundaries or blast radius containment\n- Absent logging, monitoring, or audit trails\n\nDo not just cite static rules. Evaluate the semantic intent of the resource layout and flag where the architecture diverges from security best practices. Reference resource addresses directly.\n\n## Blast Radius Assessment\nClassify the overall impact as LOW, MEDIUM, or HIGH:\n- LOW: Additive changes only, no destructive actions, minimal scope.\n- MEDIUM: Updates to existing resources, moderate scope, or changes to networking/IAM.\n- HIGH: Destructive changes (delete/recreate), structural migrations, high-cost modifications, or broad permission changes.\n\nProvide a brief justification for the classification.\n\nRules:\n- Be concise, specific, and actionable.\n- Use Markdown formatting: headers, bullet points, and code blocks where appropriate.\n- If no security issues are found, explicitly state that the plan looks safe.\n- Do NOT include any preamble, conversational text, or sign-off. Output ONLY the Markdown report.`\n\n// buildUserMessage serializes the optimized plan into the user prompt.\nfunc buildUserMessage(plan *parser.OptimizedPlan) (string, error) {\n\tpayload, err := json.Marshal(plan)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to serialize plan for LLM: %w\", err)\n\t}\n\treturn fmt.Sprintf(\"Analyze the following Terraform/OpenTofu plan changes:\\n\\n```json\\n%s\\n```\", string(payload)), nil\n}\n\n// ---------------------------------------------------------------------------\n// OpenAI-compatible provider (used by OpenAI, Ollama, and Groq)\n// ---------------------------------------------------------------------------\n\ntype openaiCompatProvider struct {\n\tbaseURL string\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n\tname string // \"openai\", \"ollama\", \"groq\", \"deepseek\", or \"gemini\" — for error messages\n}\n\ntype openaiReq struct {\n\tModel string `json:\"model\"`\n\tMessages []openaiMsg `json:\"messages\"`\n}\n\ntype openaiMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype openaiResp struct {\n\tChoices []struct {\n\t\tMessage struct {\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"message\"`\n\t} `json:\"choices\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *openaiCompatProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treqBody := openaiReq{\n\t\tModel: p.model,\n\t\tMessages: []openaiMsg{\n\t\t\t{Role: \"system\", Content: systemPrompt},\n\t\t\t{Role: \"user\", Content: userMsg},\n\t\t},\n\t}\n\n\t// Build headers\n\theaders := map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t}\n\tif p.apiKey != \"\" {\n\t\theaders[\"Authorization\"] = \"Bearer \" + p.apiKey\n\t}\n\n\trespBytes, err := doPost(ctx, p.client, p.baseURL, headers, reqBody)\n\tif err != nil {\n\t\t// Check for Ollama connection refused\n\t\tif p.name == \"ollama\" && isConnectionRefused(err) {\n\t\t\treturn \"\", ErrOllamaConnRefused\n\t\t}\n\t\treturn \"\", err\n\t}\n\n\tvar resp openaiResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode %s response: %w\", p.name, err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: %s returned no choices\", ErrAPIFailure, p.name)\n\t}\n\n\treturn resp.Choices[0].Message.Content, nil\n}\n\n// ---------------------------------------------------------------------------\n// Anthropic (Claude) Messages API\n// ---------------------------------------------------------------------------\n\nconst anthropicURL = \"https://api.anthropic.com/v1/messages\"\n\ntype anthropicProvider struct {\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n}\n\ntype anthropicReq struct {\n\tModel string `json:\"model\"`\n\tMaxTokens int `json:\"max_tokens\"`\n\tSystem string `json:\"system\"`\n\tMessages []anthropicMsg `json:\"messages\"`\n}\n\ntype anthropicMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype anthropicResp struct {\n\tContent []struct {\n\t\tType string `json:\"type\"`\n\t\tText string `json:\"text\"`\n\t} `json:\"content\"`\n\tError *struct {\n\t\tType string `json:\"type\"`\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *anthropicProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treqBody := anthropicReq{\n\t\tModel: p.model,\n\t\tMaxTokens: 4096,\n\t\tSystem: systemPrompt,\n\t\tMessages: []anthropicMsg{{Role: \"user\", Content: userMsg}},\n\t}\n\n\trespBytes, err := p.doRequest(ctx, reqBody)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar resp anthropicResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode Anthropic response: %w\", err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Content) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: Anthropic returned empty content\", ErrAPIFailure)\n\t}\n\n\tvar sb strings.Builder\n\tfor _, block := range resp.Content {\n\t\tif block.Type == \"text\" {\n\t\t\tsb.WriteString(block.Text)\n\t\t}\n\t}\n\treturn sb.String(), nil\n}\n\nfunc (p *anthropicProvider) doRequest(ctx context.Context, body interface{}) ([]byte, error) {\n\treturn doPost(ctx, p.client, anthropicURL, map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t\t\"x-api-key\": p.apiKey,\n\t\t\"anthropic-version\": \"2023-06-01\",\n\t}, body)\n}\n\n// ---------------------------------------------------------------------------\n// Shared HTTP helper with context support\n// ---------------------------------------------------------------------------\n\nfunc doPost(ctx context.Context, client *http.Client, url string, headers map[string]string, payload interface{}) ([]byte, error) {\n\tbodyBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build HTTP request: %w\", err)\n\t}\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: the request exceeded the configured timeout\", ErrAPITimeout)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%w (HTTP %d): %s\", ErrAPIFailure, resp.StatusCode, truncateBody(data, 500))\n\t}\n\n\treturn data, nil\n}\n\n// truncateBody limits error response bodies to avoid flooding the terminal.\nfunc truncateBody(data []byte, maxLen int) string {\n\ts := string(data)\n\tif len(s) > maxLen {\n\t\treturn s[:maxLen] + \"... (truncated)\"\n\t}\n\treturn s\n}\n\n// isConnectionRefused checks if the error is a TCP connection refused.\nfunc isConnectionRefused(err error) bool {\n\tvar netErr *net.OpError\n\tif errors.As(err, &netErr) {\n\t\treturn true\n\t}\n\t// Also check the stringified error for wrapped cases\n\treturn strings.Contains(err.Error(), \"connection refused\") ||\n\t\tstrings.Contains(err.Error(), \"connect: connection refused\")\n}\n"}, "0923e082167cf8cfeeef9af0e8c32d8703ced63a": {"t": 1785749255.9589012, "n": 12855, "h": "d79a1e57c1844751", "c": "// Package llm provides a provider-agnostic interface for sending Terraform plan\n// analysis requests to LLM APIs (Anthropic Claude, OpenAI GPT, Ollama, Groq, DeepSeek).\n//\n// It uses a factory pattern to instantiate the correct provider client based on\n// configuration, and enforces timeout contexts on all HTTP calls.\npackage llm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tf-triage/tf-triage/pkg/parser\"\n)\n\n// ---------------------------------------------------------------------------\n// Typed errors\n// ---------------------------------------------------------------------------\n\n// ErrUnsupportedProvider indicates the requested provider is not implemented.\nvar ErrUnsupportedProvider = errors.New(\"unsupported LLM provider\")\n\n// ErrAPIKeyMissing indicates the required API key was not provided.\nvar ErrAPIKeyMissing = errors.New(\"API key not configured\")\n\n// ErrAPITimeout indicates the LLM API did not respond within the deadline.\nvar ErrAPITimeout = errors.New(\"LLM API request timed out\")\n\n// ErrAPIFailure indicates the LLM API returned a non-200 response.\nvar ErrAPIFailure = errors.New(\"LLM API returned an error\")\n\n// ErrOllamaConnRefused indicates the local Ollama daemon is not reachable.\nvar ErrOllamaConnRefused = errors.New(\"could not connect to local Ollama instance. Is Ollama running on http://localhost:11434?\")\n\n// ---------------------------------------------------------------------------\n// Configuration\n// ---------------------------------------------------------------------------\n\n// Config holds all settings needed to call an LLM provider.\ntype Config struct {\n\tProvider string // \"ollama\", \"groq\", \"deepseek\", \"anthropic\", or \"openai\"\n\tModel string // Model identifier\n\tAPIKey string // Provider API key (empty for ollama)\n\tTimeout time.Duration // HTTP request timeout (default: 120s)\n}\n\n// DefaultTimeout is applied when Config.Timeout is zero.\nconst DefaultTimeout = 120 * time.Second\n\n// ---------------------------------------------------------------------------\n// Provider interface & factory\n// ---------------------------------------------------------------------------\n\n// Provider defines the contract for an LLM backend.\ntype Provider interface {\n\t// Analyze sends the optimized plan to the LLM and returns Markdown output.\n\tAnalyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error)\n}\n\n// NewProvider is the factory function that returns the appropriate Provider\n// implementation based on the config.\nfunc NewProvider(cfg Config) (Provider, error) {\n\ttimeout := cfg.Timeout\n\tif timeout == 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\tswitch strings.ToLower(cfg.Provider) {\n\tcase \"ollama\":\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"http://localhost:11434/v1/chat/completions\",\n\t\t\tapiKey: \"\", // No auth required\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"ollama\",\n\t\t}, nil\n\n\tcase \"groq\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set GROQ_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.groq.com/openai/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"groq\",\n\t\t}, nil\n\n\tcase \"deepseek\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set DEEPSEEK_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.deepseek.com/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"deepseek\",\n\t\t}, nil\n\n\tcase \"anthropic\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set ANTHROPIC_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &anthropicProvider{\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t}, nil\n\n\tcase \"openai\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set OPENAI_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.openai.com/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"openai\",\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"%w: %q (supported: ollama, groq, deepseek, anthropic, openai)\",\n\t\t\tErrUnsupportedProvider, cfg.Provider)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// System prompt (shared across providers)\n// ---------------------------------------------------------------------------\n\nconst systemPrompt = `You are an elite Cloud Security Architect and Platform Engineer.\n\nObjective: Analyze the provided Terraform/OpenTofu plan changes.\n\nGenerate a clean Markdown report with the following three sections:\n\n## Executive Summary\nProvide a 2-3 sentence explanation of what this infrastructure change accomplishes in plain English.\nFor example: \"This plan provisions public-facing web servers and locks down backend databases.\"\nFocus on conveying the semantic intent of the change to a non-expert reviewer.\n\n## Security & Architectural Audit\nHighlight potential risks with a focus on:\n- Defense-in-depth gaps\n- IAM role privilege escalation or over-scoping\n- Over-exposed S3 buckets or security groups (0.0.0.0/0 ingress, public ACLs)\n- Missing encryption at rest (EBS, RDS, S3, DynamoDB)\n- Lacking resource-level boundaries or blast radius containment\n- Absent logging, monitoring, or audit trails\n\nDo not just cite static rules. Evaluate the semantic intent of the resource layout and flag where the architecture diverges from security best practices. Reference resource addresses directly.\n\n## Blast Radius Assessment\nClassify the overall impact as LOW, MEDIUM, or HIGH:\n- LOW: Additive changes only, no destructive actions, minimal scope.\n- MEDIUM: Updates to existing resources, moderate scope, or changes to networking/IAM.\n- HIGH: Destructive changes (delete/recreate), structural migrations, high-cost modifications, or broad permission changes.\n\nProvide a brief justification for the classification.\n\nRules:\n- Be concise, specific, and actionable.\n- Use Markdown formatting: headers, bullet points, and code blocks where appropriate.\n- If no security issues are found, explicitly state that the plan looks safe.\n- Do NOT include any preamble, conversational text, or sign-off. Output ONLY the Markdown report.`\n\n// buildUserMessage serializes the optimized plan into the user prompt.\nfunc buildUserMessage(plan *parser.OptimizedPlan) (string, error) {\n\tpayload, err := json.Marshal(plan)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to serialize plan for LLM: %w\", err)\n\t}\n\treturn fmt.Sprintf(\"Analyze the following Terraform/OpenTofu plan changes:\\n\\n```json\\n%s\\n```\", string(payload)), nil\n}\n\n// ---------------------------------------------------------------------------\n// OpenAI-compatible provider (used by OpenAI, Ollama, and Groq)\n// ---------------------------------------------------------------------------\n\ntype openaiCompatProvider struct {\n\tbaseURL string\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n\tname string // \"openai\", \"ollama\", \"groq\", or \"deepseek\" — for error messages\n}\n\ntype openaiReq struct {\n\tModel string `json:\"model\"`\n\tMessages []openaiMsg `json:\"messages\"`\n}\n\ntype openaiMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype openaiResp struct {\n\tChoices []struct {\n\t\tMessage struct {\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"message\"`\n\t} `json:\"choices\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *openaiCompatProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treqBody := openaiReq{\n\t\tModel: p.model,\n\t\tMessages: []openaiMsg{\n\t\t\t{Role: \"system\", Content: systemPrompt},\n\t\t\t{Role: \"user\", Content: userMsg},\n\t\t},\n\t}\n\n\t// Build headers\n\theaders := map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t}\n\tif p.apiKey != \"\" {\n\t\theaders[\"Authorization\"] = \"Bearer \" + p.apiKey\n\t}\n\n\trespBytes, err := doPost(ctx, p.client, p.baseURL, headers, reqBody)\n\tif err != nil {\n\t\t// Check for Ollama connection refused\n\t\tif p.name == \"ollama\" && isConnectionRefused(err) {\n\t\t\treturn \"\", ErrOllamaConnRefused\n\t\t}\n\t\treturn \"\", err\n\t}\n\n\tvar resp openaiResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode %s response: %w\", p.name, err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: %s returned no choices\", ErrAPIFailure, p.name)\n\t}\n\n\treturn resp.Choices[0].Message.Content, nil\n}\n\n// ---------------------------------------------------------------------------\n// Anthropic (Claude) Messages API\n// ---------------------------------------------------------------------------\n\nconst anthropicURL = \"https://api.anthropic.com/v1/messages\"\n\ntype anthropicProvider struct {\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n}\n\ntype anthropicReq struct {\n\tModel string `json:\"model\"`\n\tMaxTokens int `json:\"max_tokens\"`\n\tSystem string `json:\"system\"`\n\tMessages []anthropicMsg `json:\"messages\"`\n}\n\ntype anthropicMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype anthropicResp struct {\n\tContent []struct {\n\t\tType string `json:\"type\"`\n\t\tText string `json:\"text\"`\n\t} `json:\"content\"`\n\tError *struct {\n\t\tType string `json:\"type\"`\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *anthropicProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treqBody := anthropicReq{\n\t\tModel: p.model,\n\t\tMaxTokens: 4096,\n\t\tSystem: systemPrompt,\n\t\tMessages: []anthropicMsg{{Role: \"user\", Content: userMsg}},\n\t}\n\n\trespBytes, err := p.doRequest(ctx, reqBody)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar resp anthropicResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode Anthropic response: %w\", err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Content) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: Anthropic returned empty content\", ErrAPIFailure)\n\t}\n\n\tvar sb strings.Builder\n\tfor _, block := range resp.Content {\n\t\tif block.Type == \"text\" {\n\t\t\tsb.WriteString(block.Text)\n\t\t}\n\t}\n\treturn sb.String(), nil\n}\n\nfunc (p *anthropicProvider) doRequest(ctx context.Context, body interface{}) ([]byte, error) {\n\treturn doPost(ctx, p.client, anthropicURL, map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t\t\"x-api-key\": p.apiKey,\n\t\t\"anthropic-version\": \"2023-06-01\",\n\t}, body)\n}\n\n// ---------------------------------------------------------------------------\n// Shared HTTP helper with context support\n// ---------------------------------------------------------------------------\n\nfunc doPost(ctx context.Context, client *http.Client, url string, headers map[string]string, payload interface{}) ([]byte, error) {\n\tbodyBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build HTTP request: %w\", err)\n\t}\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: the request exceeded the configured timeout\", ErrAPITimeout)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%w (HTTP %d): %s\", ErrAPIFailure, resp.StatusCode, truncateBody(data, 500))\n\t}\n\n\treturn data, nil\n}\n\n// truncateBody limits error response bodies to avoid flooding the terminal.\nfunc truncateBody(data []byte, maxLen int) string {\n\ts := string(data)\n\tif len(s) > maxLen {\n\t\treturn s[:maxLen] + \"... (truncated)\"\n\t}\n\treturn s\n}\n\n// isConnectionRefused checks if the error is a TCP connection refused.\nfunc isConnectionRefused(err error) bool {\n\tvar netErr *net.OpError\n\tif errors.As(err, &netErr) {\n\t\treturn true\n\t}\n\t// Also check the stringified error for wrapped cases\n\treturn strings.Contains(err.Error(), \"connection refused\") ||\n\t\tstrings.Contains(err.Error(), \"connect: connection refused\")\n}\n"}, "6890b61bc96ee0876cfe026f1407e886f04a2143": {"t": 1785749257.2076838, "n": 12454, "h": "66a8e2bb6d9b694b", "c": "// Package llm provides a provider-agnostic interface for sending Terraform plan\n// analysis requests to LLM APIs (Anthropic Claude, OpenAI GPT, Ollama, Groq).\n//\n// It uses a factory pattern to instantiate the correct provider client based on\n// configuration, and enforces timeout contexts on all HTTP calls.\npackage llm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tf-triage/tf-triage/pkg/parser\"\n)\n\n// ---------------------------------------------------------------------------\n// Typed errors\n// ---------------------------------------------------------------------------\n\n// ErrUnsupportedProvider indicates the requested provider is not implemented.\nvar ErrUnsupportedProvider = errors.New(\"unsupported LLM provider\")\n\n// ErrAPIKeyMissing indicates the required API key was not provided.\nvar ErrAPIKeyMissing = errors.New(\"API key not configured\")\n\n// ErrAPITimeout indicates the LLM API did not respond within the deadline.\nvar ErrAPITimeout = errors.New(\"LLM API request timed out\")\n\n// ErrAPIFailure indicates the LLM API returned a non-200 response.\nvar ErrAPIFailure = errors.New(\"LLM API returned an error\")\n\n// ErrOllamaConnRefused indicates the local Ollama daemon is not reachable.\nvar ErrOllamaConnRefused = errors.New(\"could not connect to local Ollama instance. Is Ollama running on http://localhost:11434?\")\n\n// ---------------------------------------------------------------------------\n// Configuration\n// ---------------------------------------------------------------------------\n\n// Config holds all settings needed to call an LLM provider.\ntype Config struct {\n\tProvider string // \"ollama\", \"groq\", \"anthropic\", or \"openai\"\n\tModel string // Model identifier\n\tAPIKey string // Provider API key (empty for ollama)\n\tTimeout time.Duration // HTTP request timeout (default: 120s)\n}\n\n// DefaultTimeout is applied when Config.Timeout is zero.\nconst DefaultTimeout = 120 * time.Second\n\n// ---------------------------------------------------------------------------\n// Provider interface & factory\n// ---------------------------------------------------------------------------\n\n// Provider defines the contract for an LLM backend.\ntype Provider interface {\n\t// Analyze sends the optimized plan to the LLM and returns Markdown output.\n\tAnalyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error)\n}\n\n// NewProvider is the factory function that returns the appropriate Provider\n// implementation based on the config.\nfunc NewProvider(cfg Config) (Provider, error) {\n\ttimeout := cfg.Timeout\n\tif timeout == 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\tswitch strings.ToLower(cfg.Provider) {\n\tcase \"ollama\":\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"http://localhost:11434/v1/chat/completions\",\n\t\t\tapiKey: \"\", // No auth required\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"ollama\",\n\t\t}, nil\n\n\tcase \"groq\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set GROQ_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.groq.com/openai/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"groq\",\n\t\t}, nil\n\n\tcase \"anthropic\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set ANTHROPIC_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &anthropicProvider{\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t}, nil\n\n\tcase \"openai\":\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: set OPENAI_API_KEY environment variable\",\n\t\t\t\tErrAPIKeyMissing)\n\t\t}\n\t\treturn &openaiCompatProvider{\n\t\t\tbaseURL: \"https://api.openai.com/v1/chat/completions\",\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t\tname: \"openai\",\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"%w: %q (supported: ollama, groq, anthropic, openai)\",\n\t\t\tErrUnsupportedProvider, cfg.Provider)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// System prompt (shared across providers)\n// ---------------------------------------------------------------------------\n\nconst systemPrompt = `You are an elite Cloud Security Architect and Platform Engineer.\n\nObjective: Analyze the provided Terraform/OpenTofu plan changes.\n\nGenerate a clean Markdown report with the following three sections:\n\n## Executive Summary\nProvide a 2-3 sentence explanation of what this infrastructure change accomplishes in plain English.\nFor example: \"This plan provisions public-facing web servers and locks down backend databases.\"\nFocus on conveying the semantic intent of the change to a non-expert reviewer.\n\n## Security & Architectural Audit\nHighlight potential risks with a focus on:\n- Defense-in-depth gaps\n- IAM role privilege escalation or over-scoping\n- Over-exposed S3 buckets or security groups (0.0.0.0/0 ingress, public ACLs)\n- Missing encryption at rest (EBS, RDS, S3, DynamoDB)\n- Lacking resource-level boundaries or blast radius containment\n- Absent logging, monitoring, or audit trails\n\nDo not just cite static rules. Evaluate the semantic intent of the resource layout and flag where the architecture diverges from security best practices. Reference resource addresses directly.\n\n## Blast Radius Assessment\nClassify the overall impact as LOW, MEDIUM, or HIGH:\n- LOW: Additive changes only, no destructive actions, minimal scope.\n- MEDIUM: Updates to existing resources, moderate scope, or changes to networking/IAM.\n- HIGH: Destructive changes (delete/recreate), structural migrations, high-cost modifications, or broad permission changes.\n\nProvide a brief justification for the classification.\n\nRules:\n- Be concise, specific, and actionable.\n- Use Markdown formatting: headers, bullet points, and code blocks where appropriate.\n- If no security issues are found, explicitly state that the plan looks safe.\n- Do NOT include any preamble, conversational text, or sign-off. Output ONLY the Markdown report.`\n\n// buildUserMessage serializes the optimized plan into the user prompt.\nfunc buildUserMessage(plan *parser.OptimizedPlan) (string, error) {\n\tpayload, err := json.Marshal(plan)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to serialize plan for LLM: %w\", err)\n\t}\n\treturn fmt.Sprintf(\"Analyze the following Terraform/OpenTofu plan changes:\\n\\n```json\\n%s\\n```\", string(payload)), nil\n}\n\n// ---------------------------------------------------------------------------\n// OpenAI-compatible provider (used by OpenAI, Ollama, and Groq)\n// ---------------------------------------------------------------------------\n\ntype openaiCompatProvider struct {\n\tbaseURL string\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n\tname string // \"openai\", \"ollama\", or \"groq\" — for error messages\n}\n\ntype openaiReq struct {\n\tModel string `json:\"model\"`\n\tMessages []openaiMsg `json:\"messages\"`\n}\n\ntype openaiMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype openaiResp struct {\n\tChoices []struct {\n\t\tMessage struct {\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"message\"`\n\t} `json:\"choices\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *openaiCompatProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treqBody := openaiReq{\n\t\tModel: p.model,\n\t\tMessages: []openaiMsg{\n\t\t\t{Role: \"system\", Content: systemPrompt},\n\t\t\t{Role: \"user\", Content: userMsg},\n\t\t},\n\t}\n\n\t// Build headers\n\theaders := map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t}\n\tif p.apiKey != \"\" {\n\t\theaders[\"Authorization\"] = \"Bearer \" + p.apiKey\n\t}\n\n\trespBytes, err := doPost(ctx, p.client, p.baseURL, headers, reqBody)\n\tif err != nil {\n\t\t// Check for Ollama connection refused\n\t\tif p.name == \"ollama\" && isConnectionRefused(err) {\n\t\t\treturn \"\", ErrOllamaConnRefused\n\t\t}\n\t\treturn \"\", err\n\t}\n\n\tvar resp openaiResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode %s response: %w\", p.name, err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: %s returned no choices\", ErrAPIFailure, p.name)\n\t}\n\n\treturn resp.Choices[0].Message.Content, nil\n}\n\n// ---------------------------------------------------------------------------\n// Anthropic (Claude) Messages API\n// ---------------------------------------------------------------------------\n\nconst anthropicURL = \"https://api.anthropic.com/v1/messages\"\n\ntype anthropicProvider struct {\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n}\n\ntype anthropicReq struct {\n\tModel string `json:\"model\"`\n\tMaxTokens int `json:\"max_tokens\"`\n\tSystem string `json:\"system\"`\n\tMessages []anthropicMsg `json:\"messages\"`\n}\n\ntype anthropicMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype anthropicResp struct {\n\tContent []struct {\n\t\tType string `json:\"type\"`\n\t\tText string `json:\"text\"`\n\t} `json:\"content\"`\n\tError *struct {\n\t\tType string `json:\"type\"`\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *anthropicProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treqBody := anthropicReq{\n\t\tModel: p.model,\n\t\tMaxTokens: 4096,\n\t\tSystem: systemPrompt,\n\t\tMessages: []anthropicMsg{{Role: \"user\", Content: userMsg}},\n\t}\n\n\trespBytes, err := p.doRequest(ctx, reqBody)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar resp anthropicResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode Anthropic response: %w\", err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Content) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: Anthropic returned empty content\", ErrAPIFailure)\n\t}\n\n\tvar sb strings.Builder\n\tfor _, block := range resp.Content {\n\t\tif block.Type == \"text\" {\n\t\t\tsb.WriteString(block.Text)\n\t\t}\n\t}\n\treturn sb.String(), nil\n}\n\nfunc (p *anthropicProvider) doRequest(ctx context.Context, body interface{}) ([]byte, error) {\n\treturn doPost(ctx, p.client, anthropicURL, map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t\t\"x-api-key\": p.apiKey,\n\t\t\"anthropic-version\": \"2023-06-01\",\n\t}, body)\n}\n\n// ---------------------------------------------------------------------------\n// Shared HTTP helper with context support\n// ---------------------------------------------------------------------------\n\nfunc doPost(ctx context.Context, client *http.Client, url string, headers map[string]string, payload interface{}) ([]byte, error) {\n\tbodyBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build HTTP request: %w\", err)\n\t}\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: the request exceeded the configured timeout\", ErrAPITimeout)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%w (HTTP %d): %s\", ErrAPIFailure, resp.StatusCode, truncateBody(data, 500))\n\t}\n\n\treturn data, nil\n}\n\n// truncateBody limits error response bodies to avoid flooding the terminal.\nfunc truncateBody(data []byte, maxLen int) string {\n\ts := string(data)\n\tif len(s) > maxLen {\n\t\treturn s[:maxLen] + \"... (truncated)\"\n\t}\n\treturn s\n}\n\n// isConnectionRefused checks if the error is a TCP connection refused.\nfunc isConnectionRefused(err error) bool {\n\tvar netErr *net.OpError\n\tif errors.As(err, &netErr) {\n\t\treturn true\n\t}\n\t// Also check the stringified error for wrapped cases\n\treturn strings.Contains(err.Error(), \"connection refused\") ||\n\t\tstrings.Contains(err.Error(), \"connect: connection refused\")\n}\n"}, "6e2d0573700255d0b47373a9c1ef2ae61b51dd46": {"t": 1785749258.3236482, "n": 10882, "h": "124437d0957db2ad", "c": "// Package llm provides a provider-agnostic interface for sending Terraform plan\n// analysis requests to LLM APIs (Anthropic Claude, OpenAI GPT).\n//\n// It uses a factory pattern to instantiate the correct provider client based on\n// configuration, and enforces timeout contexts on all HTTP calls.\npackage llm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tf-triage/tf-triage/pkg/parser\"\n)\n\n// ---------------------------------------------------------------------------\n// Typed errors\n// ---------------------------------------------------------------------------\n\n// ErrUnsupportedProvider indicates the requested provider is not implemented.\nvar ErrUnsupportedProvider = errors.New(\"unsupported LLM provider\")\n\n// ErrAPIKeyMissing indicates the required API key was not provided.\nvar ErrAPIKeyMissing = errors.New(\"API key not configured\")\n\n// ErrAPITimeout indicates the LLM API did not respond within the deadline.\nvar ErrAPITimeout = errors.New(\"LLM API request timed out\")\n\n// ErrAPIFailure indicates the LLM API returned a non-200 response.\nvar ErrAPIFailure = errors.New(\"LLM API returned an error\")\n\n// ---------------------------------------------------------------------------\n// Configuration\n// ---------------------------------------------------------------------------\n\n// Config holds all settings needed to call an LLM provider.\ntype Config struct {\n\tProvider string // \"anthropic\" or \"openai\"\n\tModel string // Model identifier (e.g., \"claude-3-5-sonnet-20241022\")\n\tAPIKey string // Provider API key\n\tTimeout time.Duration // HTTP request timeout (default: 120s)\n}\n\n// DefaultTimeout is applied when Config.Timeout is zero.\nconst DefaultTimeout = 120 * time.Second\n\n// ---------------------------------------------------------------------------\n// Provider interface & factory\n// ---------------------------------------------------------------------------\n\n// Provider defines the contract for an LLM backend.\ntype Provider interface {\n\t// Analyze sends the optimized plan to the LLM and returns Markdown output.\n\tAnalyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error)\n}\n\n// NewProvider is the factory function that returns the appropriate Provider\n// implementation based on the config.\nfunc NewProvider(cfg Config) (Provider, error) {\n\tif cfg.APIKey == \"\" {\n\t\treturn nil, fmt.Errorf(\"%w: set the appropriate environment variable for provider %q\",\n\t\t\tErrAPIKeyMissing, cfg.Provider)\n\t}\n\n\ttimeout := cfg.Timeout\n\tif timeout == 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\tswitch strings.ToLower(cfg.Provider) {\n\tcase \"anthropic\":\n\t\treturn &anthropicProvider{\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t}, nil\n\n\tcase \"openai\":\n\t\treturn &openaiProvider{\n\t\t\tapiKey: cfg.APIKey,\n\t\t\tmodel: cfg.Model,\n\t\t\tclient: &http.Client{Timeout: timeout},\n\t\t}, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"%w: %q (supported: anthropic, openai)\",\n\t\t\tErrUnsupportedProvider, cfg.Provider)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// System prompt (shared across providers)\n// ---------------------------------------------------------------------------\n\nconst systemPrompt = `You are an elite Cloud Security Architect and Platform Engineer.\n\nObjective: Analyze the provided Terraform/OpenTofu plan changes.\n\nGenerate a clean Markdown report with the following three sections:\n\n## Executive Summary\nProvide a 2-3 sentence explanation of what this infrastructure change accomplishes in plain English.\nFor example: \"This plan provisions public-facing web servers and locks down backend databases.\"\nFocus on conveying the semantic intent of the change to a non-expert reviewer.\n\n## Security & Architectural Audit\nHighlight potential risks with a focus on:\n- Defense-in-depth gaps\n- IAM role privilege escalation or over-scoping\n- Over-exposed S3 buckets or security groups (0.0.0.0/0 ingress, public ACLs)\n- Missing encryption at rest (EBS, RDS, S3, DynamoDB)\n- Lacking resource-level boundaries or blast radius containment\n- Absent logging, monitoring, or audit trails\n\nDo not just cite static rules. Evaluate the semantic intent of the resource layout and flag where the architecture diverges from security best practices. Reference resource addresses directly.\n\n## Blast Radius Assessment\nClassify the overall impact as LOW, MEDIUM, or HIGH:\n- LOW: Additive changes only, no destructive actions, minimal scope.\n- MEDIUM: Updates to existing resources, moderate scope, or changes to networking/IAM.\n- HIGH: Destructive changes (delete/recreate), structural migrations, high-cost modifications, or broad permission changes.\n\nProvide a brief justification for the classification.\n\nRules:\n- Be concise, specific, and actionable.\n- Use Markdown formatting: headers, bullet points, and code blocks where appropriate.\n- If no security issues are found, explicitly state that the plan looks safe.\n- Do NOT include any preamble, conversational text, or sign-off. Output ONLY the Markdown report.`\n\n// buildUserMessage serializes the optimized plan into the user prompt.\nfunc buildUserMessage(plan *parser.OptimizedPlan) (string, error) {\n\tpayload, err := json.Marshal(plan)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to serialize plan for LLM: %w\", err)\n\t}\n\treturn fmt.Sprintf(\"Analyze the following Terraform/OpenTofu plan changes:\\n\\n```json\\n%s\\n```\", string(payload)), nil\n}\n\n// ---------------------------------------------------------------------------\n// Anthropic (Claude) Messages API\n// ---------------------------------------------------------------------------\n\nconst anthropicURL = \"https://api.anthropic.com/v1/messages\"\n\ntype anthropicProvider struct {\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n}\n\ntype anthropicReq struct {\n\tModel string `json:\"model\"`\n\tMaxTokens int `json:\"max_tokens\"`\n\tSystem string `json:\"system\"`\n\tMessages []anthropicMsg `json:\"messages\"`\n}\n\ntype anthropicMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype anthropicResp struct {\n\tContent []struct {\n\t\tType string `json:\"type\"`\n\t\tText string `json:\"text\"`\n\t} `json:\"content\"`\n\tError *struct {\n\t\tType string `json:\"type\"`\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *anthropicProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treqBody := anthropicReq{\n\t\tModel: p.model,\n\t\tMaxTokens: 4096,\n\t\tSystem: systemPrompt,\n\t\tMessages: []anthropicMsg{{Role: \"user\", Content: userMsg}},\n\t}\n\n\trespBytes, err := p.doRequest(ctx, reqBody)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar resp anthropicResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode Anthropic response: %w\", err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Content) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: Anthropic returned empty content\", ErrAPIFailure)\n\t}\n\n\tvar sb strings.Builder\n\tfor _, block := range resp.Content {\n\t\tif block.Type == \"text\" {\n\t\t\tsb.WriteString(block.Text)\n\t\t}\n\t}\n\treturn sb.String(), nil\n}\n\nfunc (p *anthropicProvider) doRequest(ctx context.Context, body interface{}) ([]byte, error) {\n\treturn doPost(ctx, p.client, anthropicURL, map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t\t\"x-api-key\": p.apiKey,\n\t\t\"anthropic-version\": \"2023-06-01\",\n\t}, body)\n}\n\n// ---------------------------------------------------------------------------\n// OpenAI Chat Completions API\n// ---------------------------------------------------------------------------\n\nconst openaiURL = \"https://api.openai.com/v1/chat/completions\"\n\ntype openaiProvider struct {\n\tapiKey string\n\tmodel string\n\tclient *http.Client\n}\n\ntype openaiReq struct {\n\tModel string `json:\"model\"`\n\tMessages []openaiMsg `json:\"messages\"`\n}\n\ntype openaiMsg struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype openaiResp struct {\n\tChoices []struct {\n\t\tMessage struct {\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"message\"`\n\t} `json:\"choices\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error\"`\n}\n\nfunc (p *openaiProvider) Analyze(ctx context.Context, plan *parser.OptimizedPlan) (string, error) {\n\tuserMsg, err := buildUserMessage(plan)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treqBody := openaiReq{\n\t\tModel: p.model,\n\t\tMessages: []openaiMsg{\n\t\t\t{Role: \"system\", Content: systemPrompt},\n\t\t\t{Role: \"user\", Content: userMsg},\n\t\t},\n\t}\n\n\trespBytes, err := p.doRequest(ctx, reqBody)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar resp openaiResp\n\tif err := json.Unmarshal(respBytes, &resp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode OpenAI response: %w\", err)\n\t}\n\tif resp.Error != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: [%s] %s\", ErrAPIFailure, resp.Error.Type, resp.Error.Message)\n\t}\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", fmt.Errorf(\"%w: OpenAI returned no choices\", ErrAPIFailure)\n\t}\n\n\treturn resp.Choices[0].Message.Content, nil\n}\n\nfunc (p *openaiProvider) doRequest(ctx context.Context, body interface{}) ([]byte, error) {\n\treturn doPost(ctx, p.client, openaiURL, map[string]string{\n\t\t\"Content-Type\": \"application/json\",\n\t\t\"Authorization\": \"Bearer \" + p.apiKey,\n\t}, body)\n}\n\n// ---------------------------------------------------------------------------\n// Shared HTTP helper with context support\n// ---------------------------------------------------------------------------\n\nfunc doPost(ctx context.Context, client *http.Client, url string, headers map[string]string, payload interface{}) ([]byte, error) {\n\tbodyBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build HTTP request: %w\", err)\n\t}\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: the request exceeded the configured timeout\", ErrAPITimeout)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response body: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%w (HTTP %d): %s\", ErrAPIFailure, resp.StatusCode, truncateBody(data, 500))\n\t}\n\n\treturn data, nil\n}\n\n// truncateBody limits error response bodies to avoid flooding the terminal.\nfunc truncateBody(data []byte, maxLen int) string {\n\ts := string(data)\n\tif len(s) > maxLen {\n\t\treturn s[:maxLen] + \"... (truncated)\"\n\t}\n\treturn s\n}\n"}}, "README.md": {"5e0b96cbe7d1f0d21a6070ddc1f8bcfd3f4be987": {"t": 1785746604.8380802, "n": 7975, "h": "589e3f8bad130c61", "c": "<div align=\"center\">\n\n# tf-triage\n\n**AI-powered security and architecture reviews for your Terraform plans.**\n\n[](https://github.com/balmha/tf-triage/releases)\n[](LICENSE)\n[](https://go.dev)\n\n<img alt=\"tf-triage demo\" src=\"./triage.gif\" width=\"700\" />\n\n`tf-triage` analyzes your Terraform and OpenTofu plans using LLMs and generates\na security audit with blast radius assessment — all from a single command.\n\n[Installation](#installation) •\n[Providers](#providers) •\n[Usage](#usage) •\n[Red Team Mode](#advanced-security-auditing-red-team-mode) •\n[CI/CD Examples](./examples/)\n\n</div>\n\n---\n\nTo get started, [install](#installation) `tf-triage` and choose a [provider](#providers).\nRun it locally with [Ollama](#using-ollama-local-private-free) (no API keys, fully private)\nor use a [cloud provider](#using-cloud-providers) like DeepSeek, Groq, or Gemini for faster inference.\nPipe your plan and get a Markdown report ready to drop into a PR comment:\n\n```\nterraform plan -json | tf-triage\n```\n\n---\n\n## Providers\n\ntf-triage supports **6 LLM providers** out of the box. Pick the one that fits your workflow — from fully local and free to cloud-hosted powerhouses.\n\n| Provider | Default Model | API Key Required | Notes |\n|----------|--------------|:---:|-------|\n| **ollama** | `llama3.2` | No | Local execution, no internet needed, completely free |\n| **groq** | `llama-3.3-70b-versatile` | Yes | Cloud, generous free tier, very fast inference |\n| **deepseek** | `deepseek-v4-flash` | Yes | Cloud, cost-efficient, strong reasoning |\n| **gemini** | `gemini-1.5-flash` | Yes | Cloud, Google AI, free tier available |\n| **anthropic** | `claude-3-5-sonnet` | Yes | Cloud, excellent analysis quality |\n| **openai** | `gpt-4o` | Yes | Cloud, widely used, strong general performance |\n\n### Using Ollama (local, private, free)\n\nOllama lets you run `tf-triage` entirely on your machine with zero external API calls. Your infrastructure plans never leave your laptop.\n\n1. Install Ollama: https://ollama.com/download\n\n2. Pull a model:\n ```bash\n ollama pull llama3.2\n ```\n\n3. Run tf-triage (Ollama is the default provider):\n ```bash\n terraform plan -json | tf-triage\n ```\n\nThat's it. No API keys, no accounts, no network dependency. Great for air-gapped environments, sensitive infrastructure, or just keeping things simple.\n\nYou can use any Ollama model:\n```bash\ntf-triage -f plan.json -m mistral\ntf-triage -f plan.json -m llama3.1:70b\ntf-triage -f plan.json -m deepseek-r1\n```\n\n### Using cloud providers\n\nFor cloud providers, export the API key and pass `--provider`:\n\n```bash\n# Groq (free tier, fast)\nexport GROQ_API_KEY=gsk_...\nterraform plan -json | tf-triage -p groq\n\n# DeepSeek\nexport DEEPSEEK_API_KEY=sk-...\ntf-triage -f plan.json -p deepseek\n\n# Google Gemini\nexport GEMINI_API_KEY=...\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# Anthropic (Claude)\nexport ANTHROPIC_API_KEY=sk-ant-...\ntf-triage -f plan.json -p anthropic\n\n# OpenAI\nexport OPENAI_API_KEY=sk-...\ntf-triage -f plan.json -p openai -m gpt-4o\n```\n\n---\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew trust --formula balmha/tap/tf-triage\nbrew install balmha/tap/tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.7.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n---\n\n## Usage\n\ntf-triage accepts input in two ways:\n\n```bash\n# Pipe directly from terraform plan\nterraform plan -json | tf-triage\n\n# Or point to a saved plan file\nterraform plan -out=plan.tfplan\nterraform show -json plan.tfplan > plan.json\ntf-triage -f plan.json\n```\n\n### More examples\n\n```bash\n# Write report to a file instead of stdout\nterraform plan -json | tf-triage -o report.md\n\n# Use Groq for fast cloud inference\ntf-triage -f plan.json -p groq\n\n# Override the model\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# OpenTofu works the same way\ntofu plan -json | tf-triage -p deepseek\n```\n\n---\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `ollama` | LLM provider (`ollama`, `groq`, `deepseek`, `gemini`, `anthropic`, `openai`) |\n| `--model` | `-m` | auto | Model name (auto-selects per provider if omitted) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n| `--version` | `-v` | | Print version and exit |\n\n## Environment Variables\n\n| Variable | Provider | Description |\n|----------|----------|-------------|\n| `GROQ_API_KEY` | groq | API key ([console.groq.com](https://console.groq.com)) |\n| `DEEPSEEK_API_KEY` | deepseek | API key ([platform.deepseek.com](https://platform.deepseek.com)) |\n| `GEMINI_API_KEY` | gemini | API key ([aistudio.google.com](https://aistudio.google.com/apikey)) |\n| `ANTHROPIC_API_KEY` | anthropic | API key ([console.anthropic.com](https://console.anthropic.com)) |\n| `OPENAI_API_KEY` | openai | API key ([platform.openai.com](https://platform.openai.com)) |\n| `TF_TRIAGE_MODEL` | any | Default model override (lower priority than `--model` flag) |\n\n---\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file. Supports both `terraform show -json` (full schema) and `terraform plan -json` (streaming format).\n2. **Parse** — Validates the schema and extracts resource changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes attribute diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with three sections:\n\n- **Executive Summary** — 2-3 sentence plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis: IAM risks, public exposure, missing encryption, logging gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n---\n\n## Advanced Security Auditing: Red Team Mode\n\n`tf-triage` isn't just for workflow summaries — it can actively hunt for exploitable attack chains in your infrastructure before deployment. The `redteam` sub-command shifts into offensive security mode, acting as an automated penetration tester that scans your plans for multi-resource vulnerability chains.\n\n### Local Threat Modeling via Ollama\n\nRun an offline security evaluation for free — your plans never leave your machine:\n\n```bash\nterraform plan -json | tf-triage redteam\n```\n\n### Cloud-Powered Red Team Analysis\n\nUse a cloud provider for deeper reasoning on complex infrastructure:\n\n```bash\ntf-triage redteam -f plan.json -p deepseek\n```\n\n### Output Structure\n\nThe red team report includes three sections:\n\n- **Exploitation Summary** — Critical risk score (LOW/MEDIUM/HIGH/CRITICAL) with an executive threat landscape overview.\n- **The Vulnerability Chain** — Step-by-step attack narrative showing how an adversary chains misconfigurations across resources.\n- **Proof of Concept (PoC)** — Actionable verification commands (curl, aws cli, nmap) to validate the exposure.\n\n### Pipeline Integration\n\nThe redteam report is fully compatible with `tf-triage comment` — post it directly to your PR:\n\n```bash\nterraform plan -json | tf-triage redteam -o tf-redteam-report.md\ntf-triage comment -i tf-redteam-report.md\n```\n\n---\n\n## License\n\nGPL-3.0\n"}, "067ed6f5226bcb1287e44784d4717d18818738e4": {"t": 1785749254.405535, "n": 7914, "h": "a602de60812b6f36", "c": "<div align=\"center\">\n\n# tf-triage\n\n**AI-powered security and architecture reviews for your Terraform plans.**\n\n[](https://github.com/balmha/tf-triage/releases)\n[](LICENSE)\n[](https://go.dev)\n\n<img alt=\"tf-triage demo\" src=\"./triage.gif\" width=\"700\" />\n\n`tf-triage` analyzes your Terraform and OpenTofu plans using LLMs and generates\na security audit with blast radius assessment — all from a single command.\n\n[Installation](#installation) •\n[Providers](#providers) •\n[Usage](#usage) •\n[CI/CD Examples](./examples/)\n\n</div>\n\n---\n\nTo get started, [install](#installation) `tf-triage` and choose a [provider](#providers).\nRun it locally with [Ollama](#using-ollama-local-private-free) (no API keys, fully private)\nor use a [cloud provider](#using-cloud-providers) like DeepSeek, Groq, or Gemini for faster inference.\nPipe your plan and get a Markdown report ready to drop into a PR comment:\n\n```\nterraform plan -json | tf-triage\n```\n\n---\n\n## Providers\n\ntf-triage supports **6 LLM providers** out of the box. Pick the one that fits your workflow — from fully local and free to cloud-hosted powerhouses.\n\n| Provider | Default Model | API Key Required | Notes |\n|----------|--------------|:---:|-------|\n| **ollama** | `llama3.2` | No | Local execution, no internet needed, completely free |\n| **groq** | `llama-3.3-70b-versatile` | Yes | Cloud, generous free tier, very fast inference |\n| **deepseek** | `deepseek-v4-flash` | Yes | Cloud, cost-efficient, strong reasoning |\n| **gemini** | `gemini-1.5-flash` | Yes | Cloud, Google AI, free tier available |\n| **anthropic** | `claude-3-5-sonnet` | Yes | Cloud, excellent analysis quality |\n| **openai** | `gpt-4o` | Yes | Cloud, widely used, strong general performance |\n\n### Using Ollama (local, private, free)\n\nOllama lets you run `tf-triage` entirely on your machine with zero external API calls. Your infrastructure plans never leave your laptop.\n\n1. Install Ollama: https://ollama.com/download\n\n2. Pull a model:\n ```bash\n ollama pull llama3.2\n ```\n\n3. Run tf-triage (Ollama is the default provider):\n ```bash\n terraform plan -json | tf-triage\n ```\n\nThat's it. No API keys, no accounts, no network dependency. Great for air-gapped environments, sensitive infrastructure, or just keeping things simple.\n\nYou can use any Ollama model:\n```bash\ntf-triage -f plan.json -m mistral\ntf-triage -f plan.json -m llama3.1:70b\ntf-triage -f plan.json -m deepseek-r1\n```\n\n### Using cloud providers\n\nFor cloud providers, export the API key and pass `--provider`:\n\n```bash\n# Groq (free tier, fast)\nexport GROQ_API_KEY=gsk_...\nterraform plan -json | tf-triage -p groq\n\n# DeepSeek\nexport DEEPSEEK_API_KEY=sk-...\ntf-triage -f plan.json -p deepseek\n\n# Google Gemini\nexport GEMINI_API_KEY=...\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# Anthropic (Claude)\nexport ANTHROPIC_API_KEY=sk-ant-...\ntf-triage -f plan.json -p anthropic\n\n# OpenAI\nexport OPENAI_API_KEY=sk-...\ntf-triage -f plan.json -p openai -m gpt-4o\n```\n\n---\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew trust --formula balmha/tap/tf-triage\nbrew install balmha/tap/tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.7.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n---\n\n## Usage\n\ntf-triage accepts input in two ways:\n\n```bash\n# Pipe directly from terraform plan\nterraform plan -json | tf-triage\n\n# Or point to a saved plan file\nterraform plan -out=plan.tfplan\nterraform show -json plan.tfplan > plan.json\ntf-triage -f plan.json\n```\n\n### More examples\n\n```bash\n# Write report to a file instead of stdout\nterraform plan -json | tf-triage -o report.md\n\n# Use Groq for fast cloud inference\ntf-triage -f plan.json -p groq\n\n# Override the model\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# OpenTofu works the same way\ntofu plan -json | tf-triage -p deepseek\n```\n\n---\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `ollama` | LLM provider (`ollama`, `groq`, `deepseek`, `gemini`, `anthropic`, `openai`) |\n| `--model` | `-m` | auto | Model name (auto-selects per provider if omitted) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n| `--version` | `-v` | | Print version and exit |\n\n## Environment Variables\n\n| Variable | Provider | Description |\n|----------|----------|-------------|\n| `GROQ_API_KEY` | groq | API key ([console.groq.com](https://console.groq.com)) |\n| `DEEPSEEK_API_KEY` | deepseek | API key ([platform.deepseek.com](https://platform.deepseek.com)) |\n| `GEMINI_API_KEY` | gemini | API key ([aistudio.google.com](https://aistudio.google.com/apikey)) |\n| `ANTHROPIC_API_KEY` | anthropic | API key ([console.anthropic.com](https://console.anthropic.com)) |\n| `OPENAI_API_KEY` | openai | API key ([platform.openai.com](https://platform.openai.com)) |\n| `TF_TRIAGE_MODEL` | any | Default model override (lower priority than `--model` flag) |\n\n---\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file. Supports both `terraform show -json` (full schema) and `terraform plan -json` (streaming format).\n2. **Parse** — Validates the schema and extracts resource changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes attribute diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with three sections:\n\n- **Executive Summary** — 2-3 sentence plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis: IAM risks, public exposure, missing encryption, logging gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n---\n\n## Advanced Security Auditing: Red Team Mode\n\n`tf-triage` isn't just for workflow summaries — it can actively hunt for exploitable attack chains in your infrastructure before deployment. The `redteam` sub-command shifts into offensive security mode, acting as an automated penetration tester that scans your plans for multi-resource vulnerability chains.\n\n### Local Threat Modeling via Ollama\n\nRun an offline security evaluation for free — your plans never leave your machine:\n\n```bash\nterraform plan -json | tf-triage redteam\n```\n\n### Cloud-Powered Red Team Analysis\n\nUse a cloud provider for deeper reasoning on complex infrastructure:\n\n```bash\ntf-triage redteam -f plan.json -p deepseek\n```\n\n### Output Structure\n\nThe red team report includes three sections:\n\n- **Exploitation Summary** — Critical risk score (LOW/MEDIUM/HIGH/CRITICAL) with an executive threat landscape overview.\n- **The Vulnerability Chain** — Step-by-step attack narrative showing how an adversary chains misconfigurations across resources.\n- **Proof of Concept (PoC)** — Actionable verification commands (curl, aws cli, nmap) to validate the exposure.\n\n### Pipeline Integration\n\nThe redteam report is fully compatible with `tf-triage comment` — post it directly to your PR:\n\n```bash\nterraform plan -json | tf-triage redteam -o tf-redteam-report.md\ntf-triage comment -i tf-redteam-report.md\n```\n\n---\n\n## License\n\nGPL-3.0\n"}, "46d46306e477b067eaa979ecb35ae49cba550443": {"t": 1785749255.5214593, "n": 6540, "h": "dd23c24f7a579b6b", "c": "<div align=\"center\">\n\n# tf-triage\n\n**AI-powered security and architecture reviews for your Terraform plans.**\n\n[](https://github.com/balmha/tf-triage/releases)\n[](LICENSE)\n[](https://go.dev)\n\n<img alt=\"tf-triage demo\" src=\"./triage.gif\" width=\"700\" />\n\n`tf-triage` analyzes your Terraform and OpenTofu plans using LLMs and generates\na security audit with blast radius assessment — all from a single command.\n\n[Installation](#installation) •\n[Providers](#providers) •\n[Usage](#usage) •\n[CI/CD Examples](./examples/)\n\n</div>\n\n---\n\nTo get started, [install](#installation) `tf-triage` and choose a [provider](#providers).\nRun it locally with [Ollama](#using-ollama-local-private-free) (no API keys, fully private)\nor use a [cloud provider](#using-cloud-providers) like DeepSeek, Groq, or Gemini for faster inference.\nPipe your plan and get a Markdown report ready to drop into a PR comment:\n\n```\nterraform plan -json | tf-triage\n```\n\n---\n\n## Providers\n\ntf-triage supports **6 LLM providers** out of the box. Pick the one that fits your workflow — from fully local and free to cloud-hosted powerhouses.\n\n| Provider | Default Model | API Key Required | Notes |\n|----------|--------------|:---:|-------|\n| **ollama** | `llama3.2` | No | Local execution, no internet needed, completely free |\n| **groq** | `llama-3.3-70b-versatile` | Yes | Cloud, generous free tier, very fast inference |\n| **deepseek** | `deepseek-v4-flash` | Yes | Cloud, cost-efficient, strong reasoning |\n| **gemini** | `gemini-1.5-flash` | Yes | Cloud, Google AI, free tier available |\n| **anthropic** | `claude-3-5-sonnet` | Yes | Cloud, excellent analysis quality |\n| **openai** | `gpt-4o` | Yes | Cloud, widely used, strong general performance |\n\n### Using Ollama (local, private, free)\n\nOllama lets you run `tf-triage` entirely on your machine with zero external API calls. Your infrastructure plans never leave your laptop.\n\n1. Install Ollama: https://ollama.com/download\n\n2. Pull a model:\n ```bash\n ollama pull llama3.2\n ```\n\n3. Run tf-triage (Ollama is the default provider):\n ```bash\n terraform plan -json | tf-triage\n ```\n\nThat's it. No API keys, no accounts, no network dependency. Great for air-gapped environments, sensitive infrastructure, or just keeping things simple.\n\nYou can use any Ollama model:\n```bash\ntf-triage -f plan.json -m mistral\ntf-triage -f plan.json -m llama3.1:70b\ntf-triage -f plan.json -m deepseek-r1\n```\n\n### Using cloud providers\n\nFor cloud providers, export the API key and pass `--provider`:\n\n```bash\n# Groq (free tier, fast)\nexport GROQ_API_KEY=gsk_...\nterraform plan -json | tf-triage -p groq\n\n# DeepSeek\nexport DEEPSEEK_API_KEY=sk-...\ntf-triage -f plan.json -p deepseek\n\n# Google Gemini\nexport GEMINI_API_KEY=...\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# Anthropic (Claude)\nexport ANTHROPIC_API_KEY=sk-ant-...\ntf-triage -f plan.json -p anthropic\n\n# OpenAI\nexport OPENAI_API_KEY=sk-...\ntf-triage -f plan.json -p openai -m gpt-4o\n```\n\n---\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew trust --formula balmha/tap/tf-triage\nbrew install balmha/tap/tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.7.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n---\n\n## Usage\n\ntf-triage accepts input in two ways:\n\n```bash\n# Pipe directly from terraform plan\nterraform plan -json | tf-triage\n\n# Or point to a saved plan file\nterraform plan -out=plan.tfplan\nterraform show -json plan.tfplan > plan.json\ntf-triage -f plan.json\n```\n\n### More examples\n\n```bash\n# Write report to a file instead of stdout\nterraform plan -json | tf-triage -o report.md\n\n# Use Groq for fast cloud inference\ntf-triage -f plan.json -p groq\n\n# Override the model\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# OpenTofu works the same way\ntofu plan -json | tf-triage -p deepseek\n```\n\n---\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `ollama` | LLM provider (`ollama`, `groq`, `deepseek`, `gemini`, `anthropic`, `openai`) |\n| `--model` | `-m` | auto | Model name (auto-selects per provider if omitted) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n| `--version` | `-v` | | Print version and exit |\n\n## Environment Variables\n\n| Variable | Provider | Description |\n|----------|----------|-------------|\n| `GROQ_API_KEY` | groq | API key ([console.groq.com](https://console.groq.com)) |\n| `DEEPSEEK_API_KEY` | deepseek | API key ([platform.deepseek.com](https://platform.deepseek.com)) |\n| `GEMINI_API_KEY` | gemini | API key ([aistudio.google.com](https://aistudio.google.com/apikey)) |\n| `ANTHROPIC_API_KEY` | anthropic | API key ([console.anthropic.com](https://console.anthropic.com)) |\n| `OPENAI_API_KEY` | openai | API key ([platform.openai.com](https://platform.openai.com)) |\n| `TF_TRIAGE_MODEL` | any | Default model override (lower priority than `--model` flag) |\n\n---\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file. Supports both `terraform show -json` (full schema) and `terraform plan -json` (streaming format).\n2. **Parse** — Validates the schema and extracts resource changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes attribute diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with three sections:\n\n- **Executive Summary** — 2-3 sentence plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis: IAM risks, public exposure, missing encryption, logging gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n---\n\n## License\n\nGPL-3.0\n"}, "c7d6385f851477c8161dc21da0d36f3e09eb378c": {"t": 1785749256.621588, "n": 6061, "h": "92589e7db8080e45", "c": "<div align=\"center\">\n\n# tf-triage\n\n**AI-powered security and architecture reviews for your Terraform plans.**\n\n[](https://github.com/balmha/tf-triage/releases)\n[](LICENSE)\n[](https://go.dev)\n\n---\n\nPipe your `terraform plan -json` through `tf-triage` and get an instant\nsecurity audit, architecture impact analysis, and blast radius assessment\n— formatted as Markdown, ready to drop into a PR comment.\n\n```\nterraform plan -json | tf-triage\n```\n\n</div>\n\n---\n\n## Providers\n\ntf-triage supports **6 LLM providers** out of the box. Pick the one that fits your workflow — from fully local and free to cloud-hosted powerhouses.\n\n| Provider | Default Model | API Key Required | Notes |\n|----------|--------------|:---:|-------|\n| **ollama** | `llama3.2` | No | Local execution, no internet needed, completely free |\n| **groq** | `llama-3.3-70b-versatile` | Yes | Cloud, generous free tier, very fast inference |\n| **deepseek** | `deepseek-v4-flash` | Yes | Cloud, cost-efficient, strong reasoning |\n| **gemini** | `gemini-1.5-flash` | Yes | Cloud, Google AI, free tier available |\n| **anthropic** | `claude-3-5-sonnet` | Yes | Cloud, excellent analysis quality |\n| **openai** | `gpt-4o` | Yes | Cloud, widely used, strong general performance |\n\n### Using Ollama (local, private, free)\n\nOllama lets you run `tf-triage` entirely on your machine with zero external API calls. Your infrastructure plans never leave your laptop.\n\n1. Install Ollama: https://ollama.com/download\n\n2. Pull a model:\n ```bash\n ollama pull llama3.2\n ```\n\n3. Run tf-triage (Ollama is the default provider):\n ```bash\n terraform plan -json | tf-triage\n ```\n\nThat's it. No API keys, no accounts, no network dependency. Great for air-gapped environments, sensitive infrastructure, or just keeping things simple.\n\nYou can use any Ollama model:\n```bash\ntf-triage -f plan.json -m mistral\ntf-triage -f plan.json -m llama3.1:70b\ntf-triage -f plan.json -m deepseek-r1\n```\n\n### Using cloud providers\n\nFor cloud providers, export the API key and pass `--provider`:\n\n```bash\n# Groq (free tier, fast)\nexport GROQ_API_KEY=gsk_...\nterraform plan -json | tf-triage -p groq\n\n# DeepSeek\nexport DEEPSEEK_API_KEY=sk-...\ntf-triage -f plan.json -p deepseek\n\n# Google Gemini\nexport GEMINI_API_KEY=...\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# Anthropic (Claude)\nexport ANTHROPIC_API_KEY=sk-ant-...\ntf-triage -f plan.json -p anthropic\n\n# OpenAI\nexport OPENAI_API_KEY=sk-...\ntf-triage -f plan.json -p openai -m gpt-4o\n```\n\n---\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew trust --formula balmha/tap/tf-triage\nbrew install balmha/tap/tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.7.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n---\n\n## Usage\n\ntf-triage accepts input in two ways:\n\n```bash\n# Pipe directly from terraform plan\nterraform plan -json | tf-triage\n\n# Or point to a saved plan file\nterraform plan -out=plan.tfplan\nterraform show -json plan.tfplan > plan.json\ntf-triage -f plan.json\n```\n\n### More examples\n\n```bash\n# Write report to a file instead of stdout\nterraform plan -json | tf-triage -o report.md\n\n# Use Groq for fast cloud inference\ntf-triage -f plan.json -p groq\n\n# Override the model\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# OpenTofu works the same way\ntofu plan -json | tf-triage -p deepseek\n```\n\n---\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `ollama` | LLM provider (`ollama`, `groq`, `deepseek`, `gemini`, `anthropic`, `openai`) |\n| `--model` | `-m` | auto | Model name (auto-selects per provider if omitted) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n| `--version` | `-v` | | Print version and exit |\n\n## Environment Variables\n\n| Variable | Provider | Description |\n|----------|----------|-------------|\n| `GROQ_API_KEY` | groq | API key ([console.groq.com](https://console.groq.com)) |\n| `DEEPSEEK_API_KEY` | deepseek | API key ([platform.deepseek.com](https://platform.deepseek.com)) |\n| `GEMINI_API_KEY` | gemini | API key ([aistudio.google.com](https://aistudio.google.com/apikey)) |\n| `ANTHROPIC_API_KEY` | anthropic | API key ([console.anthropic.com](https://console.anthropic.com)) |\n| `OPENAI_API_KEY` | openai | API key ([platform.openai.com](https://platform.openai.com)) |\n| `TF_TRIAGE_MODEL` | any | Default model override (lower priority than `--model` flag) |\n\n---\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file. Supports both `terraform show -json` (full schema) and `terraform plan -json` (streaming format).\n2. **Parse** — Validates the schema and extracts resource changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes attribute diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with three sections:\n\n- **Executive Summary** — 2-3 sentence plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis: IAM risks, public exposure, missing encryption, logging gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n---\n\n## License\n\nGPL-3.0\n"}, "da808092ed062e31e55e4b2d9e0c5fdb71b8b137": {"t": 1785749257.704297, "n": 6516, "h": "0d23b9d08137294a", "c": "<div align=\"center\">\n\n# tf-triage\n\n**AI-powered security and architecture reviews for your Terraform plans.**\n\n[](https://github.com/balmha/tf-triage/releases)\n[](LICENSE)\n[](https://go.dev)\n\n---\n\nPipe your `terraform plan -json` through `tf-triage` and get an instant\nsecurity audit, architecture impact analysis, and blast radius assessment\n— formatted as Markdown, ready to drop into a PR comment.\n\n```\nterraform plan -json | tf-triage\n```\n\n</div>\n\n---\n\n## Providers\n\ntf-triage supports **6 LLM providers** out of the box. Pick the one that fits your workflow — from fully local and free to cloud-hosted powerhouses.\n\n| Provider | Default Model | API Key Required | Notes |\n|----------|--------------|:---:|-------|\n| **ollama** | `llama3.2` | No | Local execution, no internet needed, completely free |\n| **groq** | `llama-3.3-70b-versatile` | Yes | Cloud, generous free tier, very fast inference |\n| **deepseek** | `deepseek-v4-flash` | Yes | Cloud, cost-efficient, strong reasoning |\n| **gemini** | `gemini-1.5-flash` | Yes | Cloud, Google AI, free tier available |\n| **anthropic** | `claude-3-5-sonnet` | Yes | Cloud, excellent analysis quality |\n| **openai** | `gpt-4o` | Yes | Cloud, widely used, strong general performance |\n\n### Using Ollama (local, private, free)\n\nOllama lets you run `tf-triage` entirely on your machine with zero external API calls. Your infrastructure plans never leave your laptop.\n\n1. Install Ollama: https://ollama.com/download\n\n2. Pull a model:\n ```bash\n ollama pull llama3.2\n ```\n\n3. Run tf-triage (Ollama is the default provider):\n ```bash\n terraform plan -json | tf-triage\n ```\n\nThat's it. No API keys, no accounts, no network dependency. Great for air-gapped environments, sensitive infrastructure, or just keeping things simple.\n\nYou can use any Ollama model:\n```bash\ntf-triage -f plan.json -m mistral\ntf-triage -f plan.json -m llama3.1:70b\ntf-triage -f plan.json -m deepseek-r1\n```\n\n### Using cloud providers\n\nFor cloud providers, export the API key and pass `--provider`:\n\n```bash\n# Groq (free tier, fast)\nexport GROQ_API_KEY=gsk_...\nterraform plan -json | tf-triage -p groq\n\n# DeepSeek\nexport DEEPSEEK_API_KEY=sk-...\ntf-triage -f plan.json -p deepseek\n\n# Google Gemini\nexport GEMINI_API_KEY=...\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# Anthropic (Claude)\nexport ANTHROPIC_API_KEY=sk-ant-...\ntf-triage -f plan.json -p anthropic\n\n# OpenAI\nexport OPENAI_API_KEY=sk-...\ntf-triage -f plan.json -p openai -m gpt-4o\n```\n\n---\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew tap balmha/tap\nbrew install tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.5.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n---\n\n## Usage\n\ntf-triage accepts input in two ways:\n\n```bash\n# Pipe directly from terraform plan\nterraform plan -json | tf-triage\n\n# Or point to a saved plan file\nterraform plan -out=plan.tfplan\nterraform show -json plan.tfplan > plan.json\ntf-triage -f plan.json\n```\n\n### More examples\n\n```bash\n# Write report to a file instead of stdout\nterraform plan -json | tf-triage -o report.md\n\n# Use Groq for fast cloud inference\ntf-triage -f plan.json -p groq\n\n# Override the model\ntf-triage -f plan.json -p gemini -m gemini-1.5-pro\n\n# OpenTofu works the same way\ntofu plan -json | tf-triage -p deepseek\n```\n\n---\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `ollama` | LLM provider (`ollama`, `groq`, `deepseek`, `gemini`, `anthropic`, `openai`) |\n| `--model` | `-m` | auto | Model name (auto-selects per provider if omitted) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n| `--version` | `-v` | | Print version and exit |\n\n## Environment Variables\n\n| Variable | Provider | Description |\n|----------|----------|-------------|\n| `GROQ_API_KEY` | groq | API key ([console.groq.com](https://console.groq.com)) |\n| `DEEPSEEK_API_KEY` | deepseek | API key ([platform.deepseek.com](https://platform.deepseek.com)) |\n| `GEMINI_API_KEY` | gemini | API key ([aistudio.google.com](https://aistudio.google.com/apikey)) |\n| `ANTHROPIC_API_KEY` | anthropic | API key ([console.anthropic.com](https://console.anthropic.com)) |\n| `OPENAI_API_KEY` | openai | API key ([platform.openai.com](https://platform.openai.com)) |\n| `TF_TRIAGE_MODEL` | any | Default model override (lower priority than `--model` flag) |\n\n---\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file. Supports both `terraform show -json` (full schema) and `terraform plan -json` (streaming format).\n2. **Parse** — Validates the schema and extracts resource changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes attribute diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with three sections:\n\n- **Executive Summary** — 2-3 sentence plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis: IAM risks, public exposure, missing encryption, logging gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n---\n\n## Project Structure\n\n```\ntf-triage/\n├── main.go\n├── cmd/\n│ └── root.go # CLI flags and pipeline orchestration\n├── pkg/\n│ ├── parser/\n│ │ └── parser.go # Plan parsing, validation, token optimization\n│ └── llm/\n│ └── client.go # Provider factory, API clients, system prompt\n├── install.sh # Curl installer for Linux/macOS\n├── .goreleaser.yaml # Cross-platform release builds\n└── .github/workflows/\n └── release.yml # Tag-triggered CI/CD\n```\n\n## License\n\nGPL-3.0\n"}, "3a1d1b00932d85cc4bc54b518df63907fcae8a02": {"t": 1785749258.7765355, "n": 3330, "h": "b3a1d61b0541b082", "c": "<div align=\"center\">\n\n# tf-triage\n\n**AI-powered security and architecture reviews for your Terraform plans.**\n\n[](https://github.com/balmha/tf-triage/releases)\n[](LICENSE)\n[](https://go.dev)\n\n---\n\nPipe your `terraform plan -json` through `tf-triage` and get an instant\nsecurity audit, architecture impact analysis, and blast radius assessment\n— formatted as Markdown, ready to drop into a PR comment.\n\n```\nterraform plan -json | tf-triage\n```\n\n</div>\n\n---\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew tap balmha/tap\nbrew trust balmha/tap\nbrew install tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.1.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n## Usage\n\n```bash\n# Pipe from terraform (uses Anthropic by default)\nterraform plan -json | tf-triage\n\n# Use OpenAI with a specific model\ntf-triage -f plan.json -p openai -m gpt-4o\n\n# Write report to a file\ntofu plan -json | tf-triage -o report.md\n```\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `anthropic` | LLM provider (`anthropic` or `openai`) |\n| `--model` | `-m` | auto | Model override (defaults: `claude-3-5-sonnet` / `gpt-4o`) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `ANTHROPIC_API_KEY` | API key for Anthropic (Claude) |\n| `OPENAI_API_KEY` | API key for OpenAI |\n| `TF_TRIAGE_MODEL` | Default model (overridden by `--model` flag) |\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file.\n2. **Parse** — Validates schema (`format_version`, `resource_changes`) and extracts changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with:\n\n- **Executive Summary** — Plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis, IAM risks, exposure gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n## Project Structure\n\n```\ntf-triage/\n├── go.mod\n├── main.go\n├── cmd/\n│ └── root.go # Cobra command and flag definitions\n├── pkg/\n│ ├── parser/\n│ │ └── parser.go # Plan parsing and token optimization\n│ └── llm/\n│ └── client.go # LLM client orchestration and prompt management\n└── README.md\n```\n\n## License\n\nGPL-3.0\n"}, "d3dca3e86f013e75d313c382834f78e5dddca58f": {"t": 1785749259.8439398, "n": 3308, "h": "533b375390c8159d", "c": "<div align=\"center\">\n\n# tf-triage\n\n**AI-powered security and architecture reviews for your Terraform plans.**\n\n[](https://github.com/balmha/tf-triage/releases)\n[](LICENSE)\n[](https://go.dev)\n\n---\n\nPipe your `terraform plan -json` through `tf-triage` and get an instant\nsecurity audit, architecture impact analysis, and blast radius assessment\n— formatted as Markdown, ready to drop into a PR comment.\n\n```\nterraform plan -json | tf-triage\n```\n\n</div>\n\n---\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew tap balmha/tap\nbrew install tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.1.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n## Usage\n\n```bash\n# Pipe from terraform (uses Anthropic by default)\nterraform plan -json | tf-triage\n\n# Use OpenAI with a specific model\ntf-triage -f plan.json -p openai -m gpt-4o\n\n# Write report to a file\ntofu plan -json | tf-triage -o report.md\n```\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `anthropic` | LLM provider (`anthropic` or `openai`) |\n| `--model` | `-m` | auto | Model override (defaults: `claude-3-5-sonnet` / `gpt-4o`) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `ANTHROPIC_API_KEY` | API key for Anthropic (Claude) |\n| `OPENAI_API_KEY` | API key for OpenAI |\n| `TF_TRIAGE_MODEL` | Default model (overridden by `--model` flag) |\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file.\n2. **Parse** — Validates schema (`format_version`, `resource_changes`) and extracts changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with:\n\n- **Executive Summary** — Plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis, IAM risks, exposure gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n## Project Structure\n\n```\ntf-triage/\n├── go.mod\n├── main.go\n├── cmd/\n│ └── root.go # Cobra command and flag definitions\n├── pkg/\n│ ├── parser/\n│ │ └── parser.go # Plan parsing and token optimization\n│ └── llm/\n│ └── client.go # LLM client orchestration and prompt management\n└── README.md\n```\n\n## License\n\nGPL-3.0\n"}, "d82f3533012d424eb81074cc46131fb10c8643a2": {"t": 1785749260.9272623, "n": 3295, "h": "7e62cfbea98c7a02", "c": "<div align=\"center\">\n\n# tf-triage\n\n**AI-powered security and architecture reviews for your Terraform plans.**\n\n[](https://github.com/balmha/tf-triage/releases)\n[](LICENSE)\n[](https://go.dev)\n\n---\n\nPipe your `terraform plan -json` through `tf-triage` and get an instant\nsecurity audit, architecture impact analysis, and blast radius assessment\n— formatted as Markdown, ready to drop into a PR comment.\n\n```\nterraform plan -json | tf-triage\n```\n\n</div>\n\n---\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew tap balmha/tap\nbrew install tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.1.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n## Usage\n\n```bash\n# Pipe from terraform (uses Anthropic by default)\nterraform plan -json | tf-triage\n\n# Use OpenAI with a specific model\ntf-triage -f plan.json -p openai -m gpt-4o\n\n# Write report to a file\ntofu plan -json | tf-triage -o report.md\n```\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `anthropic` | LLM provider (`anthropic` or `openai`) |\n| `--model` | `-m` | auto | Model override (defaults: `claude-3-5-sonnet` / `gpt-4o`) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `ANTHROPIC_API_KEY` | API key for Anthropic (Claude) |\n| `OPENAI_API_KEY` | API key for OpenAI |\n| `TF_TRIAGE_MODEL` | Default model (overridden by `--model` flag) |\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file.\n2. **Parse** — Validates schema (`format_version`, `resource_changes`) and extracts changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with:\n\n- **Executive Summary** — Plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis, IAM risks, exposure gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n## Project Structure\n\n```\ntf-triage/\n├── go.mod\n├── main.go\n├── cmd/\n│ └── root.go # Cobra command and flag definitions\n├── pkg/\n│ ├── parser/\n│ │ └── parser.go # Plan parsing and token optimization\n│ └── llm/\n│ └── client.go # LLM client orchestration and prompt management\n└── README.md\n```\n\n## License\n\nMIT\n"}, "caf5092d17a1e3129c6c5f2cd0d510f38d380cb0": {"t": 1785749262.000487, "n": 2904, "h": "cf47dade0de32f90", "c": "# tf-triage\n\nSemantic architectural and security analysis of Terraform/OpenTofu plans, powered by LLMs.\n\n`tf-triage` ingests `terraform plan -json` (or `tofu plan -json`) output, runs it through an LLM for architectural and security review, and produces a Markdown report ready to post as a Pull Request comment.\n\n## Installation\n\n### Homebrew (macOS)\n\n```bash\nbrew tap balmha/tap\nbrew install tf-triage\n```\n\n### Linux / macOS (one-liner)\n\n```bash\ncurl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\nOptionally pin a version or change the install directory:\n\n```bash\nVERSION=v0.1.0 INSTALL_DIR=~/.local/bin curl -sSfL https://raw.githubusercontent.com/balmha/tf-triage/main/install.sh | bash\n```\n\n### Go install\n\n```bash\ngo install github.com/balmha/tf-triage@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/balmha/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n## Usage\n\n```bash\n# Pipe from terraform (uses Anthropic by default)\nterraform plan -json | tf-triage\n\n# Use OpenAI with a specific model\ntf-triage -f plan.json -p openai -m gpt-4o\n\n# Write report to a file\ntofu plan -json | tf-triage -o report.md\n```\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `anthropic` | LLM provider (`anthropic` or `openai`) |\n| `--model` | `-m` | auto | Model override (defaults: `claude-3-5-sonnet` / `gpt-4o`) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `ANTHROPIC_API_KEY` | API key for Anthropic (Claude) |\n| `OPENAI_API_KEY` | API key for OpenAI |\n| `TF_TRIAGE_MODEL` | Default model (overridden by `--model` flag) |\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file.\n2. **Parse** — Validates schema (`format_version`, `resource_changes`) and extracts changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with:\n\n- **Executive Summary** — Plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis, IAM risks, exposure gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n## Project Structure\n\n```\ntf-triage/\n├── go.mod\n├── main.go\n├── cmd/\n│ └── root.go # Cobra command and flag definitions\n├── pkg/\n│ ├── parser/\n│ │ └── parser.go # Plan parsing and token optimization\n│ └── llm/\n│ └── client.go # LLM client orchestration and prompt management\n└── README.md\n```\n\n## License\n\nMIT\n"}, "6e2d0573700255d0b47373a9c1ef2ae61b51dd46": {"t": 1785749263.0766325, "n": 2490, "h": "ceb3f312c88492c7", "c": "# tf-triage\n\nSemantic architectural and security analysis of Terraform/OpenTofu plans, powered by LLMs.\n\n`tf-triage` ingests `terraform plan -json` (or `tofu plan -json`) output, runs it through an LLM for architectural and security review, and produces a Markdown report ready to post as a Pull Request comment.\n\n## Installation\n\n```bash\ngo install github.com/tf-triage/tf-triage@latest\n```\n\nOr build from source:\n\n```bash\ngit clone https://github.com/tf-triage/tf-triage.git\ncd tf-triage\ngo build -o tf-triage .\n```\n\n## Usage\n\n```bash\n# Pipe from terraform (uses Anthropic by default)\nterraform plan -json | tf-triage\n\n# Use OpenAI with a specific model\ntf-triage -f plan.json -p openai -m gpt-4o\n\n# Write report to a file\ntofu plan -json | tf-triage -o report.md\n```\n\n## Flags\n\n| Flag | Short | Default | Description |\n|------|-------|---------|-------------|\n| `--file` | `-f` | stdin | Path to terraform plan JSON file |\n| `--provider` | `-p` | `anthropic` | LLM provider (`anthropic` or `openai`) |\n| `--model` | `-m` | auto | Model override (defaults: `claude-3-5-sonnet` / `gpt-4o`) |\n| `--output` | `-o` | stdout | Path to write the markdown report |\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `ANTHROPIC_API_KEY` | API key for Anthropic (Claude) |\n| `OPENAI_API_KEY` | API key for OpenAI |\n| `TF_TRIAGE_MODEL` | Default model (overridden by `--model` flag) |\n\n## How It Works\n\n1. **Ingest** — Reads plan JSON from stdin or a file.\n2. **Parse** — Validates schema (`format_version`, `resource_changes`) and extracts changes.\n3. **Optimize** — Strips metadata, skips no-ops, computes diffs to minimize token usage.\n4. **Analyze** — Sends the optimized payload to the LLM with a security-focused system prompt.\n5. **Render** — Outputs formatted Markdown to stdout or a file.\n\n## Report Sections\n\nThe LLM produces a structured report with:\n\n- **Executive Summary** — Plain-English explanation of what the change accomplishes.\n- **Security & Architectural Audit** — Defense-in-depth analysis, IAM risks, exposure gaps.\n- **Blast Radius Assessment** — LOW / MEDIUM / HIGH classification with justification.\n\n## Project Structure\n\n```\ntf-triage/\n├── go.mod\n├── main.go\n├── cmd/\n│ └── root.go # Cobra command and flag definitions\n├── pkg/\n│ ├── parser/\n│ │ └── parser.go # Plan parsing and token optimization\n│ └── llm/\n│ └── client.go # LLM client orchestration and prompt management\n└── README.md\n```\n\n## License\n\nMIT\n"}, "7e974d90b35b319ffd10e39db8319b9d75e352e4": {"t": 1785749264.1735182, "n": 11, "h": "15364624f2e4a4a1", "c": "# tf-triage"}}}} |