Skip to content

Commit 424fc4c

Browse files
kfessccojocar
andauthored
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>
1 parent aa2e2fb commit 424fc4c

File tree

5 files changed

+185
-0
lines changed

5 files changed

+185
-0
lines changed

issue/issue.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ var ruleToCWE = map[string]string{
6767
"G112": "400",
6868
"G114": "676",
6969
"G115": "190",
70+
"G116": "838",
7071
"G201": "89",
7172
"G202": "89",
7273
"G203": "79",

rules/rulelist.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func Generate(trackSuppressions bool, filters ...RuleFilter) RuleList {
7676
{"G111", "Detect http.Dir('/') as a potential risk", NewDirectoryTraversal},
7777
{"G112", "Detect ReadHeaderTimeout not configured as a potential risk", NewSlowloris},
7878
{"G114", "Use of net/http serve function that has no support for setting timeouts", NewHTTPServeWithoutTimeouts},
79+
{"G116", "Detect Trojan Source attacks using bidirectional Unicode characters", NewTrojanSource},
7980

8081
// injection
8182
{"G201", "SQL query construction using format string", NewSQLStrFormat},

rules/rules_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ var _ = Describe("gosec rules", func() {
107107
runner("G114", testutils.SampleCodeG114)
108108
})
109109

110+
It("should detect Trojan Source attacks using bidirectional Unicode characters", func() {
111+
runner("G116", testutils.SampleCodeG116)
112+
})
113+
110114
It("should detect sql injection via format strings", func() {
111115
runner("G201", testutils.SampleCodeG201)
112116
})

rules/trojansource.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package rules
2+
3+
import (
4+
"go/ast"
5+
"os"
6+
7+
"github.com/securego/gosec/v2"
8+
"github.com/securego/gosec/v2/issue"
9+
)
10+
11+
type trojanSource struct {
12+
issue.MetaData
13+
bidiChars map[rune]struct{}
14+
}
15+
16+
func (r *trojanSource) ID() string {
17+
return r.MetaData.ID
18+
}
19+
20+
func (r *trojanSource) Match(node ast.Node, c *gosec.Context) (*issue.Issue, error) {
21+
if file, ok := node.(*ast.File); ok {
22+
fobj := c.FileSet.File(file.Pos())
23+
if fobj == nil {
24+
return nil, nil
25+
}
26+
27+
content, err := os.ReadFile(fobj.Name())
28+
if err != nil {
29+
return nil, nil
30+
}
31+
32+
for _, ch := range string(content) {
33+
if _, exists := r.bidiChars[ch]; exists {
34+
return c.NewIssue(node, r.ID(), r.What, r.Severity, r.Confidence), nil
35+
}
36+
}
37+
}
38+
39+
return nil, nil
40+
}
41+
42+
// func (r *trojanSource) Match(node ast.Node, c *gosec.Context) (*issue.Issue, error) {
43+
// if file, ok := node.(*ast.File); ok {
44+
// fobj := c.FileSet.File(file.Pos())
45+
// if fobj == nil {
46+
// return nil, nil
47+
// }
48+
49+
// file, err := os.Open(fobj.Name())
50+
// if err != nil {
51+
// log.Fatal(err)
52+
// }
53+
54+
// defer file.Close()
55+
56+
// scanner := bufio.NewScanner(file)
57+
// for scanner.Scan() {
58+
// line := scanner.Text()
59+
// for _, ch := range line {
60+
// if _, exists := r.bidiChars[ch]; exists {
61+
// return c.NewIssue(node, r.ID(), r.What, r.Severity, r.Confidence), nil
62+
// }
63+
// }
64+
// }
65+
66+
// if err := scanner.Err(); err != nil {
67+
// log.Fatal(err)
68+
// }
69+
// }
70+
71+
// return nil, nil
72+
// }
73+
74+
func NewTrojanSource(id string, _ gosec.Config) (gosec.Rule, []ast.Node) {
75+
return &trojanSource{
76+
MetaData: issue.MetaData{
77+
ID: id,
78+
Severity: issue.High,
79+
Confidence: issue.Medium,
80+
What: "Potential Trojan Source vulnerability via use of bidirectional text control characters",
81+
},
82+
bidiChars: map[rune]struct{}{
83+
'\u202a': {},
84+
'\u202b': {},
85+
'\u202c': {},
86+
'\u202d': {},
87+
'\u202e': {},
88+
'\u2066': {},
89+
'\u2067': {},
90+
'\u2068': {},
91+
'\u2069': {},
92+
'\u200e': {},
93+
'\u200f': {},
94+
},
95+
}, []ast.Node{(*ast.File)(nil)}
96+
}

testutils/g116_samples.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package testutils
2+
3+
import "github.com/securego/gosec/v2"
4+
5+
// #nosec - This file intentionally contains bidirectional Unicode characters
6+
// for testing trojan source detection. The G116 rule scans the entire file content (not just AST nodes)
7+
// because trojan source attacks work by manipulating visual representation of code through bidirectional
8+
// text control characters, which can appear in comments, strings or anywhere in the source file.
9+
// Without this #nosec exclusion, gosec would detect these test samples as actual vulnerabilities.
10+
var (
11+
// SampleCodeG116 - TrojanSource code snippets
12+
SampleCodeG116 = []CodeSample{
13+
{[]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()},
14+
{[]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()},
15+
{[]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()},
16+
{[]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()},
17+
{[]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()},
18+
{[]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()},
19+
{[]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()},
20+
{[]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()},
21+
{[]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()},
22+
{[]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()},
23+
{[]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()},
24+
{[]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()},
25+
{[]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()},
26+
{[]string{`
27+
package main
28+
29+
import "fmt"
30+
31+
// Safe code without bidirectional characters
32+
func main() {
33+
username := "admin"
34+
password := "secret"
35+
fmt.Println("Username:", username)
36+
fmt.Println("Password:", password)
37+
}
38+
`}, 0, gosec.NewConfig()},
39+
{[]string{`
40+
package main
41+
42+
import "fmt"
43+
44+
// Normal comment with regular text
45+
func main() {
46+
// This is a safe comment
47+
isAdmin := true
48+
if isAdmin {
49+
fmt.Println("Access granted")
50+
}
51+
}
52+
`}, 0, gosec.NewConfig()},
53+
{[]string{`
54+
package main
55+
56+
import "fmt"
57+
58+
func main() {
59+
// Regular ASCII characters only
60+
message := "Hello, World!"
61+
fmt.Println(message)
62+
}
63+
`}, 0, gosec.NewConfig()},
64+
{[]string{`
65+
package main
66+
67+
import "fmt"
68+
69+
func authenticateUser(username, password string) bool {
70+
// Normal authentication logic
71+
if username == "admin" && password == "secret" {
72+
return true
73+
}
74+
return false
75+
}
76+
77+
func main() {
78+
result := authenticateUser("user", "pass")
79+
fmt.Println("Authenticated:", result)
80+
}
81+
`}, 0, gosec.NewConfig()},
82+
}
83+
)

0 commit comments

Comments
 (0)