Skip to content
Merged
Changes from 3 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b594816
fix(pickle-scanner): three targeted false-positive reductions
yash2998chhabria Feb 25, 2026
28bd9db
fix(pickle-scanner): fix MANY_DANGEROUS_OPCODES false positives on sk…
yash2998chhabria Feb 25, 2026
9575bf2
style: apply ruff format to pickle_scanner.py
yash2998chhabria Feb 25, 2026
e19c6bd
fix: reduce false positives in pickle/openvino/keras scanners
yash2998chhabria Feb 25, 2026
8b0c4e9
fix: eliminate remaining pickle/joblib false positives (round 2)
yash2998chhabria Feb 25, 2026
e041076
fix: add scipy.sparse allowlist and lower ML confidence threshold
yash2998chhabria Feb 25, 2026
c131fd5
fix: add sklearn._loss and old module path entries to allowlist
yash2998chhabria Feb 25, 2026
aa3edaa
Fix false positives in pickle/joblib scanning (round 4+5 fixes)
yash2998chhabria Feb 26, 2026
fcddf2c
Fix false positives from binary model data matching secret/auth patterns
yash2998chhabria Feb 26, 2026
3a7df6b
Fix test_detect_secrets_in_file Windows CI failure
yash2998chhabria Feb 26, 2026
e41c324
fix: address CodeRabbit review comments on PR #591
yash2998chhabria Feb 26, 2026
1ee2a8d
fix: relax compressed joblib test assertion for cross-platform compat…
yash2998chhabria Feb 26, 2026
9d82a35
fix(pickle-scanner): address critical CodeRabbit security findings on…
yash2998chhabria Feb 26, 2026
526425b
fix(pickle-scanner): add numpy._core.numeric._frombuffer and truncate…
yash2998chhabria Feb 26, 2026
70dc71b
fix(pickle-scanner): suppress MANY_DANGEROUS_OPCODES on low-confidenc…
yash2998chhabria Feb 26, 2026
1f703b4
Merge branch 'main' into fix/pickle-remaining
yash2998chhabria Feb 26, 2026
7a5e25d
fix(test): broaden keyword filter for Windows joblib test compatibility
yash2998chhabria Feb 26, 2026
254ff0b
fix(pickle-scanner): allow safe builtins globals through ALWAYS_DANGE…
yash2998chhabria Feb 26, 2026
ad46175
Merge remote-tracking branch 'origin/main' into fix/pickle-remaining
yash2998chhabria Feb 27, 2026
91d29bf
Merge remote-tracking branch 'origin/main' into fix/pickle-remaining
yash2998chhabria Feb 27, 2026
6693314
fix: remove duplicate scipy sparse dict keys in ML_SAFE_GLOBALS
yash2998chhabria Feb 27, 2026
8bc2a7b
Merge remote-tracking branch 'origin/main' into fix/pickle-remaining
yash2998chhabria Feb 27, 2026
4078e1c
Merge remote-tracking branch 'origin/main' into fix/pickle-remaining
yash2998chhabria Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 87 additions & 14 deletions modelaudit/scanners/pickle_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,13 +624,33 @@ def _compute_pickle_length(path: str) -> int:
"numpy.random._pickle": [
"__randomstate_ctor",
"__generator_ctor",
"__bit_generator_ctor",
],
"numpy.random.mtrand": [
"RandomState",
],
"numpy.random._common": [
"BitGenerator",
],
"numpy.random._generator": [
"Generator",
],
"numpy.random._bounded_integers": [
"_bounded_integers",
],
"numpy.random._mt19937": [
"MT19937",
],
"numpy.random._pcg64": [
"PCG64",
"PCG64DXSM",
],
"numpy.random._philox": [
"Philox",
],
"numpy.random._sfc64": [
"SFC64",
],
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file structure and relevant lines
head -n 680 modelaudit/scanners/pickle_scanner.py | tail -n +620

Repository: promptfoo/modelaudit

Length of output: 1761


🏁 Script executed:

# Now run the AST verification script provided in the review comment
python - <<'PY'
import ast, collections, pathlib
p = pathlib.Path("modelaudit/scanners/pickle_scanner.py")
tree = ast.parse(p.read_text())
for node in tree.body:
    if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "ML_SAFE_GLOBALS" for t in node.targets):
        d = node.value
        keys = [k.value for k in d.keys if isinstance(k, ast.Constant) and isinstance(k.value, str)]
        dup = {k:v for k,v in collections.Counter(keys).items() if v > 1}
        print("Duplicate keys:", dup)
        print("\nAll keys in order:")
        for i, k in enumerate(keys):
            print(f"  {i}: {k}")
