Skip to content

SensitiveDataFilter fails open on dict keys, kwarg names, bytes, and sets #48

Description

@higagan

The default filter inspects dict values but silently passes several other carriers. Reproduced against current main (0.3.7):

dict KEY   {"password": "x"}      -> EXECUTED (fail-open)   # JSON-native, model-reachable
kwarg NAME send(api_key="abc")    -> EXECUTED (fail-open)
kwarg NAME send(password="x")     -> EXECUTED (fail-open)
bytes      b"my password"         -> EXECUTED (fail-open)
set        {"my password"}        -> EXECUTED (fail-open)

dict VALUE {"k": "my password"}   -> BLOCKED               # the one path that works

Three distinct defects, in priority order:

1. Dict keys are never inspected — and this one is model-reachable

SensitiveDataFilter._check_recursive in src/modelfuzz/rules.py iterates .values(). Dicts are JSON-native, so this is reachable straight from model-supplied tool arguments with no help from your own code:

import json
payload = json.loads('{"password": "hunter2"}')   # straight off the wire
send(data=payload)                                 # executes

That's semantically the exact payload the README quickstart demonstrates blocking. This sub-case is why the issue is labelled security.

2. Keyword-argument names are never inspected either

src/modelfuzz/decorator.py iterates list(args) + list(kwargs.values()) — the keys of kwargs never reach the engine:

send(api_key="abc")   # executes
send(password="x")    # executes

Correction (see discussion below): do not fix this by feeding names through the filter.
A parameter legitimately named password / api_key / secret would then trip on every
call — def login(username, password) would be permanently unusable regardless of the value.
"This parameter is sensitive" belongs in the signature-binding work, where a policy can be
scoped per-parameter, not in a blanket keyword match on names. Out of scope here.

3. Unrecognized types fail open

bytes, bytearray, set, frozenset, objects and dataclasses all fall through to the implicit return None, which the engine reads as allowed. A guardrail whose default for "I don't understand this value" is allow has the wrong default. Reachability here is narrower than for dicts (LLM arguments arrive as JSON, so these only appear if your own code builds them) — a real fail-open default with developer-dependent reachability.

Note

This is partly disclosed today — the README Limitations section states "only str, list, tuple, and dict values are inspected." So it's documented behaviour, not a hidden claim. But the dict-key case (#1) is model-reachable and semantically identical to the quickstart's own blocked example, so it's worth closing rather than only documenting.

Proposal — scope

In scope (value-side; strictly widens inspection, breaks nothing):

  • Iterate .items() for dicts so keys are checked
  • Explicit branches for bytes / bytearray (decode, then check)
  • Traverse non-str iterables (set, frozenset) like list / tuple
  • Optionally: objects via model_dump() / dataclasses.asdict() / __dict__

Out of scope — deliberately deferred:

  • Pass keyword-argument names through from the decorator — see the correction above; it makes
    any tool with a password / api_key parameter permanently unusable.
  • A final else that fails closed — that branch is reached by every ordinary scalar
    (int, float, bool, None, date, Path, a callback function), so blanket fail-closed
    would block routine tool calls for no security gain. If revisited, it needs to separate scalars
    that cannot carry a string (pass) from opaque objects that might (configurable, e.g.
    SensitiveDataFilter(on_uninspectable="allow"|"block"), defaulting to allow).

Acceptance criteria

  • A parametrized test asserting dict keys, bytes, bytearray, set, frozenset are inspected
  • send(data={"api_key": "sk-12345"}) is blocked
  • An ordinary call passing int / float / bool / None still succeeds (regression guard)
  • The README Limitations section is updated to match what is now inspected

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingsecuritySecurity-relevant defect or hardening

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions