Compare commits

...

2 Commits

Author SHA1 Message Date
kfess
424fc4cd9c feature: add rule for trojan source (#1431)
* feature: add rule for trojan source

* use bufio.Scanner for memory efficiency

* Fix lint warnings

Change-Id: Ic1df6704ba5ab8b1834d7765abd49494a98835f8
Signed-off-by: Cosmin Cojocar <ccojocar@google.com>

---------

Signed-off-by: Cosmin Cojocar <ccojocar@google.com>
Co-authored-by: Cosmin Cojocar <ccojocar@google.com>
2025-12-11 10:14:29 +01:00
Bo-Yi Wu
aa2e2fb1bd feat(ai): add OpenAI and custom API provider support (#1424)
* feat(ai): add OpenAI and custom API provider support

- Expand AI provider support to include OpenAI (gpt-4o, gpt-4o-mini) and custom OpenAI-compatible APIs
- Add support for configuring AI API base URL and skipping SSL verification
- Update documentation to list all supported AI providers and clarify configuration options with examples
- Refactor AI client initialization to fallback on OpenAI-compatible API for unknown models
- Add OpenAI client implementation using openai-go library
- Update tests to validate OpenAI-compatible fallback behavior
- Add openai-go dependency to go.mod

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* Fix info message after merge

Change-Id: I1cb556a42e2bd9e9b2051d6db99889c6c9f7ccdb
Signed-off-by: Cosmin Cojocar <ccojocar@google.com>

* Fix lint warning

Change-Id: I3689b96205f494920dbbd03344e8f132a30f40b3
Signed-off-by: Cosmin Cojocar <ccojocar@google.com>

---------

Signed-off-by: appleboy <appleboy.tw@gmail.com>
Signed-off-by: Cosmin Cojocar <ccojocar@google.com>
Co-authored-by: Cosmin Cojocar <cosmin@cojocar.ch>
Co-authored-by: Cosmin Cojocar <ccojocar@google.com>
2025-12-11 09:53:19 +01:00
12 changed files with 381 additions and 14 deletions

View File

@@ -256,17 +256,52 @@ gosec -exclude-generated ./...
```
### Auto fixing vulnerabilities
gosec can suggest fixes based on AI recommendation. It will call an AI API to receive a suggestion for a security finding.
You can enable this feature by providing the following command line arguments:
- `ai-api-provider`: the name of the AI API provider, currently only `gemini`is supported.
- `ai-api-key` or set the environment variable `GOSEC_AI_API_KEY`: the key to access the AI API,
For gemini, you can create an API key following [these instructions](https://ai.google.dev/gemini-api/docs/api-key).
- `ai-endpoint`: the endpoint of the AI provider, this is optional argument.
- `ai-api-provider`: the name of the AI API provider. Supported providers:
- **Gemini**: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`, `gemini-2.0-flash`, `gemini-2.0-flash-lite` (default)
- **Claude**: `claude-sonnet-4-0` (default), `claude-opus-4-0`, `claude-opus-4-1`, `claude-sonnet-3-7`
- **OpenAI**: `gpt-4o` (default), `gpt-4o-mini`
- **Custom OpenAI-compatible**: Any custom model name (requires `ai-base-url`)
- `ai-api-key` or set the environment variable `GOSEC_AI_API_KEY`: the key to access the AI API
- 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 [Anthropic Console](https://console.anthropic.com/)
- For OpenAI, get your API key from [OpenAI Platform](https://platform.openai.com/api-keys)
- `ai-base-url`: (optional) custom base URL for OpenAI-compatible APIs (e.g., Azure OpenAI, LocalAI, Ollama)
- `ai-skip-ssl`: (optional) skip SSL certificate verification for AI API (useful for self-signed certificates)
**Examples:**
```bash
gosec -ai-api-provider="gemini" -ai-api-key="your_key" ./...
# Using Gemini
gosec -ai-api-provider="gemini-2.0-flash" -ai-api-key="your_key" ./...
# Using Claude
gosec -ai-api-provider="claude-sonnet-4-0" -ai-api-key="your_key" ./...
# Using OpenAI
gosec -ai-api-provider="gpt-4o" -ai-api-key="your_key" ./...
# Using Azure OpenAI
gosec -ai-api-provider="gpt-4o" \
-ai-api-key="your_azure_key" \
-ai-base-url="https://your-resource.openai.azure.com/openai/deployments/your-deployment" \
./...
# Using local Ollama with custom model
gosec -ai-api-provider="llama3.2" \
-ai-base-url="http://localhost:11434/v1" \
./...
# Using self-signed certificate API
gosec -ai-api-provider="custom-model" \
-ai-api-key="your_key" \
-ai-base-url="https://internal-api.company.com/v1" \
-ai-skip-ssl \
./...
```
### Annotating code

View File

@@ -13,7 +13,8 @@ import (
const (
AIProviderFlagHelp = `AI API provider to generate auto fixes to issues. Valid options are:
- gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, gemini-2.0-flash, gemini-2.0-flash-lite (gemini, default);
- claude-sonnet-4-0 (claude, default), claude-sonnet-4-5, claude-opus-4-0, claude-opus-4-1, claude-haiku-4-5, claude-sonnet-3-7`
- claude-sonnet-4-0 (claude, default), claude-sonnet-4-5, claude-opus-4-0, claude-opus-4-1, claude-haiku-4-5, claude-sonnet-3-7
- gpt-4o (openai, default), gpt-4o-mini`
AIPrompt = `Provide a brief explanation and a solution to fix this security issue
in Go programming language: %q.
@@ -27,7 +28,7 @@ type GenAIClient interface {
}
// GenerateSolution generates a solution for the given issues using the specified AI provider
func GenerateSolution(model, aiAPIKey string, issues []*issue.Issue) (err error) {
func GenerateSolution(model, aiAPIKey, baseURL string, skipSSL bool, issues []*issue.Issue) (err error) {
var client GenAIClient
switch {
@@ -35,13 +36,27 @@ func GenerateSolution(model, aiAPIKey string, issues []*issue.Issue) (err error)
client, err = NewClaudeClient(model, aiAPIKey)
case strings.HasPrefix(model, "gemini"):
client, err = NewGeminiClient(model, aiAPIKey)
case strings.HasPrefix(model, "gpt"):
config := OpenAIConfig{
Model: model,
APIKey: aiAPIKey,
BaseURL: baseURL,
SkipSSL: skipSSL,
}
client, err = NewOpenAIClient(config)
default:
// Default to OpenAI-compatible API for custom models
config := OpenAIConfig{
Model: model,
APIKey: aiAPIKey,
BaseURL: baseURL,
SkipSSL: skipSSL,
}
client, err = NewOpenAIClient(config)
}
switch {
case err != nil:
if err != nil {
return fmt.Errorf("initializing AI client: %w", err)
case client == nil:
return fmt.Errorf("unsupported AI backend: %s", model)
}
return generateSolution(client, issues)

View File

@@ -81,8 +81,11 @@ func TestGenerateSolution_UnsupportedProvider(t *testing.T) {
}
// Act
err := GenerateSolution("unsupported-provider", "test-api-key", issues)
// Note: With default OpenAI-compatible fallback, this will attempt to create an OpenAI client
// The test will fail during client initialization due to missing/invalid API key or base URL
err := GenerateSolution("custom-model", "", "", false, issues)
// Assert
require.EqualError(t, err, "unsupported AI backend: unsupported-provider")
// Expect an error during client initialization or API call
require.Error(t, err)
}

120
autofix/openai.go Normal file
View File

@@ -0,0 +1,120 @@
package autofix
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
const (
ModelGPT4o = openai.ChatModelGPT4o
ModelGPT4oMini = openai.ChatModelGPT4oMini
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
)
var _ GenAIClient = (*openaiWrapper)(nil)
type OpenAIConfig struct {
Model string
APIKey string
BaseURL string
MaxTokens int
Temperature float64
SkipSSL bool
}
type openaiWrapper struct {
client openai.Client
model openai.ChatModel
maxTokens int
temperature float64
}
func NewOpenAIClient(config OpenAIConfig) (GenAIClient, error) {
var options []option.RequestOption
if config.APIKey != "" {
options = append(options, option.WithAPIKey(config.APIKey))
}
// Support custom base URL (for OpenAI-compatible APIs)
if config.BaseURL != "" {
options = append(options, option.WithBaseURL(config.BaseURL))
}
// Support skip SSL verification
if config.SkipSSL {
// Create custom HTTP client with InsecureSkipVerify
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // #nosec G402
},
},
}
options = append(options, option.WithHTTPClient(httpClient))
}
openaiModel := parseOpenAIModel(config.Model)
// Set default values
maxTokens := config.MaxTokens
if maxTokens == 0 {
maxTokens = 1024
}
temperature := config.Temperature
if temperature == 0 {
temperature = 0.7
}
return &openaiWrapper{
client: openai.NewClient(options...),
model: openaiModel,
maxTokens: maxTokens,
temperature: temperature,
}, nil
}
func (o *openaiWrapper) GenerateSolution(ctx context.Context, prompt string) (string, error) {
params := openai.ChatCompletionNewParams{
Model: o.model,
Messages: []openai.ChatCompletionMessageParamUnion{
openai.UserMessage(prompt),
},
}
// Set optional parameters if available
// Using WithMaxTokens and WithTemperature methods if they exist in v3
resp, err := o.client.Chat.Completions.New(ctx, params)
if err != nil {
return "", fmt.Errorf("generating autofix: %w", err)
}
if resp == nil || len(resp.Choices) == 0 {
return "", errors.New("no autofix returned by openai")
}
content := resp.Choices[0].Message.Content
if content == "" {
return "", errors.New("nothing found in the first autofix returned by openai")
}
return content, nil
}
func parseOpenAIModel(model string) openai.ChatModel {
switch model {
case "gpt-4o":
return openai.ChatModelGPT4o
case "gpt-4o-mini":
return openai.ChatModelGPT4oMini
default:
return model
}
}

View File

@@ -159,6 +159,12 @@ var (
// key to implementing AI provider services
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)")
// skip SSL verification for AI API
flagAiSkipSSL = flag.Bool("ai-skip-ssl", false, "Skip SSL certificate verification for AI API")
// exclude the folders from scan
flagDirsExclude arrayFlags
@@ -501,7 +507,7 @@ func main() {
aiEnabled := *flagAiAPIProvider != ""
if len(issues) > 0 && aiEnabled {
err := autofix.GenerateSolution(*flagAiAPIProvider, aiAPIKey, issues)
err := autofix.GenerateSolution(*flagAiAPIProvider, aiAPIKey, *flagAiBaseURL, *flagAiSkipSSL, issues)
if err != nil {
logger.Print(err)
}

1
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/mozilla/tls-observatory v0.0.0-20250923143331-eef96233227e
github.com/onsi/ginkgo/v2 v2.27.2
github.com/onsi/gomega v1.38.2
github.com/openai/openai-go/v3 v3.8.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/stretchr/testify v1.11.1
go.yaml.in/yaml/v3 v3.0.4

2
go.sum
View File

@@ -311,6 +311,8 @@ github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zw
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/openai/openai-go/v3 v3.8.1 h1:b+YWsmwqXnbpSHWQEntZAkKciBZ5CJXwL68j+l59UDg=
github.com/openai/openai-go/v3 v3.8.1/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=

View File

@@ -67,6 +67,7 @@ var ruleToCWE = map[string]string{
"G112": "400",
"G114": "676",
"G115": "190",
"G116": "838",
"G201": "89",
"G202": "89",
"G203": "79",

View File

@@ -76,6 +76,7 @@ func Generate(trackSuppressions bool, filters ...RuleFilter) RuleList {
{"G111", "Detect http.Dir('/') as a potential risk", NewDirectoryTraversal},
{"G112", "Detect ReadHeaderTimeout not configured as a potential risk", NewSlowloris},
{"G114", "Use of net/http serve function that has no support for setting timeouts", NewHTTPServeWithoutTimeouts},
{"G116", "Detect Trojan Source attacks using bidirectional Unicode characters", NewTrojanSource},
// injection
{"G201", "SQL query construction using format string", NewSQLStrFormat},

View File

@@ -107,6 +107,10 @@ var _ = Describe("gosec rules", func() {
runner("G114", testutils.SampleCodeG114)
})
It("should detect Trojan Source attacks using bidirectional Unicode characters", func() {
runner("G116", testutils.SampleCodeG116)
})
It("should detect sql injection via format strings", func() {
runner("G201", testutils.SampleCodeG201)
})

96
rules/trojansource.go Normal file
View File

@@ -0,0 +1,96 @@
package rules
import (
"go/ast"
"os"
"github.com/securego/gosec/v2"
"github.com/securego/gosec/v2/issue"
)
type trojanSource struct {
issue.MetaData
bidiChars map[rune]struct{}
}
func (r *trojanSource) ID() string {
return r.MetaData.ID
}
func (r *trojanSource) Match(node ast.Node, c *gosec.Context) (*issue.Issue, error) {
if file, ok := node.(*ast.File); ok {
fobj := c.FileSet.File(file.Pos())
if fobj == nil {
return nil, nil
}
content, err := os.ReadFile(fobj.Name())
if err != nil {
return nil, nil
}
for _, ch := range string(content) {
if _, exists := r.bidiChars[ch]; exists {
return c.NewIssue(node, r.ID(), r.What, r.Severity, r.Confidence), nil
}
}
}
return nil, nil
}
// func (r *trojanSource) Match(node ast.Node, c *gosec.Context) (*issue.Issue, error) {
// if file, ok := node.(*ast.File); ok {
// fobj := c.FileSet.File(file.Pos())
// if fobj == nil {
// return nil, nil
// }
// file, err := os.Open(fobj.Name())
// if err != nil {
// log.Fatal(err)
// }
// defer file.Close()
// scanner := bufio.NewScanner(file)
// for scanner.Scan() {
// line := scanner.Text()
// for _, ch := range line {
// if _, exists := r.bidiChars[ch]; exists {
// return c.NewIssue(node, r.ID(), r.What, r.Severity, r.Confidence), nil
// }
// }
// }
// if err := scanner.Err(); err != nil {
// log.Fatal(err)
// }
// }
// return nil, nil
// }
func NewTrojanSource(id string, _ gosec.Config) (gosec.Rule, []ast.Node) {
return &trojanSource{
MetaData: issue.MetaData{
ID: id,
Severity: issue.High,
Confidence: issue.Medium,
What: "Potential Trojan Source vulnerability via use of bidirectional text control characters",
},
bidiChars: map[rune]struct{}{
'\u202a': {},
'\u202b': {},
'\u202c': {},
'\u202d': {},
'\u202e': {},
'\u2066': {},
'\u2067': {},
'\u2068': {},
'\u2069': {},
'\u200e': {},
'\u200f': {},
},
}, []ast.Node{(*ast.File)(nil)}
}

83
testutils/g116_samples.go Normal file
View File

@@ -0,0 +1,83 @@
package testutils
import "github.com/securego/gosec/v2"
// #nosec - This file intentionally contains bidirectional Unicode characters
// for testing trojan source detection. The G116 rule scans the entire file content (not just AST nodes)
// because trojan source attacks work by manipulating visual representation of code through bidirectional
// text control characters, which can appear in comments, strings or anywhere in the source file.
// Without this #nosec exclusion, gosec would detect these test samples as actual vulnerabilities.
var (
// SampleCodeG116 - TrojanSource code snippets
SampleCodeG116 = []CodeSample{
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// This comment contains bidirectional unicode: access\u202e\u2066 granted\u2069\u202d\n\tisAdmin := false\n\tfmt.Println(\"Access status:\", isAdmin)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Trojan source with RLO character\n\taccessLevel := \"user\"\n\t// Actually assigns \"nimda\" due to bidi chars: accessLevel = \"\u202enimda\"\n\tif accessLevel == \"admin\" {\n\t\tfmt.Println(\"Access granted\")\n\t}\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// String with bidirectional override\n\tusername := \"admin\u202e \u2066Check if admin\u2069 \u2066\"\n\tpassword := \"secret\"\n\tfmt.Println(username, password)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains LRI (Left-to-Right Isolate) U+2066\n\tcomment := \"Safe comment \u2066with hidden text\u2069\"\n\tfmt.Println(comment)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains RLI (Right-to-Left Isolate) U+2067\n\tmessage := \"Normal text \u2067hidden\u2069\"\n\tfmt.Println(message)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains FSI (First Strong Isolate) U+2068\n\ttext := \"Text with \u2068hidden content\u2069\"\n\tfmt.Println(text)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains LRE (Left-to-Right Embedding) U+202A\n\tembedded := \"Text with \u202aembedded\u202c content\"\n\tfmt.Println(embedded)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains RLE (Right-to-Left Embedding) U+202B\n\trtlEmbedded := \"Text with \u202bembedded\u202c content\"\n\tfmt.Println(rtlEmbedded)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains PDF (Pop Directional Formatting) U+202C\n\tformatted := \"Text with \u202cformatting\"\n\tfmt.Println(formatted)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains LRO (Left-to-Right Override) U+202D\n\toverride := \"Text \u202doverride\"\n\tfmt.Println(override)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains RLO (Right-to-Left Override) U+202E\n\trloText := \"Text \u202eoverride\"\n\tfmt.Println(rloText)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains RLM (Right-to-Left Mark) U+200F\n\tmarked := \"Text \u200fmarked\"\n\tfmt.Println(marked)\n}\n"}, 1, gosec.NewConfig()},
{[]string{"\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n\t// Contains LRM (Left-to-Right Mark) U+200E\n\tlrmText := \"Text \u200emarked\"\n\tfmt.Println(lrmText)\n}\n"}, 1, gosec.NewConfig()},
{[]string{`
package main
import "fmt"
// Safe code without bidirectional characters
func main() {
username := "admin"
password := "secret"
fmt.Println("Username:", username)
fmt.Println("Password:", password)
}
`}, 0, gosec.NewConfig()},
{[]string{`
package main
import "fmt"
// Normal comment with regular text
func main() {
// This is a safe comment
isAdmin := true
if isAdmin {
fmt.Println("Access granted")
}
}
`}, 0, gosec.NewConfig()},
{[]string{`
package main
import "fmt"
func main() {
// Regular ASCII characters only
message := "Hello, World!"
fmt.Println(message)
}
`}, 0, gosec.NewConfig()},
{[]string{`
package main
import "fmt"
func authenticateUser(username, password string) bool {
// Normal authentication logic
if username == "admin" && password == "secret" {
return true
}
return false
}
func main() {
result := authenticateUser("user", "pass")
fmt.Println("Authenticated:", result)
}
`}, 0, gosec.NewConfig()},
}
)