diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..b393ec20d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +GOSEC_AI_API_KEY=your_atlas_cloud_api_key +ATLASCLOUD_API_KEY=your_atlas_cloud_api_key +GOSEC_AI_PROVIDER=atlas +GOSEC_AI_MODEL=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/ATLAS_CLOUD_REVIEW.md b/ATLAS_CLOUD_REVIEW.md new file mode 100644 index 0000000000..b25ead6534 --- /dev/null +++ b/ATLAS_CLOUD_REVIEW.md @@ -0,0 +1,42 @@ +# Atlas Cloud Provider Review + +## What Changed + +- Added a first-class `atlas` AI provider preset in `autofix`. +- Defaulted Atlas Cloud traffic to `https://api.atlascloud.ai/v1`. +- Added Atlas model aliases: + - `atlas` -> `deepseek-ai/deepseek-v4-flash` + - `atlas-deepseek-v4-flash` -> `deepseek-ai/deepseek-v4-flash` + - `atlas-qwen3-coder-next` -> `qwen/qwen3-coder-next` + - `atlas-kimi-k2.6` -> `moonshotai/kimi-k2.6` + - `atlas/` and `atlas:` for direct model pass-through +- Added `ATLASCLOUD_API_KEY` fallback support in the CLI when `-ai-api-provider` starts with `atlas`. +- Updated README with Atlas Cloud usage, examples, and the official link: + `https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=gosec` +- Added `.env.example` for local setup and ignored `.env.local` files. + +## Files Changed + +- `autofix/ai.go` +- `autofix/atlas.go` +- `autofix/ai_test.go` +- `autofix/atlas_test.go` +- `cmd/gosec/main.go` +- `README.md` +- `.gitignore` +- `.env.example` + +## Local Validation Plan + +- Unit test the `autofix` package. +- Build and run `gosec` against a temporary vulnerable sample with `-ai-api-provider=atlas`. +- Validate direct Atlas Cloud non-stream and stream responses with the provided API key. + +## Validation Results + +- `go test ./...` passed. +- Atlas Cloud `/v1/models` responded successfully and returned account-accessible model IDs. +- Atlas Cloud non-stream chat completion succeeded with `deepseek-ai/deepseek-v4-flash`. +- Atlas Cloud stream chat completion succeeded with `deepseek-ai/deepseek-v4-flash`. +- `gosec` binary integration succeeded: + `-ai-api-provider=atlas` generated a live Autofix for a temporary `G402` sample. diff --git a/README.md b/README.md index 589727b812..c38c793a39 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,14 @@ 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/` / `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` @@ -411,6 +419,8 @@ line arguments: (requires `ai-base-url`) - `ai-api-key` or set the environment variable `GOSEC_AI_API_KEY`: the key to access the AI API + - For Atlas Cloud, you can also set `ATLASCLOUD_API_KEY` + and use the default base URL `https://api.atlascloud.ai/v1` - For Gemini, you can create an API key following [these instructions](https://ai.google.dev/gemini-api/docs/api-key) - For Claude, get your API key from @@ -420,12 +430,23 @@ 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 - `ai-skip-ssl`: (optional) skip SSL certificate verification for AI API (useful for self-signed certificates) **Examples:** ```bash +# Using Atlas Cloud with the default DeepSeek V4 Flash model +export ATLASCLOUD_API_KEY="your_key" +gosec -ai-api-provider="atlas" ./... + +# Using Atlas Cloud with an explicit hosted model +gosec -ai-api-provider="atlas:qwen/qwen3-coder-next" \ + -ai-api-key="your_key" ./... + # 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..0c7a6bc3e0 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/, 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..9f987707a8 --- /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..f6286fc008 --- /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, string(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", string(wrapper.model)) +} diff --git a/cmd/gosec/main.go b/cmd/gosec/main.go index 50df79af4e..e52a012db3 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 + atlasAPIKeyEnv = "ATLASCLOUD_API_KEY" + atlasProviderEnv = "atlas" // Exit codes exitSuccess = 0 @@ -181,7 +183,7 @@ Use "*" to exclude all rules for a path: "scripts/.*:*"`) flagAiAPIKey = flag.String("ai-api-key", "", "Key to access the AI API") // base URL for AI API (optional, for OpenAI-compatible APIs) - flagAiBaseURL = flag.String("ai-base-url", "", "Base URL for AI API (e.g., for OpenAI-compatible services)") + flagAiBaseURL = flag.String("ai-base-url", "", "Base URL for AI API (e.g., for Atlas Cloud or other OpenAI-compatible services)") // skip SSL verification for AI API flagAiSkipSSL = flag.Bool("ai-skip-ssl", false, "Skip SSL certificate verification for AI API") @@ -589,6 +591,9 @@ func run() int { // Call AI request to solve the issues aiAPIKey := os.Getenv(aiAPIKeyEnv) + if aiAPIKey == "" && strings.HasPrefix(*flagAiAPIProvider, atlasProviderEnv) { + aiAPIKey = os.Getenv(atlasAPIKeyEnv) + } if aiAPIKey == "" { aiAPIKey = *flagAiAPIKey }