Skip to content

Commit 2cd1d2f

Browse files
Merge pull request #20392 from joefarebrother/python-qual-file-not-closed
Python: Improve File Not Closed query to reduce false positives and provide clearer alerts
2 parents 8f85964 + ea562de commit 2cd1d2f

File tree

7 files changed

+92
-63
lines changed

7 files changed

+92
-63
lines changed

python/ql/src/Resources/FileNotAlwaysClosed.ql

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515

1616
import python
1717
import FileNotAlwaysClosedQuery
18+
import codeql.util.Option
1819

19-
from FileOpen fo, string msg
20+
from FileOpen fo, string msg, LocatableOption<Location, DataFlow::Node>::Option exec
2021
where
2122
fileNotClosed(fo) and
22-
msg = "File is opened but is not closed."
23+
msg = "File is opened but is not closed." and
24+
exec.isNone()
2325
or
24-
fileMayNotBeClosedOnException(fo, _) and
25-
msg = "File may not be closed if an exception is raised."
26-
select fo, msg
26+
fileMayNotBeClosedOnException(fo, exec.asSome()) and
27+
msg = "File may not be closed if $@ raises an exception."
28+
select fo, msg, exec, "this operation"

python/ql/src/Resources/FileNotAlwaysClosedQuery.qll

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ class FileWrapperCall extends DataFlow::CallCfgNode {
3434
DataFlow::Node wrapped;
3535

3636
FileWrapperCall() {
37+
// Approximation: Treat any passing of a file object to a class constructor as potentially a wrapper
38+
// This could be made more precise by checking that the constructor writes the file to a field.
3739
wrapped = this.getArg(_).getALocalSource() and
3840
this.getFunction() = classTracker(_)
3941
or
@@ -50,22 +52,15 @@ class FileWrapperCall extends DataFlow::CallCfgNode {
5052

5153
/** A node where a file is closed. */
5254
abstract class FileClose extends DataFlow::CfgNode {
53-
/** Holds if this file close will occur if an exception is raised at `raises`. */
54-
predicate guardsExceptions(DataFlow::CfgNode raises) {
55+
/** Holds if this file close will occur if an exception is raised at `fileRaises`. */
56+
predicate guardsExceptions(DataFlow::CfgNode fileRaises) {
5557
// The close call occurs after an exception edge in the cfg (a catch or finally)
56-
bbReachableRefl(raises.asCfgNode().getBasicBlock().getAnExceptionalSuccessor(),
58+
bbReachableRefl(fileRaises.asCfgNode().getBasicBlock().getAnExceptionalSuccessor(),
5759
this.asCfgNode().getBasicBlock())
5860
or
5961
// The exception is after the close call.
60-
// A full cfg reachability check is not in general feasible for performance, so we approximate it with:
61-
// - A basic block reachability check (here) that works if the expression and close call are in different basic blocks
62-
// - A check (in the `WithStatement` override of `guardsExceptions`) for the case where the exception call
63-
// is lexically contained in the body of a `with` statement that closes the file.
64-
// This may cause FPs in a case such as:
65-
// f.close()
66-
// f.write("...")
67-
// We presume this to not be very common.
68-
bbReachableStrict(this.asCfgNode().getBasicBlock(), raises.asCfgNode().getBasicBlock())
62+
// A full cfg reachability check is not feasible for performance, instead we use local dataflow
63+
fileLocalFlow(this, fileRaises)
6964
}
7065
}
7166

@@ -94,11 +89,10 @@ class WithStatement extends FileClose {
9489

9590
WithStatement() { this.asExpr() = w.getContextExpr() }
9691

97-
override predicate guardsExceptions(DataFlow::CfgNode raises) {
98-
super.guardsExceptions(raises)
92+
override predicate guardsExceptions(DataFlow::CfgNode fileRaises) {
93+
super.guardsExceptions(fileRaises)
9994
or
100-
// Check whether the exception is raised in the body of the with statement.
101-
raises.asExpr().getParent*() = w.getBody().getAnItem()
95+
w.getBody().contains(fileRaises.asExpr())
10296
}
10397
}
10498

@@ -131,7 +125,7 @@ private predicate fileLocalFlowHelper1(
131125

132126
/** Holds if data flows from `source` to `sink`, including file wrapper classes. */
133127
pragma[inline]
134-
private predicate fileLocalFlow(FileOpen source, DataFlow::Node sink) {
128+
private predicate fileLocalFlow(DataFlow::Node source, DataFlow::Node sink) {
135129
exists(DataFlow::LocalSourceNode mid | fileLocalFlowHelper1(source, mid) and mid.flowsTo(sink))
136130
}
137131

@@ -171,7 +165,7 @@ predicate fileMayNotBeClosedOnException(FileOpen fo, DataFlow::Node raises) {
171165
fileLocalFlow(fo, fileRaised) and
172166
not exists(FileClose fc |
173167
fileLocalFlow(fo, fc) and
174-
fc.guardsExceptions(raises)
168+
fc.guardsExceptions(fileRaised)
175169
)
176170
)
177171
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
| resources_test.py:4:10:4:25 | ControlFlowNode for open() | File may not be closed if $@ raises an exception. | resources_test.py:5:5:5:33 | ControlFlowNode for Attribute() | this operation |
2+
| resources_test.py:9:10:9:25 | ControlFlowNode for open() | File is opened but is not closed. | file://:0:0:0:0 | (none) | this operation |
3+
| resources_test.py:108:11:108:20 | ControlFlowNode for open() | File is opened but is not closed. | file://:0:0:0:0 | (none) | this operation |
4+
| resources_test.py:112:11:112:28 | ControlFlowNode for opener_func2() | File may not be closed if $@ raises an exception. | resources_test.py:113:5:113:22 | ControlFlowNode for Attribute() | this operation |
5+
| resources_test.py:123:11:123:24 | ControlFlowNode for opener_func2() | File is opened but is not closed. | file://:0:0:0:0 | (none) | this operation |
6+
| resources_test.py:129:15:129:24 | ControlFlowNode for open() | File may not be closed if $@ raises an exception. | resources_test.py:130:9:130:26 | ControlFlowNode for Attribute() | this operation |
7+
| resources_test.py:248:11:248:25 | ControlFlowNode for open() | File is opened but is not closed. | file://:0:0:0:0 | (none) | this operation |
8+
| resources_test.py:269:10:269:27 | ControlFlowNode for Attribute() | File may not be closed if $@ raises an exception. | resources_test.py:271:5:271:19 | ControlFlowNode for Attribute() | this operation |
9+
| resources_test.py:285:11:285:20 | ControlFlowNode for open() | File may not be closed if $@ raises an exception. | resources_test.py:287:5:287:31 | ControlFlowNode for Attribute() | this operation |
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
query: Resources/FileNotAlwaysClosed.ql
2+
postprocess: utils/test/InlineExpectationsTestQuery.ql

python/ql/test/query-tests/Resources/FileNotAlwaysClosed/resources_test.py

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
#File not always closed
22

33
def not_close1():
4-
f1 = open("filename") # $ notClosedOnException
4+
f1 = open("filename") # $ Alert # not closed on exception
55
f1.write("Error could occur")
66
f1.close()
77

88
def not_close2():
9-
f2 = open("filename") # $ notClosed
9+
f2 = open("filename") # $ Alert
1010

1111
def closed3():
1212
f3 = open("filename")
@@ -46,7 +46,7 @@ def closed7():
4646
def not_closed8():
4747
f8 = None
4848
try:
49-
f8 = open("filename") # $ MISSING:notClosedOnException
49+
f8 = open("filename") # $ MISSING:Alert # not closed on exception
5050
f8.write("Error could occur")
5151
finally:
5252
if f8 is None: # We don't precisely consider this condition, so this result is MISSING. However, this seems uncommon.
@@ -105,11 +105,11 @@ def opener_func2(name):
105105
return t1
106106

107107
def not_closed13(name):
108-
f13 = open(name) # $ notClosed
108+
f13 = open(name) # $ Alert
109109
f13.write("Hello")
110110

111111
def may_not_be_closed14(name):
112-
f14 = opener_func2(name) # $ notClosedOnException
112+
f14 = opener_func2(name) # $ Alert # not closed on exception
113113
f14.write("Hello")
114114
f14.close()
115115

@@ -120,13 +120,13 @@ def closer2(t3):
120120
closer1(t3)
121121

122122
def closed15():
123-
f15 = opener_func2() # $ SPURIOUS:notClosed
123+
f15 = opener_func2() # $ SPURIOUS:Alert
124124
closer2(f15) # We don't detect that this call closes the file, so this result is SPURIOUS.
125125

126126

127127
def may_not_be_closed16(name):
128128
try:
129-
f16 = open(name) # $ notClosedOnException
129+
f16 = open(name) # $ Alert # not closed on exception
130130
f16.write("Hello")
131131
f16.close()
132132
except IOError:
@@ -138,7 +138,7 @@ def may_raise():
138138

139139
#Not handling all exceptions, but we'll tolerate the false negative
140140
def not_closed17():
141-
f17 = open("filename") # $ MISSING:notClosedOnException
141+
f17 = open("filename") # $ MISSING:Alert # not closed on exception
142142
try:
143143
f17.write("IOError could occur")
144144
f17.write("IOError could occur")
@@ -151,11 +151,11 @@ def not_closed17():
151151
#With statement will close the fp
152152
def closed18(path):
153153
try:
154-
f18 = open(path)
154+
f18 = open(path)
155155
except IOError as ex:
156156
print(ex)
157157
raise ex
158-
with f18:
158+
with f18:
159159
f18.read()
160160

161161
class Closed19(object):
@@ -234,7 +234,7 @@ def closed21(path):
234234

235235

236236
def not_closed22(path):
237-
f22 = open(path, "wb") # $ MISSING:notClosedOnException
237+
f22 = open(path, "wb") # $ MISSING:Alert # not closed on exception
238238
try:
239239
f22.write(b"foo")
240240
may_raise()
@@ -245,7 +245,7 @@ def not_closed22(path):
245245
f22.close()
246246

247247
def not_closed23(path):
248-
f23 = open(path, "w") # $ notClosed
248+
f23 = open(path, "w") # $ Alert
249249
wr = FileWrapper(f23)
250250

251251
def closed24(path):
@@ -266,7 +266,7 @@ def closed26(path):
266266
os.close(fd)
267267

268268
def not_closed27(path):
269-
fd = os.open(path, "w") # $notClosedOnException
269+
fd = os.open(path, "w") # $Alert # not closed on exception
270270
f27 = os.fdopen(fd, "w")
271271
f27.write("hi")
272272
f27.close()
@@ -282,6 +282,53 @@ def closed28(path):
282282
def closed29(path):
283283
# Due to an approximation in CFG reachability for performance, it is not detected that the `write` call that may raise occurs after the file has already been closed.
284284
# We presume this case to be uncommon.
285-
f28 = open(path) # $SPURIOUS:notClosedOnException
285+
f28 = open(path) # $SPURIOUS:Alert # not closed on exception
286286
f28.close()
287-
f28.write("already closed")
287+
f28.write("already closed")
288+
289+
# False positive in a previous implementation:
290+
291+
class NotWrapper:
292+
def __init__(self, fp):
293+
self.data = fp.read()
294+
fp.close()
295+
296+
def do_something(self):
297+
pass
298+
299+
def closed30(path):
300+
# Combination of approximations resulted in this FP:
301+
# - NotWrapper is treated as a wrapper class as a file handle is passed to it
302+
# - thing.do_something() is treated as a call that can raise an exception while a file is open
303+
# - this call is treated as occurring after the open but not as being guarded by the with statement, as it is in the same basic block
304+
# - - this behavior has been changed fixing the FP
305+
306+
with open(path) as fp: # No longer spurious alert here.
307+
thing = NotWrapper(fp)
308+
309+
thing.do_something()
310+
311+
312+
def closed31(path):
313+
with open(path) as fp:
314+
data = fp.readline()
315+
data2 = fp.readline()
316+
317+
318+
class Wrapper():
319+
def __init__(self, f):
320+
self.f = f
321+
def read(self):
322+
return self.f.read()
323+
def __enter__(self):
324+
pass
325+
def __exit__(self,exc_type, exc_value,traceback):
326+
self.f.close()
327+
328+
def closed32(path):
329+
with open(path, "rb") as f: # No longer spurious alert here.
330+
wrap = Wrapper(f)
331+
# This resulted in an FP in a previous implementation,
332+
# due to a check that an operation is lexically contained within a `with` block (with `expr.getParent*()`)
333+
# not detecting this case.
334+
return list(wrap.read())

python/ql/test/query-tests/Resources/FileNotAlwaysClosed/test.expected

Whitespace-only changes.

python/ql/test/query-tests/Resources/FileNotAlwaysClosed/test.ql

Lines changed: 0 additions & 25 deletions
This file was deleted.

0 commit comments

Comments
 (0)