Skip to content

Python: Remove imprecise container steps #2 - #21888

Merged
owen-mc merged 26 commits into
github:mainfrom
owen-mc:py/remove-imprecise-container-steps
Jun 4, 2026
Merged

Python: Remove imprecise container steps #2#21888
owen-mc merged 26 commits into
github:mainfrom
owen-mc:py/remove-imprecise-container-steps

Conversation

@owen-mc

Copy link
Copy Markdown
Contributor

Supersedes #17493.

We used to have taint steps from any element of a collection to the entire collection (see here).
These are very imprecise, leading to false positives (e.g. seen #17008 (comment) and #16976).
They are also at odds with how other languages treat collections, see https://github.com/github/codeql-python-team/issues/728?reload=1 about this.

We wish to keep the semantics, that if a collection is tainted, then all elements are considered tainted. Therefor we now try to not taint collections, if we have precise information about which elements are tainted.
For a list, if an element is tainted, we do not know which one, so any read is potentially reading tainted information.
There is not much difference between the list having content and the list being tainted.
But for a dictionary, if an entry is tainted and we know which one, only reads of the appropriate key is reading tainted information. All other reads should ideally be considered safe (they used to not be). If we do not know that other keys are safe, e.g. if the collection came from an untrusted source, we can taint the collection itself, and all reads will be considered dangerous. So for collections with precise content, there is a big difference between having content and the collection being tainted.

Thus we wish to remove these imprecise taint steps for tuples and dictionaries, where we track content precisely (we keep them for lists and sets, where content is imprecise anyway).
Changes

In this PR we do the following:

remove tupleStoreStep and dictStoreStep from containerStep These are imprecise compared to the content being precise.
add implicit reads to recover taint at sinks
add implicit read steps for decoders to supplement the AdditionalTaintStep that now only covers when the full container is tainted.

Status:
Potential confusions:

A comprehension is no longer tainted even if it has tainted elements. See the taint test for Tornado for an example.
Dict.items is no longer tainted for a tainted dict (but Dict.values are). We could choose to change this.

Improvements:

Fixed FP in test_unpacking
Fixed FP in CleartextLogging
Nicer paths in NoSqlInjection test

Closes#17493.

yoff added 9 commits May 21, 2026 16:57
- remove `tupleStoreStep` and `dictStoreStep` from `containerStep`
These are imprecise compared to the content being precise.
- add implicit reads to recover taint at sinks
- add implicit read steps for decoders
to supplement the `AdditionalTaintStep`
that now only covers when the full container is tainted.
We now find an alert on this line as we hope to
It is not an alert for _full_ SSRF, though, since that configuration cannot handle multiple substitutions.
and adjust collection test
@owen-mc
owen-mcforce-pushed the py/remove-imprecise-container-steps branch 2 times, most recently from 1d66c7b to 20fadc8CompareMay 23, 2026 06:06
@owen-mc
owen-mcforce-pushed the py/remove-imprecise-container-steps branch from 20fadc8 to ec13e1bCompareMay 27, 2026 14:30
@owen-mc

Copy link
Copy Markdown
ContributorAuthor

The first DCA run showed major performance problems, as expected from the original PR. I tracked this down to the problem that ContentSet was created to address, but we weren't defining the correct ContentSets to fix the problem. I got copilot to fix that and ran DCA again. The results of this second DCA run were good: all the alert changes stayed the same, but performance improved, to the point where it is pretty much performance neutral on average (between a 4% speedup to a 5% slowdown). There is a small increase in analysis time for some proejcts due to NodeEx.toString, which seems unavoidable.

@owen-mc
owen-mc marked this pull request as ready for review May 27, 2026 20:11
@owen-mc
owen-mc requested a review from a team as a code ownerMay 27, 2026 20:11
CopilotAI review requested due to automatic review settings May 27, 2026 20:11
@owen-mc
owen-mc requested a review from tausbnMay 27, 2026 20:11

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refines Python’s new dataflow/taint tracking for containers by removing imprecise “element taints whole container” steps for tuples and dictionaries (where content is tracked precisely), and compensates by adding implicit-read mechanisms so taint can still be recovered at sinks and through certain conversions/decoders. It updates flow-summary/content-set plumbing to support wildcard content sets for better scalability and adjusts a broad set of query and library tests accordingly.

Changes:

  • Remove imprecise container bubbling for tuple/dict stores and introduce wildcard ContentSets plus implicit taint reads at sinks.
  • Add/adjust conversion-related read steps (e.g., decoders, % formatting, format_map) to preserve intended taint behavior without container-wide tainting.
  • Update query-test .expected baselines and library tests to reflect the new, more precise paths/alerts.
Show a summary per file
FileDescription
python/ql/lib/semmle/python/dataflow/new/internal/TaintTrackingPrivate.qllAdds default implicit read policy (wildcard tuple/dict element reads) and removes tuple/dict store bubbling from containerStep.
python/ql/lib/semmle/python/dataflow/new/internal/DataFlowPublic.qllIntroduces wildcard-capable ContentSet representation (AnyTupleElement/AnyDictionaryElement) and singleton wrapper helper.
python/ql/lib/semmle/python/dataflow/new/internal/DataFlowPrivate.qllRefactors read/store/clear steps to operate via singleton/wildcard ContentSets and adds conversion read steps.
python/ql/lib/semmle/python/dataflow/new/internal/FlowSummaryImpl.qllUpdates summary encoding/summary-components to use singleton ContentSets and adds encoding for wildcard sets.
python/ql/lib/semmle/python/dataflow/new/internal/TypeTrackingImpl.qllWraps tracked content in singleton ContentSets when delegating to summary flow.
python/ql/lib/semmle/python/frameworks/Stdlib.qllAdjusts stdlib summary behavior to taint both list element content and (imprecisely) the list where appropriate.
python/ql/src/Variables/LoopVariableCapture/LoopVariableCaptureQuery.qllUpdates implicit read allowlist logic to use wildcard tuple/dict element checks and store-content inspection.
python/ql/consistency-queries/DataFlowConsistency.qlExcludes new conversion read steps from consistency checks where appropriate.
python/ql/test/library-tests/frameworks/tornado/taint_test.pyUpdates tornado taint expectations around comprehensions/element reads under the new container semantics.
python/ql/test/library-tests/frameworks/stdlib/test_re.pyUpdates re modeling expectations to rely on implicit reads at sinks for Match object content.
python/ql/test/library-tests/dataflow/tainttracking/defaultAdditionalTaintStep/test_unpacking.pyRemoves a now-fixed spurious taint expectation for tuple/list unpacking.
python/ql/test/library-tests/dataflow/tainttracking/defaultAdditionalTaintStep/test_collections.pyAdjusts dict .items() taint expectations to match new dict precision semantics.
python/ql/test/library-tests/dataflow/sensitive-data/test.pyUpdates sensitive-data expectations for non-sensitive dict entries with precise dict content.
python/ql/test/query-tests/Security/CWE-943-NoSqlInjection/NoSqlInjection.expectedUpdates expected NoSQL injection path output to reflect refined dict/tuple content steps.
python/ql/test/query-tests/Security/CWE-918-ServerSideRequestForgery/PartialServerSideRequestForgery.expectedUpdates expected SSRF partial-path edges/nodes under new tuple content behavior.
python/ql/test/query-tests/Security/CWE-312-CleartextLogging/CleartextLogging.expectedUpdates expected cleartext logging results (removes prior spurious dict cross-talk path).
python/ql/test/query-tests/Security/CWE-209-StackTraceExposure/StackTraceExposure.expectedUpdates expected stack trace exposure path with refined dict/str conversion steps.
python/ql/test/query-tests/Security/CVE-2018-1281/BindToAllInterfaces.expectedUpdates expected bind-to-all-interfaces results to reflect tuple element precision.
python/ql/test/experimental/query-tests/Security/CWE-1427-PromptInjection/PromptInjection.expectedUpdates expected prompt-injection results to reflect reduced container-wide tainting.
python/ql/test/experimental/query-tests/Security/CWE-1427-PromptInjection/openai_test.pyAdjusts inline alert annotation expectations to match updated analysis behavior.

Copilot's findings

  • Files reviewed: 20/20 changed files
  • Comments generated: 3

Comment on lines +78 to +79
[(k, v) for (k, v) in request.headers.get_all()], # The comprehension is not tainted, only the elements
list([(k, v) for (k, v) in request.headers.get_all()]), # Here, all the elements of the list are tainted, but the list is not.
# since we have taint-step from store of `password`, we will consider any item in the
# dictionary to be a password :(
print(_config["sleep_timer"]) # $ SPURIOUS: SensitiveUse=password
# since we have precise dictionary content, other items of the config are not tainted
Comment on lines +10 to +11
# returns Match object, which is tested properly below. (note: the match objects contain
# tainted values but are not themselves tainted - this test relies on implicit reads at sinks).
@owen-mc

Copy link
Copy Markdown
ContributorAuthor

I edited the ContentSet commit after reviewing what copilot did more closely, so I've rerun DCA. The results are still good. A few repos have a slight slowdown (up to 5%) which is caused by NodeEx.toString suddenly having to make extra strings with " [Ext]" on the end for all the data flow nodes. This is unavoidable with our current architecture. Still, the overall average performance affect is neutral. The ContentSet commit does not change alerts at all, so @yoff's previous analysis of them still stands.

nodeFrom = decoding.getAnInput() and
nodeTo = decoding.getOutput()
) and
(c.isAnyTupleElement() or c.isAnyDictionaryElement())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could also add another TAnyTupleOrDictionaryElement to TContentSet to represent this union.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would that be better?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It reduces the size of the predicate by 50 %; it may not matter much, but I also see this pattern elsewhere where having a dedicated ContentSet entity could remove code duplication as well.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first sight it seemed weird to have a ContentSet which is the union of two other ones, but I guess there's no reason not to. I've done it in a new commit.

