Skip to content

Commit 9bc2c1d

Browse files
yoffCopilot
andcommitted
Python: preserve configuration key precision
The sensitive-name heuristic marks calls such as _get_config_value_from_secret_backend as secret sources. Interprocedural summaries then route those sources through every layered configuration getter result without retaining the guard correlation for each concrete section/key pair. This reproduced Airflow clear-text logging false positives for core.EXECUTOR and core.DAGS_FOLDER. Recognize fully resolved configuration lookups whose only value selectors are unique literal section and key arguments, and expose them as sanitizers to the clear-text logging, clear-text storage, and weak sensitive-data hashing analyses. The implementation checks every resolved target and preserves sensitive callee names and configuration-specific secret indicators. This belongs in SensitiveDataSources rather than core summary routing: the summarized path is topologically valid, while the missing fact is semantic precision for a name-based sensitive-source heuristic. Core dataflow therefore remains conservative for all other analyses. Dynamic or ambiguous selectors, sensitive concrete names, direct secret-named getters, and calls with additional value arguments remain flowing. Tests cover PASSWORD, FERNET_KEY, PASSWORD_FILE, dynamic keys, a sensitive fallback, and the Airflow-shaped safe routes. The refinement intentionally relies on conventional section/key parameter names and does not attempt arbitrary application registry reasoning. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1684780 commit 9bc2c1d

7 files changed

Lines changed: 146 additions & 3 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* The `py/clear-text-logging-sensitive-data`, `py/clear-text-storage-sensitive-data`, and `py/weak-sensitive-data-hashing` queries no longer propagate sensitive data through resolved configuration lookups whose only value selectors are concrete, non-sensitive `section` and `key` arguments. Calls with sensitive or dynamic names, or with additional value arguments, remain unchanged.

python/ql/lib/semmle/python/dataflow/new/SensitiveDataSources.qll

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
private import python
77
private import semmle.python.controlflow.internal.Cfg as Cfg
88
private import semmle.python.dataflow.new.DataFlow
9+
private import semmle.python.dataflow.new.internal.DataFlowDispatch as DataFlowDispatch
910
// Need to import `semmle.python.Frameworks` since frameworks can extend `SensitiveDataSource::Range`
1011
private import semmle.python.Frameworks
1112
private import codeql.concepts.internal.SensitiveDataHeuristics as SensitiveDataHeuristics
@@ -84,6 +85,87 @@ private module SensitiveDataModeling {
8485
sensitiveFunction(DataFlow::TypeTracker::end(), classification).flowsTo(result)
8586
}
8687

