diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..0a7ec71e54 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +GOSEC_AI_API_KEY=your_ai_api_key +GOSEC_AI_PROVIDER=atlas +GOSEC_AI_BASE_URL=https://api.atlascloud.ai/v1 diff --git a/.gitignore b/.gitignore index d9f5c1fc99..eac8f99dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,10 +33,12 @@ coverage.out *.prof .DS_Store +.env.local +.env.*.local .vscode .idea # SBOMs generated during CI /bom.json -1 \ No newline at end of file +1 diff --git a/README.md b/README.md index 589727b812..c3362fa913 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,13 @@ line arguments: - `ai-api-provider`: the name of the AI API provider. Supported providers: + - **Atlas Cloud**: `atlas` (default model + `deepseek-ai/deepseek-v4-flash`), + `atlas-deepseek-v4-flash`, + `atlas-qwen3-coder-next`, `atlas-kimi-k2.6`, or + `atlas:` for any Atlas Cloud hosted chat model. + Atlas Cloud is an OpenAI-compatible provider available at + [atlascloud.ai](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=gosec) - **Gemini**: `gemini-3-pro-preview` (default), `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` @@ -420,12 +427,32 @@ line arguments: - `ai-base-url`: (optional) custom base URL for OpenAI-compatible APIs (e.g., Azure OpenAI, LocalAI, Ollama) + - Atlas Cloud uses `https://api.atlascloud.ai/v1` by default, + so `ai-base-url` is optional for the built-in `atlas` + provider +- `GOSEC_AI_PROVIDER`: (optional) environment variable + alternative to `ai-api-provider` +- `GOSEC_AI_BASE_URL`: (optional) environment variable + alternative to `ai-base-url` - `ai-skip-ssl`: (optional) skip SSL certificate verification for AI API (useful for self-signed certificates) +> 🎁 **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=gosec)** is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. +> +> Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access: [https://www.atlascloud.ai/console/coding-plan](https://www.atlascloud.ai/console/coding-plan) + **Examples:** ```bash +# Using Atlas Cloud with the default DeepSeek V4 Flash model +export GOSEC_AI_API_KEY="your_key" +export GOSEC_AI_PROVIDER="atlas" +gosec ./... + +# Using Atlas Cloud with an explicit hosted model +GOSEC_AI_API_KEY="your_key" \ + gosec -ai-api-provider="atlas:qwen/qwen3-coder-next" ./... + # Using Gemini gosec -ai-api-provider="gemini-3-pro-preview" \ -ai-api-key="your_key" ./... diff --git a/autofix/ai.go b/autofix/ai.go index f8d0690a5c..f1a208ea2d 100644 --- a/autofix/ai.go +++ b/autofix/ai.go @@ -12,6 +12,7 @@ import ( const ( AIProviderFlagHelp = `AI API provider to generate auto fixes to issues. Valid options are: + - atlas (Atlas Cloud default), atlas-deepseek-v4-flash, atlas-qwen3-coder-next, atlas-kimi-k2.6, atlas:; - gemini-3-pro-preview (gemini, default), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite; - claude-sonnet-4-6 (claude, default), claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-5, claude-opus-4-5, claude-haiku-4-5; - gpt-5.4 (openai, default), gpt-5.4-mini, gpt-5.4-nano` @@ -32,6 +33,14 @@ func GenerateSolution(model, aiAPIKey, baseURL string, skipSSL bool, issues []*i var client GenAIClient switch { + case model == "atlas" || strings.HasPrefix(model, "atlas-") || strings.HasPrefix(model, "atlas/") || strings.HasPrefix(model, "atlas:"): + config := atlasConfig{ + Model: model, + APIKey: aiAPIKey, + BaseURL: baseURL, + SkipSSL: skipSSL, + } + client, err = newAtlasClient(config) case strings.HasPrefix(model, "claude"): client, err = NewClaudeClient(model, aiAPIKey) case strings.HasPrefix(model, "gemini"): @@ -76,11 +85,11 @@ func generateSolution(client GenAIClient, issues []*issue.Issue) error { prompt := fmt.Sprintf(AIPrompt, issue.What) resp, err := client.GenerateSolution(ctx, prompt) if err != nil { - return fmt.Errorf("generating autofix with gemini: %w", err) + return fmt.Errorf("generating autofix with AI provider: %w", err) } if resp == "" { - return errors.New("no autofix returned by gemini") + return errors.New("no autofix returned by AI provider") } issue.Autofix = resp diff --git a/autofix/ai_test.go b/autofix/ai_test.go index ea012f5bad..3a6c14a292 100644 --- a/autofix/ai_test.go +++ b/autofix/ai_test.go @@ -53,7 +53,7 @@ func TestGenerateSolutionByGemini_NoCandidates(t *testing.T) { err := generateSolution(mockClient, issues) // Assert - require.EqualError(t, err, "no autofix returned by gemini") + require.EqualError(t, err, "no autofix returned by AI provider") mock.AssertExpectationsForObjects(t, mockClient) } @@ -70,7 +70,7 @@ func TestGenerateSolutionByGemini_APIError(t *testing.T) { err := generateSolution(mockClient, issues) // Assert - require.EqualError(t, err, "generating autofix with gemini: API error") + require.EqualError(t, err, "generating autofix with AI provider: API error") mock.AssertExpectationsForObjects(t, mockClient) } diff --git a/autofix/atlas.go b/autofix/atlas.go new file mode 100644 index 0000000000..e09aad089a --- /dev/null +++ b/autofix/atlas.go @@ -0,0 +1,68 @@ +package autofix + +import "strings" + +const ( + modelAtlasDefault = "deepseek-ai/deepseek-v4-flash" + modelAtlasDeepSeekV4Flash = "deepseek-ai/deepseek-v4-flash" + modelAtlasQwenCoderNext = "qwen/qwen3-coder-next" + modelAtlasKimiK26 = "moonshotai/kimi-k2.6" + + defaultAtlasBaseURL = "https://api.atlascloud.ai/v1" +) + +type atlasConfig struct { + Model string + APIKey string `json:"-"` + BaseURL string + MaxTokens int + Temperature float64 + SkipSSL bool +} + +func newAtlasClient(config atlasConfig) (GenAIClient, error) { + baseURL := config.BaseURL + if baseURL == "" { + baseURL = defaultAtlasBaseURL + } + + return NewOpenAIClient(OpenAIConfig{ + Model: parseAtlasModel(config.Model), + APIKey: config.APIKey, + BaseURL: baseURL, + MaxTokens: config.MaxTokens, + Temperature: config.Temperature, + SkipSSL: config.SkipSSL, + }) +} + +func parseAtlasModel(model string) string { + switch model { + case "", "atlas", "atlas-deepseek-v4-flash": + return modelAtlasDefault + case "atlas-qwen3-coder-next", "atlas-qwen-turbo": + return modelAtlasQwenCoderNext + case "atlas-kimi-k2.6", "atlas-kimi-k2": + return modelAtlasKimiK26 + } + + for _, prefix := range []string{"atlas/", "atlas:"} { + if strings.HasPrefix(model, prefix) { + trimmed := strings.TrimPrefix(model, prefix) + if trimmed != "" { + return trimmed + } + return modelAtlasDefault + } + } + + if strings.HasPrefix(model, "atlas-") { + trimmed := strings.TrimPrefix(model, "atlas-") + if trimmed != "" { + return trimmed + } + return modelAtlasDefault + } + + return model +} diff --git a/autofix/atlas_test.go b/autofix/atlas_test.go new file mode 100644 index 0000000000..e881dad5a4 --- /dev/null +++ b/autofix/atlas_test.go @@ -0,0 +1,85 @@ +package autofix + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAtlasModel(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "atlas defaults to deepseek v3", + input: "atlas", + expected: modelAtlasDefault, + }, + { + name: "atlas deepseek alias", + input: "atlas-deepseek-v4-flash", + expected: modelAtlasDeepSeekV4Flash, + }, + { + name: "atlas qwen alias", + input: "atlas-qwen3-coder-next", + expected: modelAtlasQwenCoderNext, + }, + { + name: "atlas kimi alias", + input: "atlas-kimi-k2.6", + expected: modelAtlasKimiK26, + }, + { + name: "atlas slash syntax", + input: "atlas/deepseek-v3", + expected: "deepseek-v3", + }, + { + name: "atlas colon syntax", + input: "atlas:qwen-plus", + expected: "qwen-plus", + }, + { + name: "unknown non atlas model passes through", + input: "custom-model", + expected: "custom-model", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, parseAtlasModel(tt.input)) + }) + } +} + +func TestNewAtlasClient_Defaults(t *testing.T) { + client, err := newAtlasClient(atlasConfig{ + Model: "atlas", + APIKey: "test-key", + }) + require.NoError(t, err) + require.NotNil(t, client) + + wrapper, ok := client.(*openaiWrapper) + require.True(t, ok) + assert.Equal(t, modelAtlasDefault, wrapper.model) + assert.Equal(t, 1024, wrapper.maxTokens) + assert.InEpsilon(t, 0.7, wrapper.temperature, 0.001) +} + +func TestNewAtlasClient_CustomModelSyntax(t *testing.T) { + client, err := newAtlasClient(atlasConfig{ + Model: "atlas/moonshot-v1-8k", + APIKey: "test-key", + BaseURL: defaultAtlasBaseURL, + }) + require.NoError(t, err) + + wrapper := client.(*openaiWrapper) + assert.Equal(t, "moonshot-v1-8k", wrapper.model) +} diff --git a/cmd/gosec/main.go b/cmd/gosec/main.go index 50df79af4e..386a45eee1 100644 --- a/cmd/gosec/main.go +++ b/cmd/gosec/main.go @@ -66,7 +66,9 @@ USAGE: $ gosec --exclude-rules="scripts/.*:*" ./... ` // Environment variable for AI API key. - aiAPIKeyEnv = "GOSEC_AI_API_KEY" // #nosec G101 + aiAPIKeyEnv = "GOSEC_AI_API_KEY" // #nosec G101 + aiProviderEnv = "GOSEC_AI_PROVIDER" + aiBaseURLEnv = "GOSEC_AI_BASE_URL" // Exit codes exitSuccess = 0 @@ -588,15 +590,25 @@ func run() int { reportInfo := gosec.NewReportInfo(issues, metrics, errors).WithVersion(Version) // Call AI request to solve the issues + aiProvider := *flagAiAPIProvider + if aiProvider == "" { + aiProvider = os.Getenv(aiProviderEnv) + } + aiAPIKey := os.Getenv(aiAPIKeyEnv) if aiAPIKey == "" { aiAPIKey = *flagAiAPIKey } - aiEnabled := *flagAiAPIProvider != "" + aiBaseURL := *flagAiBaseURL + if aiBaseURL == "" { + aiBaseURL = os.Getenv(aiBaseURLEnv) + } + + aiEnabled := aiProvider != "" if len(issues) > 0 && aiEnabled { - err := autofix.GenerateSolution(*flagAiAPIProvider, aiAPIKey, *flagAiBaseURL, *flagAiSkipSSL, issues) + err := autofix.GenerateSolution(aiProvider, aiAPIKey, aiBaseURL, *flagAiSkipSSL, issues) if err != nil { logger.Print(err) }