Comment on lines 185 to 187
@@ -176,10 +186,6 @@ predicate containerStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
or
DataFlowPrivate::setStoreStep(nodeFrom, _, nodeTo)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this PR doesn't also fix the issue with lists and sets?

@@ -176,10 +186,6 @@ predicate containerStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
or
DataFlowPrivate::setStoreStep(nodeFrom, _, nodeTo)
or

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect there to be something like

exists(ContentSet cs |
readStep(nodeFrom, cs, nodeTo) |
cs.isAnyTupleElement() or cs.isAnyDictionaryElement()
)

See e.g.

// Read steps give rise to taint steps. This has the effect that if `foo`
// is tainted and an operation reads from `foo` (e.g., `foo.bar`) then
// taint is propagated.
exists(ContentSetcs|
DataFlow::readStep(pred,cs,succ)and
notexcludedTaintStepContent(cs.getAReadContent())
)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just didn't see it. I've added a commit for it now - see what you think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is exactly what I meant.

TAnyTupleElement() or
TAnyDictionaryElement()
TAnyDictionaryElement() or
TAnyTupleOrDictionaryElement()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs logic in getAReadContent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. Added in a new commit.

DataFlowPrivate::yieldStoreStep(nodeFrom, _, nodeTo)
exists(DataFlow::ContentSet contentSet |
DataFlowPrivate::readStep(nodeFrom, contentSet, nodeTo) and
defaultTaintReadContent(contentSet)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you will instead need

exists(Content c |
c = contentSet.getAReadContent() |
c instanceof TupleElementContent or
c instanceof DictionaryElementContent or
c instanceof DictionaryElementAnyContent
)

@owen-mcowen-mcJun 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in this commit, though I think that has made the python language tests start failing. Did I misunderstand what you suggested?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They were already failing prior to that change; I spot checked a few, and it actually looks like good changes to the expected test output.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I missed that. I think I've fixed them now.

@owen-mc
owen-mcforce-pushed the py/remove-imprecise-container-steps branch from 815b9a0 to b384404CompareMay 31, 2026 20:48

@hvitvedhvitved left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few last comments.

}