88+
/** Gets the unique string literal passed to `parameterName`, if all local sources agree. */
89+
private string constantArgument(DataFlowDispatch::NormalCall call, string parameterName) {
90+
exists(
91+
DataFlowDispatch::ArgumentPosition argumentPosition,
92+
DataFlowDispatch::ParameterPosition parameterPosition, DataFlow::ArgumentNode argument,
93+
DataFlow::LocalSourceNode representative
94+
|
95+
DataFlowDispatch::parameterMatch(parameterPosition, argumentPosition) and
96+
call.getCallable().getParameter(parameterPosition).getParameter().getName() = parameterName and
97+
argument = call.getArgument(argumentPosition) and
98+
representative = argument.getALocalSource() and
99+
result = representative.asExpr().(StringLiteral).getText() and
100+
forall(DataFlow::LocalSourceNode source | source = argument.getALocalSource() |
101+
source.asExpr().(StringLiteral).getText() = result
102+
)
103+
)
104+
}
105+
106+
private predicate resolvedConfigurationCall(
107+
DataFlowDispatch::NormalCall call, string section, string key
108+
) {
109+
section = constantArgument(call, "section") and
110+
key = constantArgument(call, "key") and
111+
not callNameIndicatesSensitiveData(call) and
112+
onlyConfigurationSelectorArguments(call)
113+
}
114+
115+
private predicate onlyConfigurationSelectorArguments(DataFlowDispatch::NormalCall call) {
116+
forall(DataFlowDispatch::ArgumentPosition argumentPosition |
117+
exists(call.getArgument(argumentPosition))
118+
|
119+
exists(DataFlowDispatch::ParameterPosition parameterPosition |
120+
DataFlowDispatch::parameterMatch(parameterPosition, argumentPosition) and
121+
(
122+
parameterPosition.isSelf()
123+
or
124+
call.getCallable().getParameter(parameterPosition).getParameter().getName() in [
125+
"section", "key"
126+
]
127+
)
128+
)
129+
)
130+
}
131+
132+
private predicate callNameIndicatesSensitiveData(DataFlowDispatch::NormalCall call) {
133+
nameIndicatesSensitiveData(call.getCallable().getScope().(Function).getName())
134+
or
135+
nameIndicatesSensitiveData(call.getNode().(Cfg::CallNode).getFunction().(Cfg::NameNode).getId())
136+
or
137+
nameIndicatesSensitiveData(call.getNode()
138+
.(Cfg::CallNode)
139+
.getFunction()
140+
.(Cfg::AttrNode)
141+
.getName())
142+
}
143+
144+
bindingset[name]
145+
private predicate configurationSelectorIndicatesSensitiveData(string name) {
146+
// File, path, and URL suffixes do not make configuration selectors safe: they
147+
// commonly identify an indirect secret or a value that embeds credentials.
148+
name.regexpMatch(maybeSensitiveRegexp(_))
149+
or
150+
name.regexpMatch("(?is).*(^|[_-])(secret|auth|salt|bearer|key(tab)?|token|cred(ential)?|conn(ect(ion)?)?|"
151+
+ "backend|dsn|url|uri|args|kwargs)([_-]|$).*")
152+
}
153+
154+
/**
155+
* Holds if every resolved target for `node` has concrete `section` and `key`
156+
* arguments whose names do not indicate sensitive data.
157+
*/
158+
predicate knownNonSensitiveConfigurationValue(DataFlow::Node node) {
159+
exists(DataFlowDispatch::NormalCall call | call.getNode() = node.asCfgNode()) and
160+
forall(DataFlowDispatch::NormalCall call | call.getNode() = node.asCfgNode() |
161+
exists(string section, string key |
162+
resolvedConfigurationCall(call, section, key) and
163+
not configurationSelectorIndicatesSensitiveData(section) and
164+
not configurationSelectorIndicatesSensitiveData(key)
165+
)
166+
)
167+
}
168+
87169
/**
88170
* Gets a reference (in local scope) to a string constant that, if used as the key in
89171
* a lookup, indicates the presence of sensitive data with `classification`.
@@ -336,4 +418,16 @@ private module SensitiveDataModeling {
336418

337419
predicate sensitiveDataExtraStepForCalls = SensitiveDataModeling::extraStepForCalls/2;
338420

421+
/**
422+
* Holds if `node` is a resolved configuration lookup whose only inputs are its
423+
* receiver and concrete, non-sensitive section and key names.
424+
*
425+
* This predicate is intended for use as a sensitive-data barrier. It blocks all
426+
* flow through the lookup result, so calls with additional value arguments are
427+
* deliberately excluded.
428+
*/
429+
predicate isKnownNonSensitiveConfigurationLookup(DataFlow::Node node) {
430+
SensitiveDataModeling::knownNonSensitiveConfigurationValue(node)
431+
}
432+
339433
predicate sensitiveLookupStringConst = SensitiveDataModeling::sensitiveLookupStringConst/1;