PY

Repository: promptfoo/modelaudit

Length of output: 46


🏁 Script executed:

# Let's also get more detailed analysis of the dictionary values
python - <<'PY'
import ast, pathlib
p = pathlib.Path("modelaudit/scanners/pickle_scanner.py")
tree = ast.parse(p.read_text())
for node in tree.body:
    if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "ML_SAFE_GLOBALS" for t in node.targets):
        d = node.value
        # Extract key-value pairs
        pairs = []
        for k, v in zip(d.keys, d.values):
            if isinstance(k, ast.Constant) and isinstance(k.value, str):
                if isinstance(v, ast.List):
                    values = [elem.value for elem in v.elts if isinstance(elem, ast.Constant)]
                    pairs.append((k.value, values))
        
        # Find duplicates with their values
        from collections import defaultdict
        key_occurrences = defaultdict(list)
        for k, v in pairs:
            key_occurrences[k].append(v)
        
        print("Keys with multiple occurrences:")
        for k, occurrences in key_occurrences.items():
            if len(occurrences) > 1:
                print(f"\n  Key: {k}")
                for i, values in enumerate(occurrences):
                    print(f"    Occurrence {i+1}: {values}")
PY

Repository: promptfoo/modelaudit

Length of output: 46


🏁 Script executed:

# Find the exact lines where each key appears
rg -n '"numpy\.random\._pcg64"' modelaudit/scanners/pickle_scanner.py
rg -n '"numpy\.random\._philox"' modelaudit/scanners/pickle_scanner.py
rg -n '"numpy\.random\._sfc64"' modelaudit/scanners/pickle_scanner.py

Repository: promptfoo/modelaudit

Length of output: 291


🏁 Script executed:

# Get line 644-653 and 674-680 specifically
sed -n '644,653p' modelaudit/scanners/pickle_scanner.py | cat -n
echo "---"
sed -n '674,680p' modelaudit/scanners/pickle_scanner.py | cat -n

Repository: promptfoo/modelaudit

Length of output: 659


🏁 Script executed:

# Check the dictionary structure more carefully
sed -n '600,700p' modelaudit/scanners/pickle_scanner.py | head -n 100

Repository: promptfoo/modelaudit

Length of output: 2508


Remove duplicate ML_SAFE_GLOBALS keys that override earlier entries.

