From a8d196b7aec5d733462ca74eac2bdd14ae5adf02 Mon Sep 17 00:00:00 2001 From: AutonomyProof Date: Tue, 18 Aug 2026 16:57:19 +0530 Subject: [PATCH] Add AG040: insecure model-output handling (0.19.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First source->sink flow rule: flags an LLM call's output flowing into a code or shell execution sink (OWASP LLM02, 'insecure output handling' / 'the agent ran the code the model generated'). Source: invoke/ainvoke/predict/generate/complete (LangChain-style) or a completions/messages .create (OpenAI/Anthropic). Sink: eval/exec/compile/os.system/ os.popen/subprocess.*. Connected inline, via a single-function variable, or through .content / .choices[...].message.content accessors. Uses the engine's existing resolve_local_value source tracking; intentionally shallow (single function). Zero-FP scoping: parameters, constants, non-model calls (requests.get().text), plain function results, non-completions .create (Customer.create/Payout.create), list args, and deep alias chains all stay silent. MITRE T1059. Verification: - Ground-truth corpus: 156 cases, precision 1.000 / recall 1.000. - Real-repo benchmark: 0 findings across 41 repos — zero false positives. - 616 tests, 100% branch coverage, ruff + ruff format + mypy all clean. Bumps 0.18.0 -> 0.19.0. Signed-off-by: AutonomyProof --- CHANGELOG.md | 11 ++ README.md | 5 +- benchmark/CORPUS_RESULTS.md | 5 +- benchmark/corpus.yaml | 8 ++ benchmark/results.json | 193 +++++++++++++-------------- pyproject.toml | 2 +- src/autonomyproof/__init__.py | 2 +- src/autonomyproof/rules/execution.py | 95 +++++++++++++ src/autonomyproof/rules/registry.py | 2 + tests/test_cli.py | 2 +- tests/test_rules_execution.py | 98 ++++++++++++++ tests/test_scanner.py | 2 +- 12 files changed, 314 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c99709..310e0c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.19.0] + +### Added +- **AG040 — Model output executed as code or command (insecure output handling, OWASP LLM02).** + Flags the output of an LLM call (`invoke`/`predict`/`generate`/`complete`, or + `completions`/`messages`.`create`) flowing into a code or shell execution sink + (`eval`/`exec`/`compile`/`os.system`/`os.popen`/`subprocess.*`) — inline, via a + single-function variable, or through `.content` / `.choices[...].message.content` + accessors. This is the first rule to use the engine's source tracking to connect a + model-output *source* to a dangerous *sink*. MITRE ATT&CK T1059. + ## [0.18.0] ### Added diff --git a/README.md b/README.md index 5f6217c..13c12b7 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ destruction (`terminate_instances`, `delete_bucket`, `delete_cluster`, k8s teard money movement without approval (`Refund`/`Payout`/`Transfer.create`), persistence/backdoor writes (SSH `authorized_keys`, `crontab`, `sudoers`), runtime package installs (`pip install`), IAM/privilege escalation (`create_access_key`, `attach_role_policy`), world-writable `chmod`, +insecure output handling (LLM output flowing into `eval`/`exec`/a shell), known-vulnerable framework dependencies (version-validated CVEs), and more. Run `autonomyproof rules list` for the full catalogue and `autonomyproof rules explain AG001` for details. Every finding carries **OWASP Agentic, NIST AI RMF, ISO 42001, MITRE @@ -142,7 +143,7 @@ re-run `autonomyproof baseline .` and commit the updated file in the same PR. Use the action directly: ```yaml -- uses: autonomyproof/autonomyproof-cli@v0.18.0 +- uses: autonomyproof/autonomyproof-cli@v0.19.0 with: target: . fail-on: high @@ -169,7 +170,7 @@ Gate locally before a commit ever leaves your machine: # .pre-commit-config.yaml repos: - repo: https://github.com/autonomyproof/autonomyproof-cli - rev: v0.18.0 + rev: v0.19.0 hooks: - id: autonomyproof ``` diff --git a/benchmark/CORPUS_RESULTS.md b/benchmark/CORPUS_RESULTS.md index 620d7fb..a11c2c0 100644 --- a/benchmark/CORPUS_RESULTS.md +++ b/benchmark/CORPUS_RESULTS.md @@ -1,6 +1,6 @@ # Labeled-corpus results (ground-truth precision & recall) -**Cases:** 150 · **Rules covered:** 33 · **Overall precision:** 1.000 · **Overall recall:** 1.000 +**Cases:** 156 · **Rules covered:** 34 · **Overall precision:** 1.000 · **Overall recall:** 1.000 | Rule | pos | neg | TP | FP | FN | Precision | Recall | F1 | |---|--:|--:|--:|--:|--:|--:|--:|--:| @@ -37,8 +37,9 @@ | AG037 | 1 | 2 | 1 | 0 | 0 | 1.00 | 1.00 | 1.00 | | AG038 | 2 | 2 | 2 | 0 | 0 | 1.00 | 1.00 | 1.00 | | AG039 | 1 | 2 | 1 | 0 | 0 | 1.00 | 1.00 | 1.00 | +| AG040 | 3 | 3 | 3 | 0 | 0 | 1.00 | 1.00 | 1.00 | -**Totals:** TP 78 · FP 0 · FN 0 · TN 72 +**Totals:** TP 81 · FP 0 · FN 0 · TN 75 Precision = of the cases where a rule fired, how many were true positives. Recall = of the cases where a rule should fire, how many did. Reproduce with `python benchmark/corpus_eval.py`. diff --git a/benchmark/corpus.yaml b/benchmark/corpus.yaml index 2a2536e..ca76687 100644 --- a/benchmark/corpus.yaml +++ b/benchmark/corpus.yaml @@ -222,6 +222,14 @@ cases: - {id: ag039-neg-safe, rule: AG039, label: negative, code: "import os\n@tool\ndef fix(p):\n os.chmod(p, 0o644)\n"} - {id: ag039-neg-dynamic, rule: AG039, label: negative, code: "import os\n@tool\ndef fix(p, mode):\n os.chmod(p, mode)\n"} + # --- AG040 insecure model output executed as code/command --- + - {id: ag040-pos-eval, rule: AG040, label: positive, code: "def run(p):\n return eval(llm.invoke(p))\n"} + - {id: ag040-pos-var, rule: AG040, label: positive, code: "def run(p):\n code = llm.predict(p)\n exec(code)\n"} + - {id: ag040-pos-ossystem, rule: AG040, label: positive, code: "import os\ndef run(p):\n os.system(agent.generate(p))\n"} + - {id: ag040-neg-userinput, rule: AG040, label: negative, code: "def run(user_input):\n exec(user_input)\n"} + - {id: ag040-neg-nonmodel, rule: AG040, label: negative, code: "def run(u):\n data = requests.get(u).text\n exec(data)\n"} + - {id: ag040-neg-constant, rule: AG040, label: negative, code: "exec('print(1)')\n"} + # --- AG021 broadened deserialization sinks --- - {id: ag021-pos-joblib, rule: AG021, label: positive, code: "import joblib\njoblib.load(f)\n"} - {id: ag021-pos-pandas, rule: AG021, label: positive, code: "import pandas\npandas.read_pickle(f)\n"} diff --git a/benchmark/results.json b/benchmark/results.json index cf8411a..3a29689 100644 --- a/benchmark/results.json +++ b/benchmark/results.json @@ -1,32 +1,32 @@ { "repo_count": 41, - "total_files": 35318, - "total_findings": 9367, + "total_files": 34817, + "total_findings": 8942, "by_rule": { - "AG001": 38, - "AG002": 174, - "AG003": 3412, + "AG001": 37, + "AG002": 168, + "AG003": 3334, "AG004": 58, - "AG005": 1005, - "AG006": 76, - "AG007": 55, - "AG009": 577, - "AG011": 73, - "AG012": 821, + "AG005": 918, + "AG006": 61, + "AG007": 56, + "AG009": 589, + "AG011": 70, + "AG012": 663, "AG013": 179, "AG014": 11, - "AG015": 276, - "AG016": 42, - "AG017": 226, - "AG018": 1532, - "AG019": 404, + "AG015": 279, + "AG016": 41, + "AG017": 143, + "AG018": 1511, + "AG019": 420, "AG020": 41, - "AG021": 61, - "AG022": 30, + "AG021": 58, + "AG022": 33, "AG023": 112, - "AG024": 12, + "AG024": 10, "AG025": 135, - "AG026": 5, + "AG026": 3, "AG028": 4, "AG029": 1, "AG031": 5, @@ -36,11 +36,11 @@ { "repo": "mcp-python-sdk", "status": "ok", - "files_scanned": 823, - "findings": 50, + "files_scanned": 832, + "findings": 52, "by_rule": { "AG001": 1, - "AG003": 6, + "AG003": 8, "AG007": 3, "AG009": 5, "AG013": 23, @@ -53,13 +53,13 @@ { "repo": "crewai", "status": "ok", - "files_scanned": 1300, - "findings": 360, + "files_scanned": 1303, + "findings": 359, "by_rule": { "AG002": 5, "AG003": 164, "AG004": 8, - "AG005": 32, + "AG005": 31, "AG007": 4, "AG009": 10, "AG012": 5, @@ -97,21 +97,21 @@ { "repo": "openai-agents", "status": "ok", - "files_scanned": 896, - "findings": 346, + "files_scanned": 907, + "findings": 396, "by_rule": { "AG002": 11, - "AG003": 31, + "AG003": 34, "AG004": 4, "AG005": 9, "AG007": 2, - "AG009": 45, - "AG012": 9, + "AG009": 48, + "AG012": 14, "AG013": 9, "AG015": 6, - "AG016": 11, + "AG016": 12, "AG017": 2, - "AG018": 44, + "AG018": 82, "AG019": 36, "AG020": 1, "AG022": 6, @@ -121,8 +121,8 @@ { "repo": "pydantic-ai", "status": "ok", - "files_scanned": 678, - "findings": 111, + "files_scanned": 684, + "findings": 113, "by_rule": { "AG002": 8, "AG003": 18, @@ -135,7 +135,7 @@ "AG015": 5, "AG016": 1, "AG017": 2, - "AG018": 15, + "AG018": 17, "AG020": 1, "AG021": 5 } @@ -235,7 +235,7 @@ { "repo": "haystack", "status": "ok", - "files_scanned": 556, + "files_scanned": 558, "findings": 120, "by_rule": { "AG002": 1, @@ -253,22 +253,22 @@ { "repo": "litellm", "status": "ok", - "files_scanned": 5193, - "findings": 934, + "files_scanned": 5242, + "findings": 961, "by_rule": { "AG001": 1, "AG002": 3, - "AG003": 404, + "AG003": 406, "AG004": 5, "AG005": 84, "AG006": 26, - "AG009": 101, + "AG009": 105, "AG012": 5, "AG014": 1, "AG015": 13, "AG017": 83, - "AG018": 184, - "AG019": 18, + "AG018": 202, + "AG019": 21, "AG020": 1, "AG022": 2, "AG023": 2, @@ -293,42 +293,27 @@ { "repo": "letta", "status": "ok", - "files_scanned": 878, - "findings": 708, + "files_scanned": 0, + "findings": 1, "by_rule": { - "AG001": 1, - "AG002": 13, - "AG003": 86, - "AG005": 92, - "AG006": 16, - "AG009": 10, - "AG012": 299, - "AG013": 1, - "AG016": 3, - "AG017": 85, - "AG018": 93, - "AG019": 2, - "AG020": 1, - "AG021": 3, - "AG024": 1, - "AG026": 2 + "AG020": 1 } }, { "repo": "livekit-agents", "status": "ok", - "files_scanned": 935, - "findings": 139, + "files_scanned": 943, + "findings": 141, "by_rule": { "AG002": 2, "AG003": 19, "AG005": 2, - "AG007": 5, + "AG007": 6, "AG009": 72, "AG012": 4, "AG013": 8, "AG015": 13, - "AG016": 1, + "AG016": 2, "AG018": 8, "AG020": 1, "AG021": 2, @@ -413,27 +398,27 @@ { "repo": "langflow", "status": "ok", - "files_scanned": 3583, - "findings": 826, + "files_scanned": 3818, + "findings": 1003, "by_rule": { - "AG002": 24, - "AG003": 92, + "AG002": 31, + "AG003": 93, "AG004": 10, - "AG005": 257, - "AG006": 8, + "AG005": 260, + "AG006": 9, "AG007": 2, - "AG009": 36, - "AG011": 11, - "AG012": 65, + "AG009": 43, + "AG011": 8, + "AG012": 201, "AG013": 3, - "AG015": 14, - "AG017": 3, - "AG018": 218, - "AG019": 62, + "AG015": 17, + "AG017": 5, + "AG018": 224, + "AG019": 77, "AG020": 1, "AG021": 8, "AG022": 5, - "AG024": 2, + "AG024": 1, "AG028": 4, "AG029": 1 } @@ -441,7 +426,7 @@ { "repo": "superagent", "status": "ok", - "files_scanned": 30, + "files_scanned": 32, "findings": 1, "by_rule": { "AG020": 1 @@ -451,7 +436,7 @@ "repo": "dspy", "status": "ok", "files_scanned": 279, - "findings": 111, + "findings": 110, "by_rule": { "AG002": 1, "AG003": 67, @@ -459,7 +444,7 @@ "AG009": 1, "AG013": 5, "AG015": 4, - "AG018": 18, + "AG018": 17, "AG020": 1, "AG021": 2, "AG022": 2 @@ -489,10 +474,10 @@ { "repo": "mem0", "status": "ok", - "files_scanned": 367, - "findings": 187, + "files_scanned": 370, + "findings": 188, "by_rule": { - "AG003": 84, + "AG003": 85, "AG004": 1, "AG005": 20, "AG011": 8, @@ -505,7 +490,7 @@ { "repo": "marvin", "status": "ok", - "files_scanned": 180, + "files_scanned": 184, "findings": 62, "by_rule": { "AG001": 1, @@ -539,16 +524,16 @@ { "repo": "adk-python", "status": "ok", - "files_scanned": 1772, - "findings": 351, + "files_scanned": 1782, + "findings": 355, "by_rule": { "AG002": 4, - "AG003": 195, + "AG003": 192, "AG004": 9, "AG005": 29, "AG006": 2, "AG007": 5, - "AG009": 15, + "AG009": 20, "AG012": 1, "AG013": 12, "AG015": 10, @@ -557,6 +542,7 @@ "AG019": 7, "AG020": 1, "AG021": 9, + "AG022": 2, "AG023": 4, "AG025": 1, "AG032": 1 @@ -587,7 +573,7 @@ { "repo": "swarms", "status": "ok", - "files_scanned": 965, + "files_scanned": 969, "findings": 300, "by_rule": { "AG001": 1, @@ -659,7 +645,7 @@ { "repo": "anthropic-cookbook", "status": "ok", - "files_scanned": 117, + "files_scanned": 121, "findings": 76, "by_rule": { "AG003": 44, @@ -678,14 +664,14 @@ { "repo": "pipecat", "status": "ok", - "files_scanned": 1195, - "findings": 146, + "files_scanned": 1208, + "findings": 147, "by_rule": { "AG003": 45, "AG004": 3, "AG005": 2, "AG009": 56, - "AG013": 8, + "AG013": 9, "AG015": 10, "AG017": 4, "AG018": 12, @@ -718,16 +704,17 @@ { "repo": "open-interpreter", "status": "ok", - "files_scanned": 137, - "findings": 98, + "files_scanned": 150, + "findings": 116, "by_rule": { "AG002": 2, - "AG003": 31, - "AG005": 8, - "AG009": 7, + "AG003": 33, + "AG005": 11, + "AG009": 10, "AG015": 1, - "AG018": 48, - "AG020": 1 + "AG018": 57, + "AG020": 1, + "AG022": 1 } }, { @@ -769,7 +756,7 @@ { "repo": "agentscope", "status": "ok", - "files_scanned": 582, + "files_scanned": 583, "findings": 250, "by_rule": { "AG003": 143, diff --git a/pyproject.toml b/pyproject.toml index 4080844..9432311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autonomyproof" -version = "0.18.0" +version = "0.19.0" description = "Open-source local scanner that finds unsafe capabilities and missing guardrails in Python AI-agent code." readme = "README.md" requires-python = ">=3.11" diff --git a/src/autonomyproof/__init__.py b/src/autonomyproof/__init__.py index 1710560..4190de4 100644 --- a/src/autonomyproof/__init__.py +++ b/src/autonomyproof/__init__.py @@ -4,4 +4,4 @@ __all__ = ["__version__"] -__version__ = "0.18.0" +__version__ = "0.19.0" diff --git a/src/autonomyproof/rules/execution.py b/src/autonomyproof/rules/execution.py index 4668193..c85c1aa 100644 --- a/src/autonomyproof/rules/execution.py +++ b/src/autonomyproof/rules/execution.py @@ -18,6 +18,29 @@ } _OS_SHELL = {"os.system", "os.popen"} +# AG040 — sinks that execute their argument as code or a shell command. +_CODE_EXEC_SINKS = {"eval", "exec", "compile", "os.system", "os.popen"} +_SHELL_EXEC_SINKS = { + "subprocess.run", + "subprocess.Popen", + "subprocess.call", + "subprocess.check_call", + "subprocess.check_output", +} +# High-precision method names for an LLM/model call. Deliberately excludes overloaded verbs +# like run/call/create-in-general; `create` only counts on a completions/messages receiver. +_MODEL_METHODS = { + "invoke", + "ainvoke", + "predict", + "apredict", + "predict_messages", + "generate", + "agenerate", + "complete", + "acomplete", +} + _DESTRUCTIVE_MARKERS = [ "rm -rf", "git push --force", @@ -125,3 +148,75 @@ def check(self, ctx: RuleContext) -> Iterable[Finding]: pattern=f"{self.id}:{marker}", ) break + + +def _terminal(node: ast.expr) -> ast.expr: + """Peel attribute/subscript/await accessors to the underlying expression. + + ``llm.invoke(x).content`` -> the ``llm.invoke(x)`` call; + ``resp.choices[0].message.content`` -> the ``resp`` name. + """ + while isinstance(node, ast.Attribute | ast.Subscript | ast.Await): + node = node.value + return node + + +def _is_model_call(node: ast.expr) -> bool: + """True if ``node`` is a call that returns LLM/model output.""" + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + return False + if node.func.attr in _MODEL_METHODS: + return True + if node.func.attr in {"create", "acreate"}: + recv = node.func.value + return isinstance(recv, ast.Attribute) and recv.attr in {"completions", "messages"} + return False + + +class InsecureModelOutputRule(Rule): + """AG040 — Model output executed as code or a shell command.""" + + id = "AG040" + name = "Model output executed as code or command" + default_severity = Severity.CRITICAL + description = "The output of an LLM call flows into a code or shell execution sink." + risk = ( + "If an attacker steers the model (e.g. via prompt injection), model-generated text " + "becomes executed code or shell commands — remote code execution." + ) + remediation = [ + "Never pass model output to eval/exec/compile or a shell", + "Validate model output against a strict schema or allowlist before use", + "Prefer structured tool-calling over executing generated code", + "If code execution is required, run it in an isolated, no-network sandbox", + ] + mappings = Mappings( + owaspAgentic=["Tool misuse", "Excessive agency"], + nistAiRmf=["Measure", "Manage"], + iso42001Alignment=["Operational control", "Accountability"], + mitre=["T1059"], # Command and Scripting Interpreter + ) + + def check(self, ctx: RuleContext) -> Iterable[Finding]: + for call in ctx.analysis.calls: + name = ctx.analysis.resolve_call(call) + if name not in _CODE_EXEC_SINKS and name not in _SHELL_EXEC_SINKS: + continue + if not call.args: + continue + if self._from_model(ctx, call.args[0], call): + yield self.make_finding( + ctx, call, evidence=f"Model output flows into {name}() and is executed" + ) + + def _from_model( + self, ctx: RuleContext, node: ast.expr, origin: ast.AST, depth: int = 0 + ) -> bool: + base = _terminal(node) + if _is_model_call(base): + return True + if isinstance(base, ast.Name) and depth < 4: + assigned = ctx.analysis.resolve_local_value(base.id, origin) + if assigned is not None: + return self._from_model(ctx, assigned, origin, depth + 1) + return False diff --git a/src/autonomyproof/rules/registry.py b/src/autonomyproof/rules/registry.py index 920b8b2..cdecfa4 100644 --- a/src/autonomyproof/rules/registry.py +++ b/src/autonomyproof/rules/registry.py @@ -25,6 +25,7 @@ from autonomyproof.rules.execution import ( DestructiveCommandRule, DynamicCodeExecutionRule, + InsecureModelOutputRule, ShellExecutionRule, ) from autonomyproof.rules.filesystem import CredentialPathAccessRule, FilesystemAccessRule @@ -96,6 +97,7 @@ RuntimePackageInstallRule, # AG037 IamPrivilegeEscalationRule, # AG038 WorldWritablePermissionRule, # AG039 + InsecureModelOutputRule, # AG040 ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 193e8b0..b585a7d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,7 +33,7 @@ def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def test_version(runner: CliRunner) -> None: result = runner.invoke(cli.main, ["--version"]) assert result.exit_code == 0 - assert "0.18.0" in result.output + assert "0.19.0" in result.output def test_init_creates_and_is_idempotent(runner: CliRunner) -> None: diff --git a/tests/test_rules_execution.py b/tests/test_rules_execution.py index f8afa8e..38a670a 100644 --- a/tests/test_rules_execution.py +++ b/tests/test_rules_execution.py @@ -6,6 +6,7 @@ from autonomyproof.rules.execution import ( DestructiveCommandRule, DynamicCodeExecutionRule, + InsecureModelOutputRule, ShellExecutionRule, ) from helpers import run_rule @@ -56,3 +57,100 @@ def test_ag019_only_one_finding_per_string() -> None: def test_ag019_ignores_non_string_and_clean_string() -> None: assert run_rule(DestructiveCommandRule(), "x = 5\ny = 'hello world'\n") == [] + + +# --- AG040 (insecure model output → exec) ------------------------------------ +def test_ag040_inline_eval_of_model_output() -> None: + code = "@tool\ndef run(p):\n return eval(llm.invoke(p))\n" + findings = run_rule(InsecureModelOutputRule(), code) + assert findings and findings[0].ruleId == "AG040" + + +def test_ag040_variable_exec() -> None: + code = "def run(p):\n code = llm.predict(p)\n exec(code)\n" + assert run_rule(InsecureModelOutputRule(), code) + + +def test_ag040_content_accessor() -> None: + code = "def run(p):\n exec(llm.invoke(p).content)\n" + assert run_rule(InsecureModelOutputRule(), code) + + +def test_ag040_openai_choices_chain() -> None: + code = ( + "def run(p):\n" + " resp = client.chat.completions.create(model='x', messages=p)\n" + " exec(resp.choices[0].message.content)\n" + ) + assert run_rule(InsecureModelOutputRule(), code) + + +def test_ag040_os_system_of_model_output() -> None: + code = "import os\ndef run(p):\n os.system(agent.generate(p))\n" + assert run_rule(InsecureModelOutputRule(), code) + + +def test_ag040_subprocess_of_model_output() -> None: + code = "import subprocess\ndef run(p):\n subprocess.run(llm.ainvoke(p))\n" + assert run_rule(InsecureModelOutputRule(), code) + + +def test_ag040_await_model_call() -> None: + code = "async def run(p):\n exec(await llm.ainvoke(p))\n" + assert run_rule(InsecureModelOutputRule(), code) + + +def test_ag040_user_input_not_model_clean() -> None: + # exec of a parameter (not model output) is AG002's job, not AG040. + assert run_rule(InsecureModelOutputRule(), "def run(user_input):\n exec(user_input)\n") == [] + + +def test_ag040_constant_clean() -> None: + assert run_rule(InsecureModelOutputRule(), "exec('print(1)')\n") == [] + + +def test_ag040_expression_clean() -> None: + assert run_rule(InsecureModelOutputRule(), "exec('a' + 'b')\n") == [] + + +def test_ag040_non_model_call_clean() -> None: + code = "def run(u):\n data = requests.get(u).text\n exec(data)\n" + assert run_rule(InsecureModelOutputRule(), code) == [] + + +def test_ag040_plain_function_result_clean() -> None: + code = "def run(p):\n x = compute(p)\n exec(x)\n" + assert run_rule(InsecureModelOutputRule(), code) == [] + + +def test_ag040_create_non_llm_receiver_clean() -> None: + # `.create` only counts on a completions/messages receiver, not e.g. Customer.create. + code = "def run(e):\n exec(stripe.Customer.create(email=e))\n" + assert run_rule(InsecureModelOutputRule(), code) == [] + + +def test_ag040_create_name_receiver_clean() -> None: + code = "def run():\n exec(Payout.create())\n" + assert run_rule(InsecureModelOutputRule(), code) == [] + + +def test_ag040_subprocess_list_clean() -> None: + code = "import subprocess\ndef run():\n subprocess.run(['ls', '-la'])\n" + assert run_rule(InsecureModelOutputRule(), code) == [] + + +def test_ag040_no_args_clean() -> None: + assert run_rule(InsecureModelOutputRule(), "def run():\n eval()\n") == [] + + +def test_ag040_not_a_sink_clean() -> None: + assert run_rule(InsecureModelOutputRule(), "def run(p):\n print(llm.invoke(p))\n") == [] + + +def test_ag040_deep_alias_chain_not_tracked_clean() -> None: + # Single-function tracking is intentionally shallow; a 5-deep alias chain is not followed. + code = ( + "def run(p):\n a = llm.invoke(p)\n b = a\n c = b\n" + " d = c\n e = d\n exec(e)\n" + ) + assert run_rule(InsecureModelOutputRule(), code) == [] diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 29aeba8..000e3d4 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -30,7 +30,7 @@ def test_scan_produces_findings_and_metadata(tmp_path: Path) -> None: assert result.score < 100 assert result.risk_level assert result.files_scanned == 1 - assert len(result.rules_executed) == 39 + assert len(result.rules_executed) == 40 assert any(c.name == "Shell execution" for c in result.capabilities)