@@ -27,7 +27,10 @@ type readfile struct {
2727 gosec.CallList
2828 pathJoin gosec.CallList
2929 clean gosec.CallList
30+ // cleanedVar maps the declaration node of an identifier to the Clean() call node
3031 cleanedVar map [any ]ast.Node
32+ // joinedVar maps the declaration node of an identifier to the Join() call node
33+ joinedVar map [any ]ast.Node
3134}
3235
3336// ID returns the identifier for this rule
@@ -61,6 +64,7 @@ func (r *readfile) isJoinFunc(n ast.Node, c *gosec.Context) bool {
6164
6265// isFilepathClean checks if there is a filepath.Clean for given variable
6366func (r * readfile ) isFilepathClean (n * ast.Ident , c * gosec.Context ) bool {
67+ // quick lookup: was this var's declaration recorded as a Clean() call?
6468 if _ , ok := r .cleanedVar [n .Obj .Decl ]; ok {
6569 return true
6670 }
@@ -90,54 +94,177 @@ func (r *readfile) trackFilepathClean(n ast.Node) {
9094 }
9195}
9296
93- // Match inspects AST nodes to determine if the match the methods `os.Open` or `ioutil.ReadFile`
97+ // trackJoin records when a Join() call result is assigned to an identifier
98+ // example: fullPath := filepath.Join(baseDir, cleanPath)
99+ func (r * readfile ) trackJoin (n ast.Node ) {
100+ // n is expected to be a *ast.CallExpr (Join(...)) but the assignment is a different node.
101+ // We will look for an enclosing AssignStmt if provided (the caller passes n which is the call)
102+ if joinCall , ok := n .(* ast.CallExpr ); ok && len (joinCall .Args ) > 0 {
103+ // We don't have direct access to the enclosing assignment here (since Match receives call exprs),
104+ // so the practical approach is: when Match sees a Join call used in an assignment, it should call this helper
105+ // with the assignment node. For simplicity, we will expect the caller to pass an AssignStmt.
106+ _ = joinCall // caller should call trackJoin on the AssignStmt (see Match where we call it)
107+ }
108+ }
109+
110+ // trackJoinAssignStmt tracks assignments where RHS is a Join(...) call and LHS is an identifier
111+ func (r * readfile ) trackJoinAssignStmt (node * ast.AssignStmt , c * gosec.Context ) {
112+ if len (node .Rhs ) == 0 {
113+ return
114+ }
115+ if call , ok := node .Rhs [0 ].(* ast.CallExpr ); ok {
116+ if r .pathJoin .ContainsPkgCallExpr (call , c , false ) != nil {
117+ // LHS must be an identifier (simple case)
118+ if len (node .Lhs ) > 0 {
119+ if ident , ok := node .Lhs [0 ].(* ast.Ident ); ok && ident .Obj != nil {
120+ r .joinedVar [ident .Obj .Decl ] = call
121+ }
122+ }
123+ }
124+ }
125+ }
126+
127+ // isSafeJoin checks if path is baseDir + filepath.Clean(fn) joined.
128+ // improvements over earlier naive version:
129+ // - allow baseDir as a BasicLit or as an identifier that resolves to a string constant
130+ // - accept Clean(...) being either a CallExpr or an identifier previously recorded as Clean result
131+ func (r * readfile ) isSafeJoin (call * ast.CallExpr , c * gosec.Context ) bool {
132+ join := r .pathJoin .ContainsPkgCallExpr (call , c , false )
133+ if join == nil {
134+ return false
135+ }
136+
137+ // We expect join.Args to include a baseDir-like arg and a cleaned path arg.
138+ var foundBaseDir bool
139+ var foundCleanArg bool
140+
141+ for _ , arg := range join .Args {
142+ switch a := arg .(type ) {
143+ case * ast.BasicLit :
144+ // literal string or similar — treat as possible baseDir
145+ foundBaseDir = true
146+ case * ast.Ident :
147+ // If ident is resolvable to a constant string (TryResolve true), treat as baseDir.
148+ // Or if ident refers to a variable that was itself assigned from a constant BasicLit,
149+ // it's considered safe as baseDir.
150+ if gosec .TryResolve (a , c ) {
151+ foundBaseDir = true
152+ } else {
153+ // It might be a cleaned variable: e.g. cleanPath := filepath.Clean(fn)
154+ if r .isFilepathClean (a , c ) {
155+ foundCleanArg = true
156+ }
157+ }
158+ case * ast.CallExpr :
159+ // If an argument is a Clean() call directly, mark clean arg found.
160+ if r .clean .ContainsPkgCallExpr (a , c , false ) != nil {
161+ foundCleanArg = true
162+ }
163+ default :
164+ // ignore other types
165+ }
166+ }
167+
168+ return foundBaseDir && foundCleanArg
169+ }
170+
94171func (r * readfile ) Match (n ast.Node , c * gosec.Context ) (* issue.Issue , error ) {
172+ // Track filepath.Clean usages so identifiers assigned from Clean() are known.
95173 if node := r .clean .ContainsPkgCallExpr (n , c , false ); node != nil {
96174 r .trackFilepathClean (n )
97175 return nil , nil
98- } else if node := r .ContainsPkgCallExpr (n , c , false ); node != nil {
99- for _ , arg := range node .Args {
100- // handles path joining functions in Arg
101- // eg. os.Open(filepath.Join("/tmp/", file))
102- if callExpr , ok := arg .(* ast.CallExpr ); ok {
103- if r .isJoinFunc (callExpr , c ) {
104- return c .NewIssue (n , r .ID (), r .What , r .Severity , r .Confidence ), nil
176+ }
177+
178+ // Track Join assignments if we see an AssignStmt whose RHS is a Join call.
179+ if assign , ok := n .(* ast.AssignStmt ); ok {
180+ // track join result assigned to a variable, e.g., fullPath := filepath.Join(baseDir, cleanPath)
181+ r .trackJoinAssignStmt (assign , c )
182+ // also track Clean assignment if present on RHS
183+ if len (assign .Rhs ) > 0 {
184+ if call , ok := assign .Rhs [0 ].(* ast.CallExpr ); ok {
185+ if r .clean .ContainsPkgCallExpr (call , c , false ) != nil {
186+ r .trackFilepathClean (call )
105187 }
106188 }
107- // handles binary string concatenation eg. ioutil.Readfile("/tmp/" + file + "/blob")
108- if binExp , ok := arg .(* ast.BinaryExpr ); ok {
109- // resolve all found identities from the BinaryExpr
110- if _ , ok := gosec .FindVarIdentities (binExp , c ); ok {
111- return c .NewIssue (n , r .ID (), r .What , r .Severity , r .Confidence ), nil
112- }
189+ }
190+ // continue, don't return here — other checks may apply
191+ }
192+
193+ // Now check for file-reading calls (os.Open, os.OpenFile, ioutil.ReadFile etc.)
194+ if node := r .ContainsPkgCallExpr (n , c , false ); node != nil {
195+ if len (node .Args ) == 0 {
196+ return nil , nil
197+ }
198+ arg := node .Args [0 ]
199+
200+ // If argument is a call expression, check for Join/Clean patterns.
201+ if callExpr , ok := arg .(* ast.CallExpr ); ok {
202+ // If this call matches a safe Join(baseDir, Clean(...)) pattern, treat as safe.
203+ if r .isSafeJoin (callExpr , c ) {
204+ // safe pattern detected; do not raise an issue
205+ return nil , nil
206+ }
207+ // If the argument is a Join call but not safe per above, flag it (as before)
208+ if r .isJoinFunc (callExpr , c ) {
209+ return c .NewIssue (n , r .ID (), r .What , r .Severity , r .Confidence ), nil
113210 }
211+ }
114212
115- if ident , ok := arg .(* ast.Ident ); ok {
116- obj := c .Info .ObjectOf (ident )
117- if _ , ok := obj .(* types.Var ); ok &&
118- ! gosec .TryResolve (ident , c ) &&
119- ! r .isFilepathClean (ident , c ) {
120- return c .NewIssue (n , r .ID (), r .What , r .Severity , r .Confidence ), nil
213+ // If arg is an identifier that was assigned from a Join(...) call, check that recorded Join call.
214+ if ident , ok := arg .(* ast.Ident ); ok {
215+ if ident .Obj != nil {
216+ if joinCall , ok := r .joinedVar [ident .Obj .Decl ]; ok {
217+ // If the identifier itself was later cleaned, treat as safe regardless of original Join args
218+ if r .isFilepathClean (ident , c ) {
219+ return nil , nil
220+ }
221+ // joinCall is a *ast.CallExpr; check if that join is a safe join
222+ if jc , ok := joinCall .(* ast.CallExpr ); ok {
223+ if r .isSafeJoin (jc , c ) {
224+ return nil , nil
225+ }
226+ // join exists but is not safe: flag it
227+ return c .NewIssue (n , r .ID (), r .What , r .Severity , r .Confidence ), nil
228+ }
121229 }
122230 }
123231 }
232+
233+ // handles binary string concatenation eg. ioutil.Readfile("/tmp/" + file + "/blob")
234+ if binExp , ok := arg .(* ast.BinaryExpr ); ok {
235+ // resolve all found identities from the BinaryExpr
236+ if _ , ok := gosec .FindVarIdentities (binExp , c ); ok {
237+ return c .NewIssue (n , r .ID (), r .What , r .Severity , r .Confidence ), nil
238+ }
239+ }
240+
241+ // if it's a plain identifier, and not resolved and not cleaned, flag it
242+ if ident , ok := arg .(* ast.Ident ); ok {
243+ obj := c .Info .ObjectOf (ident )
244+ if _ , ok := obj .(* types.Var ); ok &&
245+ ! gosec .TryResolve (ident , c ) &&
246+ ! r .isFilepathClean (ident , c ) {
247+ return c .NewIssue (n , r .ID (), r .What , r .Severity , r .Confidence ), nil
248+ }
249+ }
124250 }
125251 return nil , nil
126252}
127253
128254// NewReadFile detects cases where we read files
129255func NewReadFile (id string , _ gosec.Config ) (gosec.Rule , []ast.Node ) {
130256 rule := & readfile {
131- pathJoin : gosec .NewCallList (),
132- clean : gosec .NewCallList (),
133- CallList : gosec .NewCallList (),
257+ pathJoin : gosec .NewCallList (),
258+ clean : gosec .NewCallList (),
259+ CallList : gosec .NewCallList (),
134260 MetaData : issue.MetaData {
135261 ID : id ,
136262 What : "Potential file inclusion via variable" ,
137263 Severity : issue .Medium ,
138264 Confidence : issue .High ,
139265 },
140266 cleanedVar : map [any ]ast.Node {},
267+ joinedVar : map [any ]ast.Node {},
141268 }
142269 rule .pathJoin .Add ("path/filepath" , "Join" )
143270 rule .pathJoin .Add ("path" , "Join" )
@@ -149,5 +276,5 @@ func NewReadFile(id string, _ gosec.Config) (gosec.Rule, []ast.Node) {
149276 rule .Add ("os" , "Open" )
150277 rule .Add ("os" , "OpenFile" )
151278 rule .Add ("os" , "Create" )
152- return rule , []ast.Node {(* ast .CallExpr )(nil )}
279+ return rule , []ast.Node {(* ast .CallExpr )(nil ), ( * ast . AssignStmt )( nil ) }
153280}
0 commit comments