python/ql/lib/semmle/python/security/dataflow/CleartextLoggingCustomizations.qll

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ module CleartextLogging {
3636
*/
3737
abstract class Sanitizer extends DataFlow::Node { }
3838

39+
private class KnownNonSensitiveConfigurationLookupSanitizer extends Sanitizer {
40+
KnownNonSensitiveConfigurationLookupSanitizer() { isKnownNonSensitiveConfigurationLookup(this) }
41+
}
42+
3943
/**
4044
* A source of sensitive data, considered as a flow source.
4145
*/

python/ql/lib/semmle/python/security/dataflow/CleartextStorageCustomizations.qll

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ module CleartextStorage {
3535
*/
3636
abstract class Sanitizer extends DataFlow::Node { }
3737

38+
private class KnownNonSensitiveConfigurationLookupSanitizer extends Sanitizer {
39+
KnownNonSensitiveConfigurationLookupSanitizer() { isKnownNonSensitiveConfigurationLookup(this) }
40+
}
41+
3842
/**
3943
* A source of sensitive data, considered as a flow source.
4044
*/

python/ql/lib/semmle/python/security/dataflow/WeakSensitiveDataHashingCustomizations.qll

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ module NormalHashFunction {
4848
*/
4949
abstract class Sanitizer extends DataFlow::Node { }
5050

51+
private class KnownNonSensitiveConfigurationLookupSanitizer extends Sanitizer {
52+
KnownNonSensitiveConfigurationLookupSanitizer() { isKnownNonSensitiveConfigurationLookup(this) }
53+
}
54+
5155
/**
5256
* A source of sensitive data, considered as a flow source.
5357
*/
@@ -117,6 +121,10 @@ module ComputationallyExpensiveHashFunction {
117121
*/
118122
abstract class Sanitizer extends DataFlow::Node { }
119123

124+
private class KnownNonSensitiveConfigurationLookupSanitizer extends Sanitizer {
125+
KnownNonSensitiveConfigurationLookupSanitizer() { isKnownNonSensitiveConfigurationLookup(this) }
126+
}
127+
120128
/**
121129
* A source of passwords, considered as a flow source.
122130
*/

python/ql/test/library-tests/dataflow/sensitive-data/TestSensitiveDataSources.ql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ module SensitiveUseConfig implements DataFlow::ConfigSig {
4040
predicate isAdditionalFlowStep(DataFlow::Node node1, DataFlow::Node node2) {
4141
sensitiveDataExtraStepForCalls(node1, node2)
4242
}
43+
44+
predicate isBarrier(DataFlow::Node node) { isKnownNonSensitiveConfigurationLookup(node) }
4345
}
4446

4547
module SensitiveUseFlow = TaintTracking::Global<SensitiveUseConfig>;

python/ql/test/library-tests/dataflow/sensitive-data/test.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,11 @@ def _get_secret_option(self, section, key):
145145
return load_secret_value() # $ SensitiveDataSource=secret
146146
return None
147147

148-
def get(self, section, key):
148+
def get(self, section, key, fallback="default"):
149149
value = self._get_secret_option(section, key) # $ SensitiveDataSource=secret
150150
if value is not None:
151151
return value
152-
return "default"
152+
return fallback
153153

154154
def getlist(self, section, key):
155155
return self.get(section, key).split(",")
@@ -161,11 +161,38 @@ def get_mandatory_list_value(self, section, key):
161161
configuration = Configuration()
162162

163163
executor_name = configuration.get_mandatory_list_value("core", "EXECUTOR")[0]
164-
print(executor_name) # $ SPURIOUS: SensitiveUse=secret
164+
print(executor_name)
165165

166+
# A second Airflow-shaped path carries a non-sensitive directory through a helper.
167+
dags_folder = configuration.get_mandatory_list_value("core", "DAGS_FOLDER")[0]
168+
169+
170+
def find_path_from_directory(base_dir_path):
171+
print(base_dir_path)
172+
173+
174+
find_path_from_directory(dags_folder)
175+
176+
# Concrete sensitive keys remain conservative.
166177
value2 = configuration.get_mandatory_list_value("database", "PASSWORD")[0]
167178
print(value2) # $ SensitiveUse=secret
168179

180+
# Configuration key names that can hold or locate secrets remain conservative.
181+
value_with_key_suffix = configuration.get_mandatory_list_value("crypto", "FERNET_KEY")[0]
182+
print(value_with_key_suffix) # $ SensitiveUse=secret
183+
184+
value_with_path_suffix = configuration.get_mandatory_list_value("smtp", "PASSWORD_FILE")[0]
185+
print(value_with_path_suffix) # $ SensitiveUse=secret
186+
187+
# Dynamic keys remain conservative.
169188
dynamic_key = get_key()
170189
dynamic_value = configuration.get_mandatory_list_value("core", dynamic_key)[0]
171190
print(dynamic_value) # $ SensitiveUse=secret
191+
192+
# A sensitive callee name remains a source even with non-sensitive selector names.
193+
value3 = configuration._get_secret_option("core", "EXECUTOR") # $ SensitiveDataSource=secret
194+
print(value3) # $ SensitiveUse=secret
195+
196+
# Additional value arguments prevent the call result from becoming a barrier.
197+
value4 = configuration.get("core", "EXECUTOR", get_password()) # $ SensitiveDataSource=password
198+
print(value4) # $ SensitiveUse=password SensitiveUse=secret

0 commit comments

Comments
 (0)