blitzstrike 1.0.59 → 1.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ All notable changes to Blitz Strike are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [1.0.62] — 2026-09-13
9
+
10
+ ### Added — route-confusion & dispatch-abuse detection
11
+
12
+ New vulnerability class distinct from source→sink taint (`src/route-confusion.ts`):
13
+ detects when the routing/dispatch layer lets an attacker reach an unintended
14
+ handler or control which handler runs.
15
+
16
+ - `dynamic_dispatch` — attacker-controlled callback/route (`call_user_func`,
17
+ `forward`, `dispatch`, `resolve_route`, `invoke`).
18
+ - `dynamic_method_call` — `$obj->$method()` / `$class::$method()`.
19
+ - `dynamic_include` — `include`/`require` of a variable (LFI via route).
20
+ - `batch_forwarding` — a loop over sub-requests that forwards to inner handlers
21
+ without a per-route authorization re-check.
22
+ - `route_normalization_gap` — route matching against user input without path
23
+ normalization (strtolower/rtrim/urldecode).
24
+
25
+ Wired into the `@source` agent; findings become `route` nodes in the security
26
+ graph. Added a WordPress-style batch-endpoint fixture to the enterprise
27
+ benchmark (`wp-batch-route`).
28
+
29
+ ## [1.0.60] — 2026-09-13
30
+
31
+ ### Added — deeper taint-flow coverage
32
+
33
+ - **Method-call resolution** (`calleeName`) — `$obj->method()` now resolves to
34
+ its method definition for interprocedural taint, not the object variable.
35
+ - **Built-in taint propagation** — `implode`/`join`/`sprintf`/`str_replace` and
36
+ other array→string/format built-ins now carry taint through to the sink.
37
+ - **Cast handling** — `(array)`/`(string)` casts propagate taint (a string can
38
+ still carry a payload); `(int)`/`(float)` casts act as an int_cast sanitizer.
39
+ - New corpus fixtures covering conditional-sanitizer bypass on array→SQL
40
+ interpolation (`php-int-cast-bypass-vuln`/`-fixed`).
41
+
8
42
  ## [1.0.59] — 2026-09-13
9
43
 
10
44
  ### Added — enterprise-grade detection + benchmark
@@ -0,0 +1,20 @@
1
+ - id: php-int-cast-bypass-fixed
2
+ - language: php
3
+ - vulnerable: false
4
+ - sink_type: sql_execution
5
+ ```php
6
+ <?php
7
+ class Query {
8
+ public function run($params) {
9
+ $where = "";
10
+ if ( ! empty( $params["ids"] ) ) {
11
+ $ids = implode( ",", array_map( "intval", (array) $params["ids"] ) );
12
+ $where .= " AND id NOT IN ($ids) ";
13
+ }
14
+ return $db->query("SELECT * FROM t WHERE 1=1 $where");
15
+ }
16
+ }
17
+ $q = $_GET;
18
+ $w = new Query();
19
+ $w->run($q);
20
+ ```
@@ -0,0 +1,23 @@
1
+ - id: php-int-cast-bypass-vuln
2
+ - language: php
3
+ - vulnerable: true
4
+ - sink_type: sql_execution
5
+ ```php
6
+ <?php
7
+ class Query {
8
+ public function run($params) {
9
+ $where = "";
10
+ if ( ! empty( $params["ids"] ) ) {
11
+ if ( is_array( $params["ids"] ) ) {
12
+ $params["ids"] = array_map( "intval", $params["ids"] );
13
+ }
14
+ $ids = implode( ",", (array) $params["ids"] );
15
+ $where .= " AND id NOT IN ($ids) ";
16
+ }
17
+ return $db->query("SELECT * FROM t WHERE 1=1 $where");
18
+ }
19
+ }
20
+ $q = $_GET;
21
+ $w = new Query();
22
+ $w->run($q);
23
+ ```
@@ -0,0 +1,43 @@
1
+ <?php
2
+ /**
3
+ * REST API batch endpoint (simplified) — forwards to inner handlers.
4
+ *
5
+ * The `/batch` route is registered WITHOUT a permission_callback, so it is
6
+ * reachable unauthenticated. A malformed nested request desynchronizes the
7
+ * batch router, letting an attacker reach handlers they should not.
8
+ */
9
+
10
+ add_action('rest_api_init', function () {
11
+ register_rest_route('my/v1', '/batch', array(
12
+ 'methods' => 'POST',
13
+ 'callback' => 'handle_batch',
14
+ 'permission_callback' => '__return_true', // <-- no real authz check
15
+ ));
16
+ });
17
+
18
+ function handle_batch($request) {
19
+ $operations = $request['requests'];
20
+ $responses = array();
21
+
22
+ foreach ($operations as $op) {
23
+ $route = $op['route'];
24
+ $handler = resolve_route($route);
25
+ $responses[] = $handler($op['params']);
26
+ }
27
+
28
+ return $responses;
29
+ }
30
+
31
+ function resolve_route($route) {
32
+ $registry = array(
33
+ 'users' => 'get_users',
34
+ 'posts' => 'get_posts',
35
+ 'options' => 'get_options',
36
+ );
37
+ return $registry[$route];
38
+ }
39
+
40
+ function get_users($params) {
41
+ global $wpdb;
42
+ return $wpdb->get_results("SELECT * FROM users WHERE id = " . $params['id']);
43
+ }
package/dist/index.js CHANGED
@@ -17104,6 +17104,15 @@ function rootVarName2(node) {
17104
17104
  return rootVarName2(node.what);
17105
17105
  return null;
17106
17106
  }
