fix: flag module __dict__ subscript as reflective attribute access - #517
fix: flag module __dict__ subscript as reflective attribute access#517eitanch228 wants to merge 5 commits into
Conversation
The behavioral AST walker skips every node that is not an ast.Call, so subscripting an imported module's namespace dict - os.__dict__["po"+"pen"] or vars(os)[key] - is invisible even though it is semantically identical to getattr(os, key), which AST7/AST9 already catch. Add an ast.Subscript branch that mirrors the getattr rules: when the base resolves through the import-alias map to a plain module, a non-constant key produces AST7 and a constant key in _DANGEROUS_GETATTR_NAMES produces AST9, both with messages naming the subscript form. Instance attribute bags (self.__dict__[...]) and from-imported classes are deliberately out of scope to avoid false positives. Signed-off-by: eitanch228 <eitan.ch@pluto.security>
fix: flag module __dict__ subscript as reflective attribute access
Empty commit to re-run test-unit after a flaky wall-clock budget failure (is_complete=False from the 60s processing deadline on a slow runner; the failing test scans a markdown-only bundle untouched by this change). Signed-off-by: eitanch228 <eitan.ch@pluto.security>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Reviewed head 61d753482ba5ecf85632077ff1c0c96570acbb1d — APPROVE.
The analyzer restricts the new subscript handling to imported module namespaces, distinguishes literal dangerous names from dynamic keys, preserves benign instance dictionaries and safe literals, and covers aliases plus both __dict__ and vars() spellings. I found no required changes.
Required checks pass, but GitHub currently reports mergeStateStatus=BEHIND; update against current main and re-run required checks before merging.
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Reviewed head f55402e6fdfc5e223df303af01ff85c92ba79437 — APPROVE.
I re-reviewed the complete replacement head. Its only change since the previously assessed commit is the upstream README badge merged from main; the PR's two-file delta is unchanged. The analyzer restricts the new subscript handling to imported module namespaces, distinguishes literal dangerous names from dynamic keys, preserves benign instance dictionaries and safe literals, and covers aliases plus both __dict__ and vars() spellings. I found no required changes.
Merge remains blocked until all current-head required checks finish successfully and GitHub reports a clean merge state.
Summary
The behavioral AST analyzer treats
getattras the only reflectiveattribute-access spelling, so the equivalent subscript form is invisible:
getattr(os, computed)-> AST7 (LOW, dynamic attribute access)getattr(os, "popen")-> AST9 (HIGH, literal sink name)os.__dict__["po"+"pen"]-> no finding at all_analyze_pythonwalks the tree and skips every node that is not anast.Call, so anast.Subscriptover a module's__dict__(or overvars(module)) is never inspected. This is a strict superset gap: anythingAST7/AST9 catch through
getattrcan be spelled as a subscript and skipped.Reproduction
A runnable MCP server that resolves its command-execution sink through the
module namespace dict instead of
getattr:The same server spelled with
getattr(os, _cat("po", "pen"))produces AST7.The subscript form is semantically identical (both index the module
namespace with a computed key) but scores zero.
Fix
Mirror the existing getattr rules for the subscript form.
1. New helper
_reflective_module_dict_base(node, aliases). Matches<module>.__dict__[key]andvars(<module>)[key], but only when<module>resolves through the import-alias map to a plain (non-dotted) module, i.e.
import osorimport os as o. This deliberately excludes:self.__dict__[...]and other instance attribute bags, which are a commonand idiomatic pattern (false-positive risk)
from x import SomeClass; SomeClass.__dict__[k]),whose alias resolves to a dotted path
2. A
ast.Subscriptbranch in the walk loop, reusing the existing ruleIDs so severity, confidence, and remediation text stay consistent with the
getattr form:
os.__dict__[computed]/vars(os)[computed]os.__dict__["popen"](constant key in_DANGEROUS_GETATTR_NAMES)os.__dict__["environ"](constant, not a sink name)getattr(obj, "name")After the fix, the reproduction server scores AST7 (LOW, score 3) instead of
zero, the same treatment the getattr spelling already gets. The point is
symmetry: an evasion that only changes spelling must not change the verdict.
Why no new rule IDs
This is the same issue class as AST7/AST9 with a different surface syntax.
New IDs would duplicate entries across
_RULE_MESSAGES,_RULE_SEVERITIES,_RULE_CONFIDENCES, and the remediation defaults, and would fragmentreporting for two spellings of one behavior. The message override makes the
spelling explicit in the finding text.
Out of scope (noted for follow-up)
getattr(os, "__dict__")[key]chainsglobals()["po"+"pen"]/locals()[...]namespace-dict writesdecision that applies to the getattr form equally and should be argued
separately
Test plan
os.__dict__["po"+"pen"]-> AST7os.__dict__["popen"](constant) -> AST9vars(os)[computed]-> AST7import os as o; o.__dict__[k]-> AST7 (alias resolution)self.__dict__[key]-> no finding (instance attribute bag)os.__dict__["environ"]-> no finding (constant, not a sink name)