diff --git a/python/ql/lib/change-notes/2025-08-11-jump-step-global-nested.md b/python/ql/lib/change-notes/2025-08-11-jump-step-global-nested.md new file mode 100644 index 000000000000..4109bb788259 --- /dev/null +++ b/python/ql/lib/change-notes/2025-08-11-jump-step-global-nested.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* Data flow tracking through global variables now supports nested field access patterns such as `global_var.obj.field`. This improves the precision of taint tracking analysis when data flows through complex global variable structures. \ No newline at end of file diff --git a/python/ql/lib/semmle/python/dataflow/new/internal/Attributes.qll b/python/ql/lib/semmle/python/dataflow/new/internal/Attributes.qll index 51dccc29312c..42c0c862e89b 100644 --- a/python/ql/lib/semmle/python/dataflow/new/internal/Attributes.qll +++ b/python/ql/lib/semmle/python/dataflow/new/internal/Attributes.qll @@ -64,6 +64,15 @@ abstract class AttrRef extends Node { abstract class AttrWrite extends AttrRef { /** Gets the data flow node corresponding to the value that is written to the attribute. */ abstract Node getValue(); + + /** + * Holds if this attribute write writes the attribute named `attrName` on object `object` with + * value `value`. + */ + predicate writes(Node object, string attrName, Node value) { + this.accesses(object, attrName) and + this.getValue() = value + } } /** @@ -225,7 +234,10 @@ private class ClassDefinitionAsAttrWrite extends AttrWrite, CfgNode { * - Dynamic attribute reads using `getattr`: `getattr(object, attr)` * - Qualified imports: `from module import attr as name` */ -abstract class AttrRead extends AttrRef, Node, LocalSourceNode { } +abstract class AttrRead extends AttrRef, Node, LocalSourceNode { + /** Holds if this attribute read reads the attribute named `attrName` on the object `object`. */ + predicate reads(Node object, string attrName) { this.accesses(object, attrName) } +} /** A simple attribute read, e.g. `object.attr` */ private class AttributeReadAsAttrRead extends AttrRead, CfgNode { diff --git a/python/ql/lib/semmle/python/dataflow/new/internal/DataFlowPrivate.qll b/python/ql/lib/semmle/python/dataflow/new/internal/DataFlowPrivate.qll index b29be706c4fc..96e51cb31063 100644 --- a/python/ql/lib/semmle/python/dataflow/new/internal/DataFlowPrivate.qll +++ b/python/ql/lib/semmle/python/dataflow/new/internal/DataFlowPrivate.qll @@ -556,6 +556,57 @@ predicate runtimeJumpStep(Node nodeFrom, Node nodeTo) { nodeFrom.asCfgNode() = param.getDefault() and nodeTo.asCfgNode() = param.getDefiningNode() ) + or + // Enhanced global variable field access tracking + globalVariableNestedFieldJumpStep(nodeFrom, nodeTo) +} + +/** + * Holds if there is a jump step from `nodeFrom` to `nodeTo` through global variable field access. + * This supports tracking nested object field access through global variables like `app.obj.foo`. + */ +private predicate globalVariableNestedFieldJumpStep(Node nodeFrom, Node nodeTo) { + exists(ModuleVariableNode globalVar, AttrWrite write, AttrRead read | + // Match writes and reads on the same global variable attribute path + exists(string accessPath | + globalVariableAttrPathAtDepth(globalVar, accessPath, write.getObject(), _) and + globalVariableAttrPathAtDepth(globalVar, accessPath, read.getObject(), _) + ) and + write.getAttributeName() = read.getAttributeName() and + nodeFrom = write.getValue() and + nodeTo = read + ) +} + +/** + * Maximum depth for global variable nested attribute access. + * Depth 1 = globalVar.foo, depth 2 = globalVar.foo.bar, depth 3 = globalVar.foo.bar.baz, etc. + */ +private int getMaxGlobalVariableDepth() { result = 2 } + +/** + * Holds if `node` is an attribute access path starting from global variable `globalVar` at specific `depth`. + */ +private predicate globalVariableAttrPathAtDepth( + ModuleVariableNode globalVar, string accessPath, Node node, int depth +) { + // Base case: Direct global variable access (depth 0) + depth = 0 and + // We use `globalVar` instead of `globalVar.getAWrite()` due to some weirdness with how + // attribute writes are handled in the global scope (see `GlobalAttributeAssignmentAsAttrWrite`). + node in [globalVar.getARead(), globalVar] and + accessPath = "" + or + exists(Node obj, string attrName, string parentAccessPath, int parentDepth | + node.(AttrRead).reads(obj, attrName) + or + any(AttrWrite aw).writes(obj, attrName, node) + | + globalVariableAttrPathAtDepth(globalVar, parentAccessPath, obj, parentDepth) and + accessPath = parentAccessPath + "." + attrName and + depth = parentDepth + 1 and + depth <= getMaxGlobalVariableDepth() + ) } //-------- diff --git a/python/ql/test/library-tests/dataflow/fieldflow/test.py b/python/ql/test/library-tests/dataflow/fieldflow/test.py index 1ce049917f01..f0152ad38a84 100644 --- a/python/ql/test/library-tests/dataflow/fieldflow/test.py +++ b/python/ql/test/library-tests/dataflow/fieldflow/test.py @@ -301,12 +301,12 @@ def set_to_source(): @expects(4) # $ unresolved_call=expects(..) unresolved_call=expects(..)(..) def test_global_flow_to_class_attribute(): inst = WithTuple2() - SINK_F(WithTuple2.my_tuple[0]) + SINK_F(WithTuple2.my_tuple[0]) # $ SPURIOUS: flow="SOURCE, l:-5 -> WithTuple2.my_tuple[0]" SINK_F(inst.my_tuple[0]) set_to_source() - SINK(WithTuple2.my_tuple[0]) # $ MISSING: flow="SOURCE, l:-10 -> WithTuple2.my_tuple[0]" + SINK(WithTuple2.my_tuple[0]) # $ flow="SOURCE, l:-10 -> WithTuple2.my_tuple[0]" SINK(inst.my_tuple[0]) # $ MISSING: flow="SOURCE, l:-11 -> inst.my_tuple[0]" diff --git a/python/ql/test/library-tests/dataflow/fieldflow/test_global.py b/python/ql/test/library-tests/dataflow/fieldflow/test_global.py index 0e96b37dc33c..9970c47c962f 100644 --- a/python/ql/test/library-tests/dataflow/fieldflow/test_global.py +++ b/python/ql/test/library-tests/dataflow/fieldflow/test_global.py @@ -146,6 +146,7 @@ def fields_with_local_flow(x): class NestedObj(object): def __init__(self): self.obj = MyObj("OK") + self.obj.foo = "default" def getObj(self): return self.obj @@ -163,17 +164,31 @@ def getObj(self): a2 = NestedObj() a2.getObj().foo = x2 SINK(a2.obj.foo) # $ flow="SOURCE, l:-3 -> a2.obj.foo" + +# Global variable +app = NestedObj() + +def init_global(): + app.obj.foo = SOURCE + +def read_global(): + return app.obj.foo + +def test_global_nested_attributes(): + init_global() + result = read_global() + SINK(result) # $ flow="SOURCE, l:-8 -> result" # ------------------------------------------------------------------------------ # Global scope interaction # ------------------------------------------------------------------------------ def func_defined_before(): - SINK(global_obj.foo) # $ MISSING: flow="SOURCE, l:+3 -> global_obj.foo" + SINK(global_obj.foo) # $ flow="SOURCE, l:+3 -> global_obj.foo" global_obj = MyObj(NONSOURCE) global_obj.foo = SOURCE SINK(global_obj.foo) # $ flow="SOURCE, l:-1 -> global_obj.foo" def func_defined_after(): - SINK(global_obj.foo) # $ MISSING: flow="SOURCE, l:-4 -> global_obj.foo" + SINK(global_obj.foo) # $ flow="SOURCE, l:-4 -> global_obj.foo" diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/DataflowQueryTest.expected b/python/ql/test/query-tests/Security/CWE-022-PathInjection/DataflowQueryTest.expected deleted file mode 100644 index 2fad7bb9a843..000000000000 --- a/python/ql/test/query-tests/Security/CWE-022-PathInjection/DataflowQueryTest.expected +++ /dev/null @@ -1,2 +0,0 @@ -missingAnnotationOnSink -testFailures diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/DataflowQueryTest.ql b/python/ql/test/query-tests/Security/CWE-022-PathInjection/DataflowQueryTest.ql deleted file mode 100644 index e4720596a37c..000000000000 --- a/python/ql/test/query-tests/Security/CWE-022-PathInjection/DataflowQueryTest.ql +++ /dev/null @@ -1,4 +0,0 @@ -import python -import utils.test.dataflow.DataflowQueryTest -import semmle.python.security.dataflow.PathInjectionQuery -import FromTaintTrackingStateConfig diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.expected b/python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.expected index 37e4dd927d89..79b36070c017 100644 --- a/python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.expected +++ b/python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.expected @@ -1,4 +1,38 @@ +#select +| fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | fastapi_path_injection.py:17:21:17:24 | ControlFlowNode for path | fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | This path depends on a $@. | fastapi_path_injection.py:17:21:17:24 | ControlFlowNode for path | user-provided value | +| fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | fastapi_path_injection.py:26:21:26:24 | ControlFlowNode for path | fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | This path depends on a $@. | fastapi_path_injection.py:26:21:26:24 | ControlFlowNode for path | user-provided value | +| fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | fastapi_path_injection.py:31:21:31:24 | ControlFlowNode for path | fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | This path depends on a $@. | fastapi_path_injection.py:31:21:31:24 | ControlFlowNode for path | user-provided value | +| fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | fastapi_path_injection.py:48:21:48:24 | ControlFlowNode for path | fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | This path depends on a $@. | fastapi_path_injection.py:48:21:48:24 | ControlFlowNode for path | user-provided value | +| flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname | flask_path_injection.py:1:26:1:32 | ControlFlowNode for ImportMember | flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname | This path depends on a $@. | flask_path_injection.py:1:26:1:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:13:14:13:47 | ControlFlowNode for Attribute() | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:13:14:13:47 | ControlFlowNode for Attribute() | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:21:14:21:18 | ControlFlowNode for npath | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:21:14:21:18 | ControlFlowNode for npath | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:31:14:31:18 | ControlFlowNode for npath | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:31:14:31:18 | ControlFlowNode for npath | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:48:14:48:18 | ControlFlowNode for npath | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:48:14:48:18 | ControlFlowNode for npath | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:65:14:65:18 | ControlFlowNode for npath | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:65:14:65:18 | ControlFlowNode for npath | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:87:18:87:37 | ControlFlowNode for possibly_unsafe_path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:87:18:87:37 | ControlFlowNode for possibly_unsafe_path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:94:14:94:17 | ControlFlowNode for path | path_injection.py:91:20:91:25 | ControlFlowNode for foo_id | path_injection.py:94:14:94:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:91:20:91:25 | ControlFlowNode for foo_id | user-provided value | +| path_injection.py:102:14:102:17 | ControlFlowNode for path | path_injection.py:98:20:98:22 | ControlFlowNode for foo | path_injection.py:102:14:102:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:98:20:98:22 | ControlFlowNode for foo | user-provided value | +| path_injection.py:113:14:113:17 | ControlFlowNode for path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:113:14:113:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:124:14:124:17 | ControlFlowNode for path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:124:14:124:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:132:14:132:22 | ControlFlowNode for sanitized | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:132:14:132:22 | ControlFlowNode for sanitized | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:142:14:142:17 | ControlFlowNode for path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:142:14:142:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| path_injection.py:152:18:152:21 | ControlFlowNode for path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:152:18:152:21 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| pathlib_use.py:14:5:14:5 | ControlFlowNode for p | pathlib_use.py:3:26:3:32 | ControlFlowNode for ImportMember | pathlib_use.py:14:5:14:5 | ControlFlowNode for p | This path depends on a $@. | pathlib_use.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| pathlib_use.py:17:5:17:6 | ControlFlowNode for p2 | pathlib_use.py:3:26:3:32 | ControlFlowNode for ImportMember | pathlib_use.py:17:5:17:6 | ControlFlowNode for p2 | This path depends on a $@. | pathlib_use.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| test.py:19:10:19:10 | ControlFlowNode for x | test.py:3:26:3:32 | ControlFlowNode for ImportMember | test.py:19:10:19:10 | ControlFlowNode for x | This path depends on a $@. | test.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| test.py:26:10:26:10 | ControlFlowNode for y | test.py:3:26:3:32 | ControlFlowNode for ImportMember | test.py:26:10:26:10 | ControlFlowNode for y | This path depends on a $@. | test.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| test.py:33:14:33:14 | ControlFlowNode for x | test.py:3:26:3:32 | ControlFlowNode for ImportMember | test.py:33:14:33:14 | ControlFlowNode for x | This path depends on a $@. | test.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | +| test.py:49:14:49:14 | ControlFlowNode for y | test.py:3:26:3:32 | ControlFlowNode for ImportMember | test.py:49:14:49:14 | ControlFlowNode for y | This path depends on a $@. | test.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | edges +| fastapi_path_injection.py:6:24:6:31 | ControlFlowNode for filepath | fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | provenance | | +| fastapi_path_injection.py:17:21:17:24 | ControlFlowNode for path | fastapi_path_injection.py:20:34:20:37 | ControlFlowNode for path | provenance | | +| fastapi_path_injection.py:20:34:20:37 | ControlFlowNode for path | fastapi_path_injection.py:6:24:6:31 | ControlFlowNode for filepath | provenance | | +| fastapi_path_injection.py:26:21:26:24 | ControlFlowNode for path | fastapi_path_injection.py:27:34:27:37 | ControlFlowNode for path | provenance | | +| fastapi_path_injection.py:27:34:27:37 | ControlFlowNode for path | fastapi_path_injection.py:6:24:6:31 | ControlFlowNode for filepath | provenance | | +| fastapi_path_injection.py:31:21:31:24 | ControlFlowNode for path | fastapi_path_injection.py:32:34:32:37 | ControlFlowNode for path | provenance | | +| fastapi_path_injection.py:32:34:32:37 | ControlFlowNode for path | fastapi_path_injection.py:6:24:6:31 | ControlFlowNode for filepath | provenance | | +| fastapi_path_injection.py:48:21:48:24 | ControlFlowNode for path | fastapi_path_injection.py:49:45:49:48 | ControlFlowNode for path | provenance | | +| fastapi_path_injection.py:49:45:49:48 | ControlFlowNode for path | fastapi_path_injection.py:6:24:6:31 | ControlFlowNode for filepath | provenance | | | flask_path_injection.py:1:26:1:32 | ControlFlowNode for ImportMember | flask_path_injection.py:1:26:1:32 | ControlFlowNode for request | provenance | | | flask_path_injection.py:1:26:1:32 | ControlFlowNode for request | flask_path_injection.py:19:15:19:21 | ControlFlowNode for request | provenance | | | flask_path_injection.py:19:5:19:11 | ControlFlowNode for dirname | flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname | provenance | | @@ -122,6 +156,16 @@ edges | test.py:48:23:48:23 | ControlFlowNode for x | test.py:12:15:12:15 | ControlFlowNode for x | provenance | | | test.py:48:23:48:23 | ControlFlowNode for x | test.py:48:13:48:24 | ControlFlowNode for normalize() | provenance | Config | nodes +| fastapi_path_injection.py:6:24:6:31 | ControlFlowNode for filepath | semmle.label | ControlFlowNode for filepath | +| fastapi_path_injection.py:7:19:7:26 | ControlFlowNode for filepath | semmle.label | ControlFlowNode for filepath | +| fastapi_path_injection.py:17:21:17:24 | ControlFlowNode for path | semmle.label | ControlFlowNode for path | +| fastapi_path_injection.py:20:34:20:37 | ControlFlowNode for path | semmle.label | ControlFlowNode for path | +| fastapi_path_injection.py:26:21:26:24 | ControlFlowNode for path | semmle.label | ControlFlowNode for path | +| fastapi_path_injection.py:27:34:27:37 | ControlFlowNode for path | semmle.label | ControlFlowNode for path | +| fastapi_path_injection.py:31:21:31:24 | ControlFlowNode for path | semmle.label | ControlFlowNode for path | +| fastapi_path_injection.py:32:34:32:37 | ControlFlowNode for path | semmle.label | ControlFlowNode for path | +| fastapi_path_injection.py:48:21:48:24 | ControlFlowNode for path | semmle.label | ControlFlowNode for path | +| fastapi_path_injection.py:49:45:49:48 | ControlFlowNode for path | semmle.label | ControlFlowNode for path | | flask_path_injection.py:1:26:1:32 | ControlFlowNode for ImportMember | semmle.label | ControlFlowNode for ImportMember | | flask_path_injection.py:1:26:1:32 | ControlFlowNode for request | semmle.label | ControlFlowNode for request | | flask_path_injection.py:19:5:19:11 | ControlFlowNode for dirname | semmle.label | ControlFlowNode for dirname | @@ -252,24 +296,3 @@ nodes subpaths | test.py:25:19:25:19 | ControlFlowNode for x | test.py:12:15:12:15 | ControlFlowNode for x | test.py:13:12:13:30 | ControlFlowNode for Attribute() | test.py:25:9:25:20 | ControlFlowNode for normalize() | | test.py:48:23:48:23 | ControlFlowNode for x | test.py:12:15:12:15 | ControlFlowNode for x | test.py:13:12:13:30 | ControlFlowNode for Attribute() | test.py:48:13:48:24 | ControlFlowNode for normalize() | -#select -| flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname | flask_path_injection.py:1:26:1:32 | ControlFlowNode for ImportMember | flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname | This path depends on a $@. | flask_path_injection.py:1:26:1:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:13:14:13:47 | ControlFlowNode for Attribute() | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:13:14:13:47 | ControlFlowNode for Attribute() | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:21:14:21:18 | ControlFlowNode for npath | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:21:14:21:18 | ControlFlowNode for npath | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:31:14:31:18 | ControlFlowNode for npath | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:31:14:31:18 | ControlFlowNode for npath | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:48:14:48:18 | ControlFlowNode for npath | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:48:14:48:18 | ControlFlowNode for npath | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:65:14:65:18 | ControlFlowNode for npath | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:65:14:65:18 | ControlFlowNode for npath | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:87:18:87:37 | ControlFlowNode for possibly_unsafe_path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:87:18:87:37 | ControlFlowNode for possibly_unsafe_path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:94:14:94:17 | ControlFlowNode for path | path_injection.py:91:20:91:25 | ControlFlowNode for foo_id | path_injection.py:94:14:94:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:91:20:91:25 | ControlFlowNode for foo_id | user-provided value | -| path_injection.py:102:14:102:17 | ControlFlowNode for path | path_injection.py:98:20:98:22 | ControlFlowNode for foo | path_injection.py:102:14:102:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:98:20:98:22 | ControlFlowNode for foo | user-provided value | -| path_injection.py:113:14:113:17 | ControlFlowNode for path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:113:14:113:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:124:14:124:17 | ControlFlowNode for path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:124:14:124:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:132:14:132:22 | ControlFlowNode for sanitized | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:132:14:132:22 | ControlFlowNode for sanitized | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:142:14:142:17 | ControlFlowNode for path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:142:14:142:17 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| path_injection.py:152:18:152:21 | ControlFlowNode for path | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | path_injection.py:152:18:152:21 | ControlFlowNode for path | This path depends on a $@. | path_injection.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| pathlib_use.py:14:5:14:5 | ControlFlowNode for p | pathlib_use.py:3:26:3:32 | ControlFlowNode for ImportMember | pathlib_use.py:14:5:14:5 | ControlFlowNode for p | This path depends on a $@. | pathlib_use.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| pathlib_use.py:17:5:17:6 | ControlFlowNode for p2 | pathlib_use.py:3:26:3:32 | ControlFlowNode for ImportMember | pathlib_use.py:17:5:17:6 | ControlFlowNode for p2 | This path depends on a $@. | pathlib_use.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| test.py:19:10:19:10 | ControlFlowNode for x | test.py:3:26:3:32 | ControlFlowNode for ImportMember | test.py:19:10:19:10 | ControlFlowNode for x | This path depends on a $@. | test.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| test.py:26:10:26:10 | ControlFlowNode for y | test.py:3:26:3:32 | ControlFlowNode for ImportMember | test.py:26:10:26:10 | ControlFlowNode for y | This path depends on a $@. | test.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| test.py:33:14:33:14 | ControlFlowNode for x | test.py:3:26:3:32 | ControlFlowNode for ImportMember | test.py:33:14:33:14 | ControlFlowNode for x | This path depends on a $@. | test.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | -| test.py:49:14:49:14 | ControlFlowNode for y | test.py:3:26:3:32 | ControlFlowNode for ImportMember | test.py:49:14:49:14 | ControlFlowNode for y | This path depends on a $@. | test.py:3:26:3:32 | ControlFlowNode for ImportMember | user-provided value | diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.qlref b/python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.qlref index d43482cc509e..6a680f6d5ff5 100644 --- a/python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.qlref +++ b/python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.qlref @@ -1 +1,2 @@ -Security/CWE-022/PathInjection.ql +query: Security/CWE-022/PathInjection.ql +postprocess: utils/test/InlineExpectationsTestQuery.ql diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/fastapi_path_injection.py b/python/ql/test/query-tests/Security/CWE-022-PathInjection/fastapi_path_injection.py new file mode 100644 index 000000000000..e5d7cae762cb --- /dev/null +++ b/python/ql/test/query-tests/Security/CWE-022-PathInjection/fastapi_path_injection.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI, Depends + +app = FastAPI() + +class FileHandler: + def get_data(self, filepath: str): + with open(filepath, "r") as f: # $ Alert + return f.readline() + +file_handler = None + +def init_file_handler(): + global file_handler + file_handler = FileHandler() + +@app.get("/file/") +async def read_item(path: str): # $ Source + if file_handler is None: + init_file_handler() + return file_handler.get_data(path) + +def init_file_handler(): + return FileHandler() + +@app.get("/file2/", dependencies=[Depends(init_file_handler)]) +async def read_item(path: str, file_handler: FileHandler = Depends()): # $ Source + return file_handler.get_data(path) + + +@app.get("/file3/", dependencies=[Depends(init_file_handler)]) +async def read_item(path: str): # $ Source + return file_handler.get_data(path) + + +@app.on_event("startup") +def init_file_handler(): + app.state.file_handler1 = FileHandler() + app.state.file_handler2 = FileHandler() + +def get_data_source(): + return app.state.file_handler1 + +@app.get("/file4/") +async def read_item(path: str, data_source=Depends(get_data_source)): # $ MISSING: Source + return data_source.get_data(path) + +@app.get("/file5/", dependencies=[Depends(init_file_handler)]) +async def read_item(path: str): # $ Source + return app.state.file_handler2.get_data(path) diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/flask_path_injection.py b/python/ql/test/query-tests/Security/CWE-022-PathInjection/flask_path_injection.py index f64d76a3b4da..f289bcc94ec9 100644 --- a/python/ql/test/query-tests/Security/CWE-022-PathInjection/flask_path_injection.py +++ b/python/ql/test/query-tests/Security/CWE-022-PathInjection/flask_path_injection.py @@ -1,4 +1,4 @@ -from flask import Flask, request, send_from_directory +from flask import Flask, request, send_from_directory # $Source app = Flask(__name__) @@ -18,4 +18,4 @@ def download_file(): def download_file(): dirname = request.args.get('dirname', '') filename = request.args.get('filename', '') - return send_from_directory(dirname, filename) # $result=BAD result=OK(filename) + return send_from_directory(dirname, filename) # $ Alert diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/path_injection.py b/python/ql/test/query-tests/Security/CWE-022-PathInjection/path_injection.py index 39c170374802..37ff20b7de13 100644 --- a/python/ql/test/query-tests/Security/CWE-022-PathInjection/path_injection.py +++ b/python/ql/test/query-tests/Security/CWE-022-PathInjection/path_injection.py @@ -1,6 +1,6 @@ import os -from flask import Flask, request +from flask import Flask, request # $ Source app = Flask(__name__) @@ -10,7 +10,7 @@ @app.route("/path1") def path_injection(): filename = request.args.get('filename', '') - f = open(os.path.join(STATIC_DIR, filename)) # $result=BAD + f = open(os.path.join(STATIC_DIR, filename)) # $ Alert @app.route("/path2") @@ -18,7 +18,7 @@ def path_injection(): # Normalized, but not checked filename = request.args.get('filename', '') npath = os.path.normpath(os.path.join(STATIC_DIR, filename)) - f = open(npath) # $result=BAD + f = open(npath) # $ Alert @app.route("/path3") @@ -28,7 +28,7 @@ def unsafe_path_normpath(): npath = os.path.normpath(os.path.join(STATIC_DIR, filename)) if npath.startswith(STATIC_DIR): pass - f = open(npath) # $result=BAD + f = open(npath) # $ Alert @app.route("/path4") @@ -45,7 +45,7 @@ def unsafe_path_realpath(): # Normalized (by `realpath` that also follows symlinks), but not checked properly filename = request.args.get('filename', '') npath = os.path.realpath(os.path.join(STATIC_DIR, filename)) - f = open(npath) # $result=BAD + f = open(npath) # $ Alert @app.route("/path6") @@ -62,7 +62,7 @@ def unsafe_path_abspath(): # Normalized (by `abspath`), but not checked properly filename = request.args.get('filename', '') npath = os.path.abspath(os.path.join(STATIC_DIR, filename)) - f = open(npath) # $result=BAD + f = open(npath) # $ Alert @app.route("/path7") @@ -84,22 +84,22 @@ def safe_path_abspath_tricky(): filename = request.args.get('filename', '') possibly_unsafe_path = os.path.join(STATIC_DIR, filename) if os.path.abspath(possibly_unsafe_path).startswith(STATIC_DIR): - f = open(possibly_unsafe_path) # $SPURIOUS: result=BAD + f = open(possibly_unsafe_path) # $ SPURIOUS: Alert @app.route("/int-only/") -def flask_int_only(foo_id): +def flask_int_only(foo_id): # $ SPURIOUS: Source # This is OK, since the flask routing ensures that `foo_id` MUST be an integer. path = os.path.join(STATIC_DIR, foo_id) - f = open(path) # $spurious: result=BAD + f = open(path) # $ SPURIOUS: Alert @app.route("/not-path/") -def flask_not_path(foo): +def flask_not_path(foo): # $ Source # On UNIX systems, this is OK, since without being marked as ``, flask # routing ensures that `foo` cannot contain forward slashes (not by using %2F either). path = os.path.join(STATIC_DIR, foo) - f = open(path) # $result=BAD // OK if only running on UNIX systems, NOT OK if could be running on windows + f = open(path) # $ Alert // OK if only running on UNIX systems, NOT OK if could be running on windows @app.route("/no-dot-dot") @@ -110,7 +110,7 @@ def no_dot_dot(): # handle if `filename` is an absolute path if '../' in path: return "not this time" - f = open(path) # $result=BAD + f = open(path) # $ Alert @app.route("/no-dot-dot-with-prefix") @@ -121,7 +121,7 @@ def no_dot_dot_with_prefix(): # Therefore, for UNIX-only programs, the `../` check is enough to stop path injections. if '../' in path: return "not this time" - f = open(path) # $result=BAD // OK if only running on UNIX systems, NOT OK if could be running on windows + f = open(path) # $ Alert // OK if only running on UNIX systems, NOT OK if could be running on windows @app.route("/replace-slash") @@ -129,7 +129,7 @@ def replace_slash(): filename = request.args.get('filename', '') path = os.path.join(STATIC_DIR, filename) sanitized = path.replace("/", "_") - f = open(sanitized) # $result=BAD // OK if only running on UNIX systems, NOT OK if could be running on windows + f = open(sanitized) # $ Alert // OK if only running on UNIX systems, NOT OK if could be running on windows @app.route("/stackoverflow-solution") @@ -139,7 +139,7 @@ def stackoverflow_solution(): path = os.path.join(STATIC_DIR, filename) if os.path.commonprefix((os.path.realpath(path), STATIC_DIR)) != STATIC_DIR: return "not this time" - f = open(path) # $SPURIOUS: result=BAD + f = open(path) # $ SPURIOUS: Alert SAFE_FILES = ['foo', 'bar', 'baz'] @@ -149,4 +149,4 @@ def safe_set_of_files(): filename = request.args.get('filename', '') if filename in SAFE_FILES: path = os.path.join(STATIC_DIR, filename) - f = open(path) # $SPURIOUS: result=BAD + f = open(path) # $ SPURIOUS: Alert diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/pathlib_use.py b/python/ql/test/query-tests/Security/CWE-022-PathInjection/pathlib_use.py index 6f703f903dc5..76ce1d1cc99d 100644 --- a/python/ql/test/query-tests/Security/CWE-022-PathInjection/pathlib_use.py +++ b/python/ql/test/query-tests/Security/CWE-022-PathInjection/pathlib_use.py @@ -1,6 +1,6 @@ import pathlib -from flask import Flask, request +from flask import Flask, request # $ Source app = Flask(__name__) @@ -11,7 +11,7 @@ def path_injection(): filename = request.args.get('filename', '') p = STATIC_DIR / filename - p.open() # $ result=BAD + p.open() # $ Alert p2 = pathlib.Path(STATIC_DIR, filename) - p2.open() # $ result=BAD + p2.open() # $ Alert diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/test.py b/python/ql/test/query-tests/Security/CWE-022-PathInjection/test.py index 7200cd78f454..c10c257dae53 100644 --- a/python/ql/test/query-tests/Security/CWE-022-PathInjection/test.py +++ b/python/ql/test/query-tests/Security/CWE-022-PathInjection/test.py @@ -1,6 +1,6 @@ import os.path -from flask import Flask, request +from flask import Flask, request # $ Source app = Flask(__name__) @@ -16,21 +16,21 @@ def normalize(x): @app.route("/path") def simple(): x = source() - open(x) # $result=BAD + open(x) # $ Alert @app.route("/path") def normalization(): x = source() y = normalize(x) - open(y) # $result=BAD + open(y) # $ Alert @app.route("/path") def check(): x = source() if x.startswith("subfolder/"): - open(x) # $result=BAD + open(x) # $ Alert @app.route("/path") @@ -46,4 +46,4 @@ def check_then_normalize(): x = source() if x.startswith("subfolder/"): y = normalize(x) - open(y) # $result=BAD + open(y) # $ Alert