17107
+ function calleeName(what) {
17108
+ if (!isNode3(what))
17109
+ return null;
17110
+ if (what.kind === "name")
17111
+ return typeof what.name === "string" ? what.name : what.name?.name ?? null;
17112
+ if (what.kind === "propertylookup" || what.kind === "staticlookup")
17113
+ return what.offset?.name ?? what.offset?.value ?? null;
17114
+ return null;
17115
+ }
17107
17116
  function sourceOf2(node) {
17108
17117
  if (!isNode3(node))
17109
17118
  return null;
@@ -17268,6 +17277,14 @@ function analyzeExpr(node, env, summary, ctx, taintedParam, props, activeAuth, s
17268
17277
  const src = sourceOf2(node);
17269
17278
  if (src)
17270
17279
  return { tainted: true, sources: [src], sanitizers: [], sourceLine: node.loc?.start?.line };
17280
+ if (node.kind === "cast") {
17281
+ const inner = analyzeExpr(node.expr, env, summary, ctx, taintedParam, props, activeAuth, summaries, validating);
17282
+ const castType = String(node.type ?? "");
17283
+ if (/^(int|integer|bool|boolean|float|double)$/i.test(castType) && inner.tainted) {
17284
+ return { ...inner, sanitizers: [...inner.sanitizers, { id: "int_cast", label: "Numeric cast", neutralizes: ["sql_execution", "command_execution", "file_inclusion"] }] };
17285
+ }
17286
+ return inner;
17287
+ }
17271
17288
  if (node.kind === "assign") {
17272
17289
  const byref = node.operator === "=&";
17273
17290
  const rhs = analyzeExpr(node.right, env, summary, ctx, taintedParam, props, activeAuth, summaries, validating);
@@ -17315,8 +17332,11 @@ function analyzeExpr(node, env, summary, ctx, taintedParam, props, activeAuth, s
17315
17332
  analyzeExpr(a, env, summary, ctx, taintedParam, props, activeAuth, summaries, validating);
17316
17333
  return taintedArgs.length > 0 ? mergeTaint(...taintedArgs) : CLEAN;
17317
17334
  }
17318
- const fnName = rootVarName2(node.what);
17335
+ const fnName = calleeName(node.what);
17319
17336
  if (fnName) {
17337
+ if (TAINT_PROPAGATING_BUILTINS.has(fnName) && taintedArgs.length > 0) {
17338
+ return mergeTaint(...taintedArgs);
17339
+ }
17320
17340
  const san = findSanitizers(fnName + "(");
17321
17341
  if (san.length > 0) {
17322
17342
  if (taintedArgs.length > 0) {
@@ -17575,7 +17595,7 @@ function analyzeDataFlow2(path) {
17575
17595
  const seen = new Set;
17576
17596
  const mainCalls = topStmts.flatMap((s) => findKind2(s, "call"));
17577
17597
  for (const call of mainCalls) {
17578
- const fnName = rootVarName2(call.what);
17598
+ const fnName = calleeName(call.what);
17579
17599
  if (!fnName || sinkClassOf2(call))
17580
17600
  continue;
17581
17601
  const target = resolveFuncId(fnName, summaries);
@@ -17662,7 +17682,7 @@ function analyzeDataFlow2(path) {
17662
17682
  setCache("eagle2", cacheCode, result);
17663
17683
  return result;
17664
17684
  }
17665
- var import_php_parser3, parser3, CLEAN, VALIDATION_GATES;
17685
+ var import_php_parser3, parser3, TAINT_PROPAGATING_BUILTINS, CLEAN, VALIDATION_GATES;
17666
17686
  var init_eagle2 = __esm(() => {
17667
17687
  init_dataflow();
17668
17688
  init_scanner();
@@ -17673,6 +17693,33 @@ var init_eagle2 = __esm(() => {
17673
17693
  parser: { extractDoc: false, php7: true },
17674
17694
  ast: { withPositions: true, withSource: false }
17675
17695
  });
17696
+ TAINT_PROPAGATING_BUILTINS = new Set([
17697
+ "implode",
17698
+ "join",
17699
+ "sprintf",
17700
+ "vsprintf",
17701
+ "str_replace",
17702
+ "str_ireplace",
17703
+ "substr",
17704
+ "mb_substr",
17705
+ "substr_replace",
17706
+ "trim",
17707
+ "ltrim",
17708
+ "rtrim",
17709
+ "strtolower",
17710
+ "strtoupper",
17711
+ "ucfirst",
17712
+ "lcfirst",
17713
+ "urldecode",
17714
+ "rawurldecode",
17715
+ "base64_decode",
17716
+ "html_entity_decode",
17717
+ "stripslashes",
17718
+ "addslashes",
17719
+ "strval",
17720
+ "preg_quote",
17721
+ "nl2br"
17722
+ ]);
17676
17723
  CLEAN = { tainted: false, sources: [], sanitizers: [] };
17677
17724
  VALIDATION_GATES = ["in_array", "preg_match", "ctype_digit", "ctype_alnum", "is_numeric"];
17678
17725
  });
@@ -37002,6 +37049,151 @@ function reviewValidated(mission) {
37002
37049
  }
37003
37050
  var init_reviewer = () => {};
37004
37051
 
37052
+ // src/route-confusion.ts
37053
+ function lines(code) {
37054
+ return code.split(`
37055
+ `);
37056
+ }
37057
+ function lineNo(code, pos) {
37058
+ let n = 1;
37059
+ for (let i = 0;i < pos; i++)
37060
+ if (code.charCodeAt(i) === 10)
37061
+ n++;
37062
+ return n;
37063
+ }
37064
+ function userControlled(scope) {
37065
+ return SOURCE_TOKENS.test(scope);
37066
+ }
37067
+ function detectDynamicDispatch(code, file, out) {
37068
+ const ls = lines(code);
37069
+ DYNAMIC_DISPATCH_RE.lastIndex = 0;
37070
+ let m;
37071
+ while ((m = DYNAMIC_DISPATCH_RE.exec(code)) !== null) {
37072
+ const ln = lineNo(code, m.index);
37073
+ const l = ls[ln - 1] ?? "";
37074
+ const scope = ls.slice(Math.max(0, ln - 4), ln).join(`
37075
+ `);
37076
+ if (!userControlled(l) && !userControlled(scope))
37077
+ continue;
37078
+ out.push({
37079
+ file,
37080
+ line: ln,
37081
+ type: "dynamic_dispatch",
37082
+ category: "Route confusion (user-controlled dispatch)",
37083
+ severity: "high",
37084
+ evidence: l.trim(),
37085
+ detail: `\`${m[1]}()\` invoked with an attacker-controlled callback/route — the request selects the handler.`
37086
+ });
37087
+ }
37088
+ }
37089
+ function detectDynamicMethod(code, file, out) {
37090
+ const ls = lines(code);
37091
+ DYNAMIC_METHOD_RE.lastIndex = 0;
37092
+ let m;
37093
+ while ((m = DYNAMIC_METHOD_RE.exec(code)) !== null) {
37094
+ const ln = lineNo(code, m.index);
37095
+ const l = ls[ln - 1] ?? "";
37096
+ const scope = ls.slice(Math.max(0, ln - 4), ln).join(`
37097
+ `);
37098
+ if (!userControlled(l) && !userControlled(scope))
37099
+ continue;
37100
+ out.push({
37101
+ file,
37102
+ line: ln,
37103
+ type: "dynamic_method_call",
37104
+ category: "Route confusion (dynamic method dispatch)",
37105
+ severity: "high",
37106
+ evidence: l.trim(),
37107
+ detail: "Dynamic method/static call `" + m[1] + "$var()` — the invoked symbol is attacker-influenced."
37108
+ });
37109
+ }
37110
+ }
37111
+ function detectDynamicInclude(code, file, out) {
37112
+ const ls = lines(code);
37113
+ const re = /\b(include|require)(_once)?\s*\(\s*(\$[A-Za-z_]\w*)/g;
37114
+ let m;
37115
+ while ((m = re.exec(code)) !== null) {
37116
+ const ln = lineNo(code, m.index);
37117
+ const l = ls[ln - 1] ?? "";
37118
+ const scope = ls.slice(Math.max(0, ln - 4), ln).join(`
37119
+ `);
37120
+ if (!userControlled(l) && !userControlled(scope))
37121
+ continue;
37122
+ out.push({
37123
+ file,
37124
+ line: ln,
37125
+ type: "dynamic_include",
37126
+ category: "Route confusion (dynamic file include)",
37127
+ severity: "high",
37128
+ evidence: l.trim(),
37129
+ detail: `\`${m[1]}(…)\` of a variable — potential LFI/RCE when the path is attacker-controlled.`
37130
+ });
37131
+ }
37132
+ }
37133
+ function detectBatchForwarding(code, file, out) {
37134
+ BATCH_LOOP_RE.lastIndex = 0;
37135
+ let m;
37136
+ while ((m = BATCH_LOOP_RE.exec(code)) !== null) {
37137
+ const ln = lineNo(code, m.index);
37138
+ const body = lines(code).slice(ln, ln + 8).join(`
37139
+ `);
37140
+ const forwards = /\b(call_user_func|forward|dispatch|resolve_route|invoke|handle|route)\s*\(/i.test(body);
37141
+ if (!forwards)
37142
+ continue;
37143
+ const rechecks = /\b(current_user_can|permission_callback|check_ajax_referer|authorize|authorise|is_user_logged_in)\b/i.test(body);
37144
+ out.push({
37145
+ file,
37146
+ line: ln,
37147
+ type: "batch_forwarding",
37148
+ category: "Route confusion (batch/proxy forwarding)",
37149
+ severity: rechecks ? "medium" : "high",
37150
+ evidence: lines(code)[ln - 1]?.trim() ?? "",
37151
+ detail: rechecks ? "Batch/proxy loop forwards to inner handlers — auth is re-checked in scope, but verify it applies per-route." : "Batch/proxy loop forwards to inner handlers with no per-route authorization re-check — an attacker may reach a protected handler."
37152
+ });
37153
+ }
37154
+ }
37155
+ function detectRouteNormalization(code, file, out) {
37156
+ const ls = lines(code);
37157
+ for (let i = 0;i < ls.length; i++) {
37158
+ const l = ls[i];
37159
+ if (!ROUTE_MATCH_RE.test(l))
37160
+ continue;
37161
+ const scope = ls.slice(Math.max(0, i - 3), i + 1).join(`
37162
+ `);
37163
+ if (!userControlled(scope))
37164
+ continue;
37165
+ if (NORMALIZERS.test(l) || NORMALIZERS.test(scope))
37166
+ continue;
37167
+ out.push({
37168
+ file,
37169
+ line: i + 1,
37170
+ type: "route_normalization_gap",
37171
+ category: "Route confusion (missing path normalization)",
37172
+ severity: "medium",
37173
+ evidence: l.trim(),
37174
+ detail: "Route matched against user input without visible normalization (strtolower/rtrim/urldecode) — route desync possible."
37175
+ });
37176
+ }
37177
+ }
37178
+ function detectRouteConfusion(code, file) {
37179
+ const out = [];
37180
+ detectDynamicDispatch(code, file, out);
37181
+ detectDynamicMethod(code, file, out);
37182
+ detectDynamicInclude(code, file, out);
37183
+ detectBatchForwarding(code, file, out);
37184
+ detectRouteNormalization(code, file, out);
37185
+ return out;
37186
+ }
37187
+ var SOURCE_TOKENS, DYNAMIC_DISPATCH_RE, DYNAMIC_METHOD_RE, BATCH_LOOP_RE, ROUTE_MATCH_RE, NORMALIZERS;
37188
+ var init_route_confusion = __esm(() => {
37189
+ SOURCE_TOKENS = /\$_(GET|POST|REQUEST|COOKIE|FILES)\b|\$request->|\$req->|request\.(get|input|query|post)|getParameter\(|req\.(query|body|params)/;
37190
+ DYNAMIC_DISPATCH_RE = /\b(call_user_func|call_user_func_array|forward|dispatch|resolve_route|invoke)\s*\(\s*(\$|\[)/gi;
37191
+ DYNAMIC_METHOD_RE = /(->|::)\s*\$[A-Za-z_]/g;
37192
+ BATCH_LOOP_RE = /\bforeach\s*\([^)]*(requests|batch|sub_requests|operations)\b/gi;
37193
+ ROUTE_MATCH_RE = /\b(preg_match|preg_match_all|strpos|stripos|str_contains)\s*\(/i;
37194
+ NORMALIZERS = /\b(strtolower|strtoupper|rtrim|urldecode|rawurldecode|parse_url|normalize|canonical)\b/i;
37195
+ });
37196
+
37005
37197
  // src/agents.ts
37006
37198
  import { readFileSync as readFileSync18 } from "node:fs";
37007
37199
  function emptyGraph() {
@@ -37025,6 +37217,7 @@ var init_agents = __esm(() => {
37025
37217
  init_scanner();
37026
37218
  init_eagle2();
37027
37219
  init_universal_taint();
37220
+ init_route_confusion();
37028
37221
  init_adapters();
37029
37222
  init_active();
37030
37223
  init_strike();
@@ -37109,6 +37302,29 @@ var init_agents = __esm(() => {
37109
37302
  graph = addEdge2(graph, tid, sinkId, "flows_to");
37110
37303
  }
37111
37304
  }
37305
+ for (const f of files.filter((x) => /\.php$/i.test(x))) {
37306
+ let code;
37307
+ try {
37308
+ code = readFileSync18(f, "utf8");
37309
+ } catch {
37310
+ continue;
37311
+ }
37312
+ for (const rc of detectRouteConfusion(code, f).slice(0, 50)) {
37313
+ const hyp = makeHypothesis({
37314
+ type: rc.category,
37315
+ target: `${f}:${rc.line}`,
37316
+ evidence: rc.detail,
37317
+ confidence: rc.severity === "high" ? 0.7 : 0.5,
37318
+ required_validation: "confirm reachability of the dispatch/route with attacker-controlled input",
37319
+ potential_impact: "route confusion / authorization bypass"
37320
+ });
37321
+ hypotheses.push(hyp);
37322
+ findings += 1;
37323
+ const nodeId = `route:${f}:${rc.line}:${rc.type}`;
37324
+ graph = upsertNode(graph, nodeId, "route", { file: f, line: rc.line, type: rc.type, hypothesis: hyp.id });
37325
+ graph = addEdge2(graph, tid, nodeId, "routes_to");
37326
+ }
37327
+ }
37112
37328
  return { ok: true, output: { files: files.length, findings }, hypotheses, graph };
37113
37329
  }
37114
37330
  };
@@ -38044,6 +38260,13 @@ var init_enterprise_benchmark = __esm(() => {
38044
38260
  expected_vulns: 2,
38045
38261
  expected_sinks: ["sql_execution", "command_execution"],
38046
38262
  safe_tokens: ["prepareStatement", "PreparedStatement"]
38263
+ },
38264
+ {
38265
+ id: "wp-batch-route",
38266
+ dir: join17(ROOT10, "wp-batch-route"),
38267
+ expected_vulns: 1,
38268
+ expected_sinks: ["Route confusion"],
38269
+ safe_tokens: []
38047
38270
  }
38048
38271
  ];
38049
38272
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blitzstrike",
3
- "version": "1.0.59",
3
+ "version": "1.0.62",
4
4
  "description": "Blitz Strike — a universal MCP security-audit toolbelt. BLITZ sweeps the attack surface, EAGLE-EYE traces source-to-sink, STRIKE verifies live. 57 attack chains, 130-tool catalog, intelligence data layer. One server, every agent.",
5
5
  "type": "module",
6
6
  "bin": {