How Does SkillSpector Perform AST-Based Behavioral Analysis?
SkillSpector performs AST-based behavioral analysis by walking the abstract syntax tree of Python source files to detect dangerous execution patterns like eval(), subprocess calls, and dynamic attribute access, converting these findings into structured security alerts with precise line numbers and context.
NVIDIA's SkillSpector implements a dedicated Behavioral AST analyzer that inspects Python code for potentially dangerous capabilities without executing it. According to the SkillSpector source code, this static analysis technique parses discovered .py files into abstract syntax trees and applies heuristic security rules to identify code that could enable arbitrary command execution at runtime.
Entry Point: The Behavioral AST Analyzer Node
The analysis begins in [behavioral_ast.py](https://github.com/NVIDIA/SkillSpector/blob/main/src/skillspector/nodes/analyzers/behavioral_ast.py), which implements the analyzer node that receives pipeline state from the static pattern runner.
The node(state) function accepts a SkillspectorState object containing:
components: A list of discovered Python file pathsfile_cache: A mapping of path → source-code for each component
For each .py file that satisfies the size constraint defined by MAX_FILE_BYTES in [static_runner.py](https://github.com/NVIDIA/SkillSpector/blob/main/src/skillspector/nodes/analyzers/static_runner.py), the node invokes the internal helper _analyze_python(content, path) to begin inspection.
Parsing Python Source into AST
Inside _analyze_python, the analyzer uses Python's built-in ast module to parse source code into a traversable tree structure:
tree = ast.parse(content, filename=file_path)
If ast.parse raises a SyntaxError, the analyzer aborts processing for that specific file and logs the failure, allowing the scan to continue against the remaining codebase. This tolerant approach ensures that malformed or incomplete files do not crash the entire analysis pipeline.
Detecting Hazardous Code Constructs
The core detection logic iterates over every node in the AST using ast.walk(tree) and filters for ast.Call nodes:
for ast_node in ast.walk(tree):
if not isinstance(ast_node, ast.Call):
continue
call_name = resolve_call_name(ast_node) # defined in common.py
The resolve_call_name utility (located in [common.py](https://github.com/NVIDIA/SkillSpector/blob/main/src/skillspector/nodes/analyzers/common.py)) converts a Call node into a dotted string representation (e.g., os.system, subprocess.run, exec).
Dangerous Builtins and Dynamic Imports
The analyzer checks resolved call names against pre-computed rule sets to identify high-risk builtins:
_DANGEROUS_BUILTINS: Containsexec,eval,compile, and__import__- AST1:
exec()call detected - AST2:
eval()call detected - AST3: Dynamic import via
__import__() - AST6:
compile()call detected
- AST1:
Subprocess and OS-Level Execution
System-level execution vectors are detected via two additional sets:
_SUBPROCESS_CALLS: Includescall,run,Popen,check_output, and similar subprocess module functions (AST4)_OS_EXEC_CALLS: Coverssystem,execl,posix_spawn, and other os-module execution functions (AST5)
Dynamic Attribute Access
Non-constant attribute access via getattr triggers AST7 ("Dynamic attribute access via getattr()"), flagging code that dynamically resolves object methods or properties based on runtime input.
Nested Execution Chains
When exec, eval, or compile wrap another dangerous call (such as exec(os.system('rm -rf /'))), the _contains_dangerous_source helper walks the argument subtree searching for nested dangerous calls. If found, the analyzer emits AST8 ("Dangerous execution chain").
Emitting Structured Security Findings
Each detection invokes _emit(rule_id, lineno, end_lineno, msg_override=None), which constructs an AnalyzerFinding object defined in [models.py](https://github.com/NVIDIA/SkillSpector/blob/main/src/skillspector/models.py). These objects include:
- Rule ID: The specific AST code (e.g.,
AST1) - Severity: Drawn from
_RULE_SEVERITIES(HIGH, MEDIUM, etc.) - Confidence: Based on
_RULE_CONFIDENCES - Location: File path, start line, and optional end line
- Context: A 3-line code snippet extracted via
get_context_from_linesincommon.py - Matched text: First 200 characters from
get_source_segment
The analyzer_finding_to_finding function in [static_runner.py](https://github.com/NVIDIA/SkillSpector/blob/main/src/skillspector/nodes/analyzers/static_runner.py) subsequently converts these internal objects into public Finding types, adding remediation data, tags, and user-friendly categories for the final report.
Integration with the Static Analysis Pipeline
The AST-based behavioral analysis integrates into SkillSpector's broader scanning architecture through four stages:
- File Discovery:
graph.Scannercollects all target files and populatesstate["file_cache"]with source contents - Static Pattern Execution:
run_static_patternsinstatic_runner.pyiterates over analyzer modules, includingbehavioral_ast - Node Execution: The
nodefunction returns{"findings": all_findings}to update the graph state - Reporting: Downstream nodes (such as
report.py) aggregate findings and render SARIF or CLI-formatted output
Code Examples
Detecting Direct Code Execution
A simple exec call triggers AST1:
# unsafe.py
exec("print('Hello world')")
SkillSpector output:
[AST1] exec() call detected
Location: unsafe.py:1
Context:
exec("print('Hello world')")
Identifying Dynamic Imports
Using __import__ triggers AST3:
mod = __import__('os')
Flagging Dangerous Evaluation Chains
Nested dangerous calls trigger AST8:
eval("subprocess.run(['rm', '-rf', '/'])")
Output:
[AST8] Dangerous chain: eval() wrapping subprocess.run
Location: unsafe.py:1
Spotting Dynamic Attribute Access
Non-constant getattr usage triggers AST7:
obj = SomeClass()
method = getattr(obj, user_supplied_name)
Running the Analyzer from Command Line
To run only the behavioral AST analyzer against a project:
skill-spector scan ./my_project \
--analyzers behavioral_ast \
--output sarif
Summary
- SkillSpector analyzes Python code by parsing files into ASTs using
ast.parse()and walking the tree to findast.Callnodes. - Detection relies on sets of dangerous function names (
_DANGEROUS_BUILTINS,_SUBPROCESS_CALLS,_OS_EXEC_CALLS) to flag risky capabilities. - The analyzer emits structured
AnalyzerFindingobjects that include severity, confidence, and source context extracted via helpers incommon.py. - Rule IDs AST1 through AST8 cover builtins, subprocess calls, dynamic imports, attribute access, and nested execution chains.
- Findings flow through
static_runner.pyto become publicFindingobjects suitable for SARIF or CLI reporting.
Frequently Asked Questions
What specific Python constructs does SkillSpector flag as dangerous?
SkillSpector flags four primary categories of dangerous constructs according to its rule tables: direct code execution builtins (exec, eval, compile), dynamic import mechanisms (__import__), system command execution via subprocess and os modules, and dynamic attribute resolution through getattr with variable arguments. Each category maps to specific AST rule IDs (AST1-AST8) with assigned severity levels.
How does SkillSpector handle files with syntax errors?
The analyzer wraps ast.parse() in exception handling that catches SyntaxError and logs the failure without stopping the scan. This design choice, implemented in _analyze_python within behavioral_ast.py, ensures that malformed files or template fragments do not prevent analysis of the remainder of the codebase.
What is the difference between AnalyzerFinding and Finding in SkillSpector?
AnalyzerFinding is the internal data class used by individual analyzer nodes (defined in models.py) to store raw detection results with rule IDs and source locations. Finding is the public-facing type that includes additional metadata such as remediation guidance, tags, and formatted messages. The conversion happens in static_runner.py via analyzer_finding_to_finding.
Can SkillSpector detect obfuscated code or only direct function calls?
The current AST-based analyzer primarily detects direct function calls and simple wrapper chains (AST8). It resolves call names using resolve_call_name from common.py, which handles standard import patterns. While it can detect one level of nesting (e.g., eval(subprocess.run())), it does not perform deep symbolic execution or deobfuscation of heavily encoded strings or dynamically constructed code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →