Skip to content

fix(rocprofiler-compute): Harden formula eval with AST node allowlist - #9736

Open
xuchen-amd wants to merge 2 commits into
rocprofiler-compute-developfrom
users/xuchen-amd/harden_formula_eval
Open

fix(rocprofiler-compute): Harden formula eval with AST node allowlist#9736
xuchen-amd wants to merge 2 commits into
rocprofiler-compute-developfrom
users/xuchen-amd/harden_formula_eval

Conversation

@xuchen-amd

Copy link
Copy Markdown
Contributor

Motivation

Add deny-by-default AST node validation to the metric formula evaluation pipeline. YAML config formula strings reach eval() through CodeTransformer without 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

  • Add ALLOWED_AST_NODES frozenset and a generic_visit override to CodeTransformer that rejects any AST node type not on the allowlist
  • Callers (build_eval_string, db_analysis.evaluate) catch ValueError from the transformer and report the offending expression via console_error
  • Suppress __builtins__ at both eval() sites by passing {"__builtins__": {}} as globals instead of {}

Issue Tracking

JIRA ID: ROCM-26855

Test Plan

  • Verify legitimate formulas (arithmetic, aggregation calls, ternaries) pass
  • Verify unsafe AST node types (Attribute, Lambda, ListComp, GeneratorExp, DictComp, SetComp) raise ValueError
  • Verify build_eval_string exits via console_error on unsafe input

Test Result

  • Verify legitimate formulas (arithmetic, aggregation calls, ternaries) pass
  • Verify unsafe AST node types (Attribute, Lambda, ListComp, GeneratorExp, DictComp, SetComp) raise ValueError
  • Verify build_eval_string exits via console_error on unsafe input

Submission Checklist

@abchoudh-amd abchoudh-amd 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.

LGTM.
Could you add this is as Resolved Issues in Changelog?

@vedithal-amd vedithal-amd 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.

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_error aborts the run instead of warning and skipping the metric
  • the visit_Call allowlist 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 eval sites

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({

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.

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,

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.

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:

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.

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:

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.

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 node

Verified 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()

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.

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}")

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.

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()

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.

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}")

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.

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__": {}},

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.

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

@vedithal-amd

Copy link
Copy Markdown
Contributor

Could you add this is as Resolved Issues in Changelog?

@abchoudh-amd and @xuchen-amd I dont think this is a user facing change right?

Sign up for free to 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.

3 participants