The dictionary contains duplicate keys at multiple locations: "numpy.random._pcg64" (lines 644 and 676), "numpy.random._philox" (lines 648 and 675), and "numpy.random._sfc64" (lines 651 and 677). In Python dictionaries, the last occurrence silently overwrites earlier ones. At line 676, "numpy.random._pcg64": ["PCG64"] overwrites the earlier entry at line 644 with ["PCG64", "PCG64DXSM"], making the effective allowlist more restrictive. While the net security effect is more conservative, the duplication creates unclear intent and introduces a subtle code defect that could lead to unintended behavior changes during future maintenance. Consolidate to a single entry per key with explicit values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modelaudit/scanners/pickle_scanner.py` around lines 644 - 653,
ML_SAFE_GLOBALS contains duplicate keys for "numpy.random._pcg64",
"numpy.random._philox", and "numpy.random._sfc64" that silently overwrite
earlier entries; update the ML_SAFE_GLOBALS dict in pickle_scanner.py (the dict
named ML_SAFE_GLOBALS) to remove duplicates and consolidate each key into a
single entry that contains the full, intended allowlist (e.g., keep ["PCG64",
"PCG64DXSM"] for "numpy.random._pcg64" rather than a later ["PCG64"]), ensuring
each of the three keys appears only once with the explicit combined values.

"math": [
"sqrt",
"pow",
Expand Down Expand Up @@ -1174,14 +1194,21 @@ def _detect_ml_context(opcodes: list[tuple]) -> dict[str, Any]:
matches: list[str] = []

# Check module matches with improved scoring
# Uses prefix matching so that e.g. module pattern "sklearn" matches
# global refs like "sklearn.pipeline", "sklearn.ensemble._iforest", etc.
modules = patterns["modules"]
if isinstance(modules, list):
for module in modules:
if module in global_refs:
# Aggregate counts from all global_refs that match this module
# either exactly or as a prefix (e.g. "sklearn" matches "sklearn.pipeline")
ref_count = 0
for ref_key, ref_cnt in global_refs.items():
if ref_key == module or ref_key.startswith(module + "."):
ref_count += ref_cnt
Comment thread
yash2998chhabria marked this conversation as resolved.

if ref_count > 0:
# Score based on presence and frequency,
# not proportion of total opcodes
ref_count = global_refs[module]

# Base score for presence
module_score = 10.0 # Base score for any ML module presence

Expand Down Expand Up @@ -2098,8 +2125,17 @@ def check_opcode_sequence(
else:
is_dangerous_opcode = True

elif opcode.name in ("NEWOBJ", "NEWOBJ_EX"):
# NEWOBJ/NEWOBJ_EX: check associated class via resolved_callables
is_dangerous_opcode = True
associated_ref = resolved_callables.get(i)
if associated_ref:
mod, func = associated_ref
if _is_safe_ml_global(mod, func):
is_dangerous_opcode = False
Comment thread
yash2998chhabria marked this conversation as resolved.

else:
# Other dangerous opcodes (INST, OBJ, NEWOBJ, NEWOBJ_EX, BUILD, EXT*)
# Other dangerous opcodes (INST, OBJ, BUILD, EXT*)
is_dangerous_opcode = True

if is_dangerous_opcode:
Expand All @@ -2113,9 +2149,23 @@ def check_opcode_sequence(

# Fixed threshold for dangerous opcode detection.
# Only execution-related opcodes are counted (memo/framing excluded),
# and safe ML globals are skipped for REDUCE/GLOBAL/STACK_GLOBAL.
# and safe ML globals are skipped for REDUCE/GLOBAL/STACK_GLOBAL/NEWOBJ.
threshold = 50

# In high-confidence ML contexts (e.g. sklearn tree ensembles, xgboost),
# tree-based models legitimately produce hundreds of REDUCE/NEWOBJ/BUILD opcodes
# for each estimator node. A Random Forest with 100 trees can easily produce
# 5000+ BUILD opcodes from __setstate__ calls on tree nodes.
# Raise the threshold significantly to suppress false positives.
_ml_frameworks = ml_context.get("frameworks", {})
_tree_ensemble_frameworks = {"sklearn", "xgboost"}
if (
ml_context.get("is_ml_content", False)
and ml_context.get("overall_confidence", 0) >= 0.5
and _tree_ensemble_frameworks & set(_ml_frameworks.keys())
):
threshold = 5000

if dangerous_opcode_count > threshold:
suspicious_patterns.append(
{
Expand Down Expand Up @@ -3464,16 +3514,18 @@ def get_depth(x):
ml_context,
)

result.add_check(
name="REDUCE Opcode Safety Check",
passed=False,
message=(
# CVE-2025-32434 is specific to torch.load() and
# should only be referenced for PyTorch file formats
_ext = os.path.splitext(self.current_file_path)[1].lower()
_is_pytorch_file = _ext in {".pt", ".pth"} or (
_ext == ".bin" and "pytorch" in ml_context.get("frameworks", {})
)
if _is_pytorch_file:
_reduce_msg = (
f"Found REDUCE opcode with non-allowlisted global: {associated_global}. "
f"This may indicate CVE-2025-32434 exploitation (RCE via torch.load)"
),
severity=severity,
location=f"{self.current_file_path} (pos {pos})",
details={
)
_reduce_details: dict[str, Any] = {
"position": pos,
"opcode": opcode.name,
"associated_global": associated_global,
Expand All @@ -3482,7 +3534,28 @@ def get_depth(x):
"overall_confidence",
0,
),
},
}
else:
_reduce_msg = (
f"Found REDUCE opcode with non-allowlisted global: {associated_global}"
)
_reduce_details = {
"position": pos,
"opcode": opcode.name,
"associated_global": associated_global,
"ml_context_confidence": ml_context.get(
"overall_confidence",
0,
),
}

result.add_check(
name="REDUCE Opcode Safety Check",
passed=False,
message=_reduce_msg,
severity=severity,
location=f"{self.current_file_path} (pos {pos})",
details=_reduce_details,
why=get_opcode_explanation("REDUCE"),
)

Expand Down