override predicate propagatesFlow(string input, string output, boolean preservesValue) {
input = ["Argument[0,iterable:]", "Argument[0,iterable:].ListElement"] and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since read steps are also lifted to taint steps, "Argument[0,iterable:]" should not be needed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then a test fails on python/ql/test/query-tests/Security/CWE-078-UnsafeShellCommandConstruction/src/unsafe_shell_test.py:11, on " ".join(name).

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've kept both but made the ListElement one preserves = true and the other one preserves = false.

// TODO: We need to also translate iterable content such as list element
// but we currently lack TupleElementAny
input = "Argument[0]" and
input = ["Argument[0]", "Argument[0].ListElement"] and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, "Argument[0]" can be removed.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

input = "Argument[0]" and
input = ["Argument[0]", "Argument[0].ListElement"] and
output = "ReturnValue" and
preservesValue = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this now be true?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

Comment on lines +4252 to +4254
// Since list content is imprecise, we also taint the list.
output = "ReturnValue" and
preservesValue = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this part still needed?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻 to removing it. (Assuming CI passes.)

Comment threadpython/ql/lib/semmle/python/dataflow/new/internal/TaintTrackingPrivate.qll Outdated
Comment threadpython/ql/lib/semmle/python/frameworks/Stdlib.qll Outdated
@owen-mc
owen-mc requested a review from hvitvedJune 3, 2026 21:08
@owen-mc

Copy link
Copy Markdown
ContributorAuthor

Once this is approved I will run DCA again before merging, as it's changed quite a bit since the last run.

@hvitvedhvitved left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great to finally have this Python tech debt fixed 🎉

@hvitved

Copy link
Copy Markdown
Contributor

I have started a DCA run.

@owen-mc

Copy link
Copy Markdown
ContributorAuthor

Oops, I didn't scroll down far enough to read your comment and I've started a DCA run too 😆 .

@hvitved

Copy link
Copy Markdown
Contributor

DCA looks fine to me; the slowdown on some projects is acceptable IMO.

@owen-mc
owen-mc merged commit 1f91f91 into github:mainJun 4, 2026
19 checks passed
@owen-mc
owen-mc deleted the py/remove-imprecise-container-steps branch June 4, 2026 21:16
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@owen-mc@hvitved@yoff