feat(detection): add insecure-deserialization rule for Python (pickle, yaml.load, marshal, shelve) - #91
Conversation
…, yaml.load, marshal, shelve) Signed-off-by: AdvaitVarhade <199199199+AdvaitVarhade@users.noreply.github.com>
bkd-dotcom
left a comment
There was a problem hiding this comment.
Thanks for going straight onto #87 after the SSRF rule — and the test structure here is good, the negative-case test especially.
Safety: no concerns. No new dependencies, no workflow or packaging changes, no subprocess/network/eval, pure AST inspection. _attr_chain returns "" on unexpected nodes, so nothing here can crash the scanner on valid Python. CLA, CodeQL, self-admission and all three test matrix jobs are green; I ran the full suite and ruff locally too. I'd run this code without hesitation.
(The failing review check is my infrastructure bug, not yours — fork PRs get a read-only GITHUB_TOKEN, so the reviewer workflow gets a 403 trying to post its comment. Ignore it, I'm fixing it separately.)
But I can't merge it as-is, and the reason isn't visible from the diff alone, so let me show the evidence.
Blocker: this rule already exists, so the PR doubles every finding
main already has an unsafe-deserialisation block about 30 lines above where you added yours — deterministic.py:226-238:
# --- Unsafe deserialisation ---
if target in ("pickle.loads", "pickle.load", "cPickle.loads", "_pickle.loads"):
self._add(node, "py.insecure_deserialization", "insecure_deserialization", ...)
if target in ("yaml.load",) and not any(kw.arg == "Loader" for kw in node.keywords):
self._add(node, "py.yaml_load", "insecure_deserialization", ...)Findings are de-duplicated on Finding.key(), which is (file, line, category) — deliberately keyed on category so that "the same class of issue at the same location is the same finding, regardless of which layer found it" (model.py:60).
Your block emits category deserialization; the existing one emits insecure_deserialization. Different keys, so they no longer collapse — both survive.
On this 8-line file:
import pickle, yaml, flask
def load_session():
raw = flask.request.data
return pickle.loads(raw)
def load_config(text):
return yaml.load(text)main (2 findings) this PR (4 findings)
L5 py.insecure_deserialization L5 py.insecure_deserialization [insecure_deserialization]
[insecure_deserialization] L5 py.insecure_deserialization [deserialization] <-- duplicate
L8 py.yaml_load L8 py.yaml_load [insecure_deserialization]
[insecure_deserialization] L8 py.insecure_deserialization [deserialization] <-- duplicate
Every pickle.loads and yaml.load in every scanned repo would report twice.
Two knock-on problems:
- The same
rule_id(py.insecure_deserialization) now emits two different categories, breaking the one-rule-one-category invariant the rest of the engine assumes. deserializationbecomes a fifth, orphan spelling of a class the codebase already namesinsecure_deserializationinmultilang.py(Java/Ruby/PHP) andcrossfile.py.
Why test_no_false_positive_on_safe_deserialization passes, but not for the reason it looks like
It asserts "deserialization" not in _cats(...) — your new label. The pre-existing rule still fires insecure_deserialization on two of those same lines:
yaml.load(user_input, yaml.SafeLoader)— positional loader, somain'sany(kw.arg == "Loader")check misses itpickle.loads(b'hardcoded_constant_bytes')—main's pickle branch has no constant guard
So "zero false positives" holds for the new category but not for the report a user actually sees. Those are real pre-existing FPs in main, not something you introduced — but it does mean the test doesn't prove what it claims.
The good news: three of your four additions are real gaps
I checked each against main:
| case | main | verdict |
|---|---|---|
marshal.loads(x) |
miss | real gap |
shelve.open(x) |
miss | real gap |
yaml.load(x, Loader=yaml.Loader) |
miss | real gap |
pickle.loads(x) |
caught | already covered |
yaml.load(x) |
caught | already covered |
The third is the best catch in this PR: main treats any Loader= kwarg as safe, so an explicit Loader=yaml.Loader sails straight through.
Suggested fix — extend the existing block instead of adding a second one
I wrote and tested this. 252 tests pass, ruff clean, all three gaps closed, no duplicates:
# --- Unsafe deserialisation ---
if target in ("pickle.loads", "pickle.load", "cPickle.loads", "cPickle.load",
"_pickle.loads", "_pickle.load", "marshal.loads", "marshal.load",
"shelve.open"):
self._add(node, "py.insecure_deserialization", "insecure_deserialization", Severity.HIGH,
f"Insecure deserialisation via {target}()",
f"{target}() can execute arbitrary code during deserialisation.",
"Never deserialise untrusted input with pickle/marshal/shelve; "
"use JSON or a signed, verified format.",
0.88 if self._is_tainted(node) else 0.8, "CWE-502")
if target in ("yaml.load", "yaml.unsafe_load"):
loader = next((kw.value for kw in node.keywords if kw.arg == "Loader"), None)
if loader is None and len(args) >= 2:
loader = args[1]
safe_loader = (
target == "yaml.load"
and loader is not None
and _attr_chain(loader).rsplit(".", 1)[-1] in ("SafeLoader", "CSafeLoader", "BaseLoader")
)
if not safe_loader:
self._add(node, "py.yaml_load", "insecure_deserialization", Severity.HIGH,
f"{target}() without a safe Loader",
f"{target}() can instantiate arbitrary Python objects.",
"Use yaml.safe_load or pass Loader=yaml.SafeLoader.",
0.82, "CWE-502")Measured against your version:
marshal,shelve,Loader=yaml.Loader— caught (your three gaps) ✅yaml.unsafe_load(x)— also caught; explicit unsafe entry point, missed todayyaml.load(x, yaml.SafeLoader)— now correctly silent, fixing the pre-existing positional-loader FP- resolves
Loaderby last path segment, so bothyaml.SafeLoaderand a bareSafeLoaderimport work - every case reports exactly once; one
rule_id→ one category
Two notes on your version, for the rewrite
not isinstance(args[0], ast.Constant)reads as a taint check but isn't one — it only excludes literals.pickle.load(f)on a local file handle is flagged identically to a request body. That's defensible (bandit flags the API, not the flow, and the existing rule does the same), so in my version I kept the unconditional flag and used_is_taintedto modulate confidence (0.88 vs 0.8) the way the surrounding rules do, rather than to gate the finding.yaml.loadwith a constant first arg (yaml.load(SOME_LITERAL)) is still flagged — you guarded pickle against constants but not yaml. I left it flagged for consistency with the existing rule; push back if you disagree.
What I'd like
Push the rewrite and I'll re-review same day. Or, if you'd rather I apply the patch above myself, say so and I'll land it with you credited in CONTRIBUTORS.md — you found the gaps, which is the hard part, and I don't want the structural note to cost you the contribution.
One housekeeping item either way: the PR body describes category deserialization; after the rewrite it becomes insecure_deserialization, so worth updating the description so the changelog reads correctly.
…#103) Closes #87. Gaps identified by @AdvaitVarhade in #91, credited in CONTRIBUTORS. Extends the EXISTING unsafe-deserialisation block rather than adding a second one. That distinction is the whole reason #91 could not be merged as written: findings dedup on (file, line, category), so a parallel rule using a different category name for the same class defeats the dedup and doubles every pickle/yaml finding in every scanned repo. One rule_id per class, one category. Now flagged: * marshal.loads / marshal.load — executes arbitrary code while decoding * shelve.open — pickle-backed, same exposure * yaml.unsafe_load * yaml.load(x, Loader=yaml.Loader) — the best catch of the three. The old check was `not any(kw.arg == "Loader")`, i.e. ANY Loader counted as safe, so an explicitly unsafe loader passed silently. Also fixes a pre-existing FALSE POSITIVE it exposed: the old check only inspected keywords, so `yaml.load(x, yaml.SafeLoader)` — a positional safe loader — was flagged. The Loader is now read from the keyword or the second positional arg and matched on its last path segment, so `yaml.SafeLoader`, a bare imported `SafeLoader`, and `CSafeLoader` are all recognised. Verified: every case reports exactly ONCE (the 8-line fixture that went 2 findings -> 4 under #91 stays at 2), all five safe forms stay silent, and 283 tests pass with ruff clean. Co-authored-by: Binay <bkd-dotcom@users.noreply.github.com>
|
Closing this — #87 is implemented and merged in #103, and your three findings are what it's built on. Credited in The review offered to apply the patch with credit if you'd rather not rewrite; the owner asked me to land it, so that's what happened. Not a reflection on the work. What you found that
That third one was the best catch. The old condition was: if target in ("yaml.load",) and not any(kw.arg == "Loader" for kw in node.keywords):Any It also led to two things beyond the issue:
Why it couldn't merge as written, for the record: findings dedup on You've landed two rules in this engine now (#73, #89) and directly caused a third plus a false-positive fix. That's a real contribution record. The board is at Signetry/signetry#10 if you want the next one — though fair warning, you've cleared most of it. |
Summary
This pull request implements the Python insecure deserialization detection rule (
py.insecure_deserialization, categorydeserialization,CWE-502, severityHIGH) insignetry_core/pipeline/findings/deterministic.py.Changes
pickle.loads,pickle.load,_pickle.loads,_pickle.load,marshal.loads,marshal.load, andshelve.openwhen invoked with non-constant inputs.yaml.loadcalls withoutLoader=yaml.SafeLoaderorLoader=SafeLoader(or when using unsafe loaders likeyaml.Loader/yaml.UnsafeLoader/yaml.FullLoader).yaml.safe_load,yaml.load(..., Loader=SafeLoader), hardcoded constant byte payloads topickle.loads, and standardjson.loads.tests/test_findings_engine.pycovering positive detection on vulnerable calls and negative tests for safe patterns. All 61 findings engine tests pass cleanly.Related to issue #87.
Please check and tell if any changes are required.