Compare commits

...

2 Commits

Author SHA1 Message Date
oittaa
8a5404eabf feat(resolve): enhance TryResolve to handle KeyValueExpr, IndexExpr, and SliceExpr (#1452)
* feat(resolve): enhance TryResolve to handle KeyValueExpr, IndexExpr, and SliceExpr

* golangci-lint
2026-01-04 17:22:20 +02:00
oittaa
0f6f21cb3f feat: add secrets serialization G117 (#1451)
* Rule to detect secrets serialization

* Add G117 to rules_test.go

* Fix false positives

* Map to CWE 499, update README
2026-01-04 17:21:22 +02:00
10 changed files with 375 additions and 1 deletions

View File

@@ -173,6 +173,7 @@ directory you can supply `./...` as the input argument.
- G114: Use of net/http serve function that has no support for setting timeouts
- G115: Potential integer overflow when converting between integer types
- G116: Detect Trojan Source attacks using bidirectional Unicode control characters
- G117: Potential exposure of secrets via JSON marshaling
- G201: SQL query construction using format string
- G202: SQL query construction using string concatenation
- G203: Use of unescaped data in HTML templates

View File

@@ -21,7 +21,7 @@ var _ GenAIClient = (*openaiWrapper)(nil)
type OpenAIConfig struct {
Model string
APIKey string
APIKey string `json:"-"`
BaseURL string
MaxTokens int
Temperature float64

View File

@@ -118,6 +118,11 @@ var idWeaknesses = map[string]*Weakness{
Description: "The software does not handle or incorrectly handles a compressed input with a very high compression ratio that produces a large output.",
Name: "Improper Handling of Highly Compressed Data (Data Amplification)",
},
"499": {
ID: "499",
Description: "The code contains a class with sensitive data, but the class does not explicitly deny serialization. The data can be accessed by serializing the class through another class.",
Name: "Serializable Class Containing Sensitive Data",
},
"676": {
ID: "676",
Description: "The program invokes a potentially dangerous function that could introduce a vulnerability if it is used incorrectly, but the function can also be used safely.",

View File

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

View File

@@ -90,6 +90,12 @@ func TryResolve(n ast.Node, c *Context) bool {
return resolveCallExpr(node, c)
case *ast.BinaryExpr:
return resolveBinExpr(node, c)
case *ast.KeyValueExpr:
return TryResolve(node.Key, c) && TryResolve(node.Value, c)
case *ast.IndexExpr:
return TryResolve(node.X, c)
case *ast.SliceExpr:
return TryResolve(node.X, c)
}
return false
}

View File

@@ -77,6 +77,7 @@ func Generate(trackSuppressions bool, filters ...RuleFilter) RuleList {
{"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},
{"G117", "Potential exposure of secrets via JSON marshaling", NewSecretSerialization},
// injection
{"G201", "SQL query construction using format string", NewSQLStrFormat},

View File

@@ -111,6 +111,10 @@ var _ = Describe("gosec rules", func() {
runner("G116", testutils.SampleCodeG116)
})
It("should detect exported struct fields that may contain secrets and are JSON serializable", func() {
runner("G117", testutils.SampleCodeG117)
})
It("should detect sql injection via format strings", func() {
runner("G201", testutils.SampleCodeG201)
})

View File

@@ -0,0 +1,123 @@
package rules
import (
"fmt"
"go/ast"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/securego/gosec/v2"
"github.com/securego/gosec/v2/issue"
)
type secretSerialization struct {
issue.MetaData
pattern *regexp.Regexp
}
func (r *secretSerialization) ID() string {
return r.MetaData.ID
}
func (r *secretSerialization) Match(n ast.Node, ctx *gosec.Context) (*issue.Issue, error) {
field, ok := n.(*ast.Field)
if !ok || len(field.Names) == 0 {
return nil, nil // skip embedded (anonymous) fields
}
// Parse the JSON tag to determine behavior
omitted := false
jsonKey := ""
if field.Tag != nil {
if tagVal, err := strconv.Unquote(field.Tag.Value); err == nil && tagVal != "" {
st := reflect.StructTag(tagVal)
if tag := st.Get("json"); tag != "" {
if tag == "-" {
omitted = true
} else {
// "name,omitempty" -> "name"
// "-," -> "-" (A field literally named "-")
parts := strings.SplitN(tag, ",", 2)
jsonKey = parts[0]
}
}
}
}
if omitted {
return nil, nil
}
// Iterate over all names in this field definition
// e.g., type T struct { Pass, Salt string }
isSensitiveType := false
switch t := field.Type.(type) {
case *ast.Ident:
if t.Name == "string" {
isSensitiveType = true
}
case *ast.StarExpr:
if ident, ok := t.X.(*ast.Ident); ok && ident.Name == "string" {
isSensitiveType = true
}
case *ast.ArrayType:
if star, ok := t.Elt.(*ast.StarExpr); ok {
if ident, ok := star.X.(*ast.Ident); ok && ident.Name == "string" {
isSensitiveType = true // []*string
}
} else if ident, ok := t.Elt.(*ast.Ident); ok {
if ident.Name == "string" || ident.Name == "byte" {
isSensitiveType = true // []string or []byte
}
}
}
if !isSensitiveType {
return nil, nil
}
// Check each named exported field
for _, nameIdent := range field.Names {
fieldName := nameIdent.Name
if fieldName == "_" || !ast.IsExported(fieldName) {
continue
}
effectiveKey := jsonKey
if effectiveKey == "" {
effectiveKey = fieldName
}
if r.pattern.MatchString(fieldName) || r.pattern.MatchString(effectiveKey) {
msg := fmt.Sprintf("Exported struct field %q (JSON key %q) matches secret pattern", fieldName, effectiveKey)
return ctx.NewIssue(field, r.ID(), msg, r.Severity, r.Confidence), nil
}
}
return nil, nil
}
func NewSecretSerialization(id string, conf gosec.Config) (gosec.Rule, []ast.Node) {
patternStr := `(?i)\b((?:api|access|auth|bearer|client|oauth|private|refresh|session|jwt)[_-]?(?:key|secret|token)s?|password|passwd|pwd|pass|secret|cred|jwt)\b`
if val, ok := conf[id]; ok {
if m, ok := val.(map[string]interface{}); ok {
if p, ok := m["pattern"].(string); ok && p != "" {
patternStr = p
}
}
}
return &secretSerialization{
pattern: regexp.MustCompile(patternStr),
MetaData: issue.MetaData{
ID: id,
What: "Exported struct field appears to be a secret and is not ignored by JSON marshaling",
Severity: issue.Medium,
Confidence: issue.Medium,
},
}, []ast.Node{(*ast.Field)(nil)}
}

197
testutils/g117_samples.go Normal file
View File

@@ -0,0 +1,197 @@
// testutils/g117_samples.go
package testutils
import "github.com/securego/gosec/v2"
var SampleCodeG117 = []CodeSample{
// Positive: match on field name (default JSON key)
{[]string{`
package main
type Config struct {
Password string
}
`}, 1, gosec.NewConfig()},
{[]string{`
package main
type Config struct {
APIKey *string ` + "`json:\"api_key\"`" + `
}
`}, 1, gosec.NewConfig()},
{[]string{`
package main
type Config struct {
PrivateKey []byte ` + "`json:\"private_key\"`" + `
}
`}, 1, gosec.NewConfig()},
// Positive: match on field name (explicit non-sensitive JSON key)
{[]string{`
package main
type Config struct {
Password string ` + "`json:\"text_field\"`" + `
}
`}, 1, gosec.NewConfig()},
// Positive: match on JSON key (non-sensitive field name)
{[]string{`
package main
type Config struct {
SafeField string ` + "`json:\"api_key\"`" + `
}
`}, 1, gosec.NewConfig()},
// Positive: match on both
{[]string{`
package main
type Config struct {
Token string ` + "`json:\"auth_token\"`" + `
}
`}, 1, gosec.NewConfig()},
// Positive: snake/hyphen variants in JSON key
{[]string{`
package main
type Config struct {
Key string ` + "`json:\"access-key\"`" + `
}
`}, 1, gosec.NewConfig()},
// Positive: empty json tag part falls back to field name
{[]string{`
package main
type Config struct {
Secret string ` + "`json:\",omitempty\"`" + `
}
`}, 1, gosec.NewConfig()},
// Positive: plural forms
{[]string{`
package main
type Config struct {
ApiTokens []string
}
`}, 1, gosec.NewConfig()},
{[]string{`
package main
type Config struct {
RefreshTokens []string ` + "`json:\"refresh_tokens\"`" + `
}
`}, 1, gosec.NewConfig()},
{[]string{`
package main
type Config struct {
AccessTokens []*string
}
`}, 1, gosec.NewConfig()},
{[]string{`
package main
type Config struct {
CustomSecret string ` + "`json:\"my_custom_secret\"`" + `
}
`}, 1, func() gosec.Config {
cfg := gosec.NewConfig()
cfg.Set("G117", map[string]interface{}{
"pattern": "(?i)custom[_-]?secret",
})
return cfg
}()},
// Negative: json:"-" (omitted)
{[]string{`
package main
type Config struct {
Password string ` + "`json:\"-\"`" + `
}
`}, 0, gosec.NewConfig()},
// Negative: both field name and JSON key non-sensitive
{[]string{`
package main
type Config struct {
UserID string ` + "`json:\"user_id\"`" + `
}
`}, 0, gosec.NewConfig()},
// Negative: unexported field
{[]string{`
package main
type Config struct {
password string
}
`}, 0, gosec.NewConfig()},
// Negative: non-sensitive type (int) even with "token"
{[]string{`
package main
type Config struct {
MaxTokens int
}
`}, 0, gosec.NewConfig()},
// Negative: non-secret plural slice (common FP like redaction placeholders)
{[]string{`
package main
type Config struct {
RedactionTokens []string ` + "`json:\"redactionTokens,omitempty\"`" + `
}
`}, 0, gosec.NewConfig()},
// Negative: grouped fields, only one sensitive (should still flag the sensitive one)
// Note: we expect 1 issue (for the sensitive field)
{[]string{`
package main
type Config struct {
Safe, Password string
}
`}, 1, gosec.NewConfig()},
// Suppression: trailing line comment
{[]string{`
package main
type Config struct {
Password string // #nosec G117
}
`}, 0, gosec.NewConfig()},
// Suppression: line comment above field
{[]string{`
package main
type Config struct {
// #nosec G117 -- false positive
Password string
}
`}, 0, gosec.NewConfig()},
// Suppression: trailing with justification
{[]string{`
package main
type Config struct {
APIKey string ` + "`json:\"api_key\"`" + ` // #nosec G117 -- public key
}
`}, 0, gosec.NewConfig()},
}

View File

@@ -242,4 +242,40 @@ func main() {
log.Printf("Command finished with error: %v", err)
}
`}, 1, gosec.NewConfig()},
{[]string{`
package main
import (
"os/exec"
"runtime"
)
// Safe OS-specific command selection using a hard-coded map and slice operations.
// Closely matches the pattern in https://github.com/securego/gosec/issues/1199.
// The command name and fixed arguments are fully resolved from constant composite literals,
// even though the map key is runtime.GOOS (non-constant in analysis).
func main() {
commands := map[string][]string{
"darwin": {"open"},
"freebsd": {"xdg-open"},
"linux": {"xdg-open"},
"netbsd": {"xdg-open"},
"openbsd": {"xdg-open"},
"windows": {"cmd", "/c", "start"},
}
platform := runtime.GOOS
cmdArgs := commands[platform]
if cmdArgs == nil {
return // unsupported platform
}
exe := cmdArgs[0]
args := cmdArgs[1:]
// No dynamic/tainted input; fixed args passed via ... expansion
_ = exec.Command(exe, args...)
}
`}, 0, gosec.NewConfig()},
}