fix(rocprofiler-compute): Harden formula eval with AST node allowlist - #9736
fix(rocprofiler-compute): Harden formula eval with AST node allowlist#9736xuchen-amd wants to merge 2 commits into
Conversation
abchoudh-amd
left a comment
There was a problem hiding this comment.
LGTM.
Could you add this is as Resolved Issues in Changelog?
vedithal-amd
left a comment
There was a problem hiding this comment.
Fix is sound. Verified the reported payload and variants are rejected, builtins are genuinely empty at both eval sites, and all 9,518 shipped analysis_configs/ formulas still pass the new transformer.
Also traced the parse=False branch at analysis_db.py:493 -- not a hole, those strings come from analysis_base.py:192 -> build_eval_string, so they're already validated. Worth a line in the PR body to save the next reader the trace.
Four things to fix before merge:
console_erroraborts the run instead of warning and skipping the metric- the
visit_Callallowlist is dead for all non-ammolite__names - benign operators (
ast.keyword,In/Is, bitwise) got caught in the narrowing - no test that builtins are suppressed at the
evalsites
Rest are minor/nitpick, called out inline.
| from utils.utils_common import SUPPORTED_FIELD | ||
| from utils.utils_counter_defs import SUPPORTED_DENOM | ||
|
|
||
| ALLOWED_AST_NODES: frozenset[type] = frozenset({ |
There was a problem hiding this comment.
This rejects ast.keyword (so ROUND(x, ndigits=0) breaks), In/NotIn/Is/IsNot, and all bitwise ops. None are escape vectors, so please add them back -- shipped configs don't hit these, but custom configs may.
Also worth one line above the frozenset on why Attribute is absent, since that's the whole security property:
# ast.Attribute is omitted deliberately: it is the dunder-chain escape.| ast.Call, | ||
| ast.Subscript, | ||
| ast.Slice, | ||
| ast.Index, |
There was a problem hiding this comment.
ast.parse hasn't emitted Index since 3.9 and pyproject.toml pins requires-python = ">=3.9". Dead entry.
| class CodeTransformer(ast.NodeTransformer): | ||
| """Python AST visitor to transform user equation strings to df format.""" | ||
|
|
||
| def generic_visit(self, node: ast.AST) -> ast.AST: |
There was a problem hiding this comment.
Bare ValueError could swallow an unrelated ValueError raised from inside the visitor and mislabel it as an invalid expression. Suggest an InvalidExpressionError(ValueError) and catching that at both call sites.
| raise ValueError(f"Disallowed expression element: {type(node).__name__}") | ||
| return super().generic_visit(node) | ||
|
|
||
| def visit_Call(self, node: ast.Call) -> ast.Call: |
There was a problem hiding this comment.
The SUPPORTED_CALL check never fires for non-ammolite__ names: generic_visit runs first and visit_Name has already turned the callee into a Subscript, so FOO(SQ_WAVES) becomes raw_pmc_df['FOO'](...) and SQ_WAVES[0]() passes too. Check before transforming:
def visit_Call(self, node: ast.Call) -> ast.Call:
if not isinstance(node.func, ast.Name) or node.func.id not in SUPPORTED_CALL:
raise InvalidExpressionError(
f"Unsupported call: {astunparse.unparse(node.func).strip()}"
)
self.generic_visit(node)
node.func.id = SUPPORTED_CALL[node.func.id]
return nodeVerified against all 9,518 formulas in analysis_configs/: no regressions. This also folds the bare raise Exception on line 93 (and in visit_IfExp) into the same type, so the new except ValueError actually catches a typo'd formula instead of tracebacking.
| transformer = CodeTransformer() | ||
| transformer.visit(ast_node) | ||
| try: | ||
| transformer = CodeTransformer() |
There was a problem hiding this comment.
Only .visit() can raise; move the constructor above the try. Same at analysis_db.py:592.
| transformer = CodeTransformer() | ||
| transformer.visit(ast_node) | ||
| except ValueError as exc: | ||
| console_error(f"Invalid metric expression '{equation}': {exc}") |
There was a problem hiding this comment.
console_error exits, so one bad metric aborts the whole run. Every other failure in these two functions warns and degrades the cell (metric_evaluator.py:100-120 -> "N/A", analysis_db.py:541-543 -> None).
Suggest console_warning + return "" here and return None at analysis_db.py:595; evaluation_pipeline.py:191 already skips falsy cells and build_eval_string already returns "" for an empty equation. Rejecting the expression is the security property, terminating isn't.
Note this means test_build_eval_string_exits_on_unsafe_formula needs rewriting to assert the warning rather than SystemExit.
| transformer = CodeTransformer() | ||
| transformer.visit(ast_node) | ||
| try: | ||
| transformer = CodeTransformer() |
There was a problem hiding this comment.
Same try/except as expression.py:189. Suggest a transform_expression(ast_node, source) helper in expression.py so the two error messages can't drift.
| transformer = CodeTransformer() | ||
| transformer.visit(ast_node) | ||
| except ValueError as exc: | ||
| console_error(f"Invalid metric expression '{value}': {exc}") |
There was a problem hiding this comment.
value has already been through the $ -> sys_info["..."] rewrite at line 483, so the user sees mangled text instead of their YAML. build_eval_string gets this right by reporting the original equation.
| eval_result = eval( | ||
| compile(expr, "<string>", "eval"), | ||
| {}, | ||
| {"__builtins__": {}}, |
There was a problem hiding this comment.
Nothing asserts builtins are actually suppressed, so a revert to {} passes CI silently. One test would cover it:
def test_eval_expression_cannot_reach_builtins(self):
"""Builtins are suppressed at the eval site, so __import__ is unreachable."""
evaluator = MetricEvaluator(pd.DataFrame(), {}, {})
with patch("utils.metrics.metric_evaluator.console_warning") as mock_warning:
assert evaluator.eval_expression("__import__('os')") == "N/A"
assert mock_warning.called
@abchoudh-amd and @xuchen-amd I dont think this is a user facing change right? |
Motivation
Add deny-by-default AST node validation to the metric formula evaluation pipeline. YAML config formula strings reach
eval()throughCodeTransformerwithout restriction on AST node types, allowing attribute chains("".__class__.__mro__)and other constructs to bypass the existing function call allowlist. Users can supply arbitrary YAML configs via--config-dir.Technical Details
ALLOWED_AST_NODESfrozenset and ageneric_visitoverride toCodeTransformerthat rejects any AST node type not on the allowlistbuild_eval_string,db_analysis.evaluate) catchValueErrorfrom the transformer and report the offending expression viaconsole_error__builtins__at botheval()sites by passing{"__builtins__": {}}as globals instead of{}Issue Tracking
JIRA ID: ROCM-26855
Test Plan
Attribute,Lambda,ListComp,GeneratorExp,DictComp,SetComp) raiseValueErrorbuild_eval_stringexits viaconsole_erroron unsafe inputTest Result
Submission Checklist