| 1 |
<?php |
| 2 |
/** |
| 3 |
* Snippet Lint — production gate that prevents agent + human authors from |
| 4 |
* embedding targeting/routing checks inside snippet code instead of using |
| 5 |
* the structured `conditions[]` field. |
| 6 |
* |
| 7 |
* Why this exists: |
| 8 |
* When the agent slips up — or a schema rejection elsewhere makes it think |
| 9 |
* `conditions[]` "doesn't work" — it falls back to rewriting the snippet |
| 10 |
* body with `if ( ! is_home() ) return;` or `if ( get_the_ID() !== 2805 )` |
| 11 |
* wrappers. Once that pattern lands in HEAD, it survives migrations, leaks |
| 12 |
* into versions, and is invisible at the manifest layer (executor sees the |
| 13 |
* snippet as "global" and runs it everywhere; the body silently filters). |
| 14 |
* We block it at write time so it never reaches disk. |
| 15 |
* |
| 16 |
* This is the production version of what was previously enforced only via |
| 17 |
* tool-description nudges. Author intent: "if a snippet wants targeting, |
| 18 |
* it MUST come through `conditions[]` — code is for what to do, not where." |
| 19 |
* |
| 20 |
* Filterable: |
| 21 |
* - `zip_ai_snippets_lint_enabled` (bool) — kill switch. |
| 22 |
* - `zip_ai_snippets_lint_targeting_functions` (array) — extend or override the |
| 23 |
* forbidden function map (key = function name, value = remediation hint). |
| 24 |
* |
| 25 |
* @since 0.0.5 |
| 26 |
* @package zip-ai |
| 27 |
*/ |
| 28 |
|
| 29 |
namespace ZipAI\MCP\Classes\Core; |
| 30 |
|
| 31 |
if ( ! defined( 'ABSPATH' ) ) { |
| 32 |
exit; |
| 33 |
} |
| 34 |
|
| 35 |
class Snippet_Lint { |
| 36 |
|
| 37 |
/** |
| 38 |
* Default forbidden-targeting function → remediation map. |
| 39 |
* |
| 40 |
* Each entry says: "if you find this function in snippet code, the author |
| 41 |
* is doing routing/targeting; the right place for that is the |
| 42 |
* `conditions[]` payload. Here is the equivalent shape." |
| 43 |
* |
| 44 |
* @return array<string,string> |
| 45 |
*/ |
| 46 |
public static function targeting_function_map() { |
| 47 |
$map = array( |
| 48 |
// Page-type routing |
| 49 |
'is_home' => "conditions:[{type:'page',operator:'is',value:'home'}]", |
| 50 |
'is_front_page' => "conditions:[{type:'page',operator:'is',value:'home'}]", |
| 51 |
'is_single' => "conditions:[{type:'page',operator:'is',value:'single'}]", |
| 52 |
'is_singular' => "conditions:[{type:'page',operator:'is',value:'single'}]", |
| 53 |
'is_page' => "conditions:[{type:'page',operator:'is',value:'page'}] (or {type:'post',value:'<id>'} for a specific page)", |
| 54 |
'is_archive' => "conditions:[{type:'page',operator:'is',value:'archive'}]", |
| 55 |
'is_category' => "conditions:[{type:'page',operator:'is',value:'archive'}]", |
| 56 |
'is_tag' => "conditions:[{type:'page',operator:'is',value:'archive'}]", |
| 57 |
'is_tax' => "conditions:[{type:'page',operator:'is',value:'archive'}]", |
| 58 |
'is_search' => "conditions:[{type:'page',operator:'is',value:'search'}]", |
| 59 |
'is_404' => "conditions:[{type:'page',operator:'is',value:'404'}]", |
| 60 |
|
| 61 |
// Specific post / post-type |
| 62 |
'get_the_ID' => "conditions:[{type:'post',operator:'is',value:'<id>'}]", |
| 63 |
'get_post_type' => "conditions:[{type:'post_type',operator:'is',value:'<type>'}]", |
| 64 |
|
| 65 |
// User / role. `current_user_can()` is deliberately ABSENT: it is an |
| 66 |
// authorization check, not placement — WordPress requires it inside |
| 67 |
// `auth_callback` / `permission_callback` / form + AJAX handlers, and a |
| 68 |
// `user_role` condition is not an equivalent (it decides whether the |
| 69 |
// snippet runs at all, and roles are not capabilities). |
| 70 |
'is_user_logged_in' => "conditions:[{type:'user',operator:'is',value:'logged_in'}]", |
| 71 |
|
| 72 |
// Device |
| 73 |
'wp_is_mobile' => "conditions:[{type:'device',operator:'is',value:'mobile'}]", |
| 74 |
|
| 75 |
// Admin / frontend split |
| 76 |
'is_admin' => "execution.<type>.scope = 'admin' (admin-only) or 'frontend' (frontend-only)", |
| 77 |
); |
| 78 |
$filtered = apply_filters( 'zip_ai_snippets_lint_targeting_functions', $map ); |
| 79 |
if ( is_array( $filtered ) ) { |
| 80 |
/** |
| 81 |
* Narrowed type for `$filtered`. |
| 82 |
* |
| 83 |
* @var array<string,string> $filtered |
| 84 |
*/ |
| 85 |
return $filtered; |
| 86 |
} |
| 87 |
return $map; |
| 88 |
} |
| 89 |
|
| 90 |
/** |
| 91 |
* Detect forbidden inline-targeting calls inside snippet PHP code. |
| 92 |
* |
| 93 |
* Token-based scan — comments and strings are skipped automatically. |
| 94 |
* |
| 95 |
* @param string $php_code Raw snippet body (with or without `<?php`). |
| 96 |
* @return array<int,array{function:string,line:int,hint:string}> |
| 97 |
*/ |
| 98 |
public static function detect_inline_targeting( $php_code ) { |
| 99 |
if ( ! apply_filters( 'zip_ai_snippets_lint_enabled', true ) ) { |
| 100 |
return array(); |
| 101 |
} |
| 102 |
if ( '' === trim( (string) $php_code ) ) { |
| 103 |
return array(); |
| 104 |
} |
| 105 |
|
| 106 |
// Ensure opening tag so `token_get_all` parses correctly even when |
| 107 |
// callers strip the leading `<?php`. |
| 108 |
$prefixed = ltrim( $php_code ); |
| 109 |
$has_tag = stripos( $prefixed, '<?php' ) === 0 || stripos( $prefixed, '<?' ) === 0; |
| 110 |
$source = $has_tag ? $php_code : ( '<?php ' . $php_code ); |
| 111 |
|
| 112 |
try { |
| 113 |
$tokens = @token_get_all( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 114 |
} catch ( \Throwable $e ) { |
| 115 |
// If parsing fails outright, downstream syntax check will report it. |
| 116 |
return array(); |
| 117 |
} |
| 118 |
|
| 119 |
$map = self::targeting_function_map(); |
| 120 |
$hits = array(); |
| 121 |
$name_tokens = array( T_STRING ); |
| 122 |
if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) { |
| 123 |
$name_tokens[] = T_NAME_FULLY_QUALIFIED; |
| 124 |
} |
| 125 |
|
| 126 |
// Track preceding non-whitespace token so we can ignore method calls |
| 127 |
// (`$obj->is_home()`) while still catching global calls such as |
| 128 |
// `\is_page()` and mixed-case variants. |
| 129 |
$prev_meaningful = null; |
| 130 |
|
| 131 |
foreach ( $tokens as $token ) { |
| 132 |
if ( is_array( $token ) ) { |
| 133 |
$id = $token[0]; |
| 134 |
$text = $token[1]; |
| 135 |
$line = $token[2]; |
| 136 |
|
| 137 |
if ( in_array( $id, array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { |
| 138 |
continue; |
| 139 |
} |
| 140 |
|
| 141 |
$function_name = strtolower( ltrim( $text, '\\' ) ); |
| 142 |
if ( in_array( $id, $name_tokens, true ) && isset( $map[ $function_name ] ) ) { |
| 143 |
$is_method = ( T_OBJECT_OPERATOR === $prev_meaningful ); |
| 144 |
if ( ! $is_method ) { |
| 145 |
// Adjust line number when we synthesized the leading tag. |
| 146 |
$reported_line = $has_tag ? $line : max( 1, $line ); |
| 147 |
$hits[] = array( |
| 148 |
'function' => $function_name, |
| 149 |
'line' => $reported_line, |
| 150 |
'hint' => $map[ $function_name ], |
| 151 |
); |
| 152 |
} |
| 153 |
} |
| 154 |
|
| 155 |
$prev_meaningful = $id; |
| 156 |
} else { |
| 157 |
// Single-character tokens (`(`, `;`, `?` etc.) — track as |
| 158 |
// non-method-operator so following identifiers count. |
| 159 |
$prev_meaningful = null; |
| 160 |
} |
| 161 |
} |
| 162 |
|
| 163 |
// Dedupe by function name keeping earliest line — agent only needs |
| 164 |
// to know each fingerprint once. |
| 165 |
$seen = array(); |
| 166 |
$out = array(); |
| 167 |
foreach ( $hits as $h ) { |
| 168 |
if ( isset( $seen[ $h['function'] ] ) ) { |
| 169 |
continue; |
| 170 |
} |
| 171 |
$seen[ $h['function'] ] = true; |
| 172 |
$out[] = $h; |
| 173 |
} |
| 174 |
return $out; |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* Detect code that wraps the same hook the snippet executor already uses. |
| 179 |
* |
| 180 |
* The executor includes PHP snippet files on execution.<type>.hook. If the |
| 181 |
* body then calls add_action( '<same hook>', ... ), that callback is |
| 182 |
* registered while the hook is already running and will not fire in the |
| 183 |
* current request. The snippet saves cleanly but appears broken. |
| 184 |
* |
| 185 |
* @param string $php_code Raw snippet body (with or without `<?php`). |
| 186 |
* @param string $execution_hook Hook configured in execution.php.hook. |
| 187 |
* @return array<int,array{function:string,hook:string,line:int}> |
| 188 |
*/ |
| 189 |
public static function detect_redundant_hook_wrappers( $php_code, $execution_hook ) { |
| 190 |
if ( ! apply_filters( 'zip_ai_snippets_lint_enabled', true ) ) { |
| 191 |
return array(); |
| 192 |
} |
| 193 |
$execution_hook = (string) $execution_hook; |
| 194 |
if ( '' === $execution_hook || '' === trim( (string) $php_code ) ) { |
| 195 |
return array(); |
| 196 |
} |
| 197 |
|
| 198 |
$prefixed = ltrim( $php_code ); |
| 199 |
$has_tag = stripos( $prefixed, '<?php' ) === 0 || stripos( $prefixed, '<?' ) === 0; |
| 200 |
$source = $has_tag ? $php_code : ( '<?php ' . $php_code ); |
| 201 |
|
| 202 |
try { |
| 203 |
$tokens = @token_get_all( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 204 |
} catch ( \Throwable $e ) { |
| 205 |
return array(); |
| 206 |
} |
| 207 |
|
| 208 |
$hits = array(); |
| 209 |
$count = count( $tokens ); |
| 210 |
for ( $i = 0; $i < $count; $i++ ) { |
| 211 |
$token = $tokens[ $i ]; |
| 212 |
if ( ! is_array( $token ) || T_STRING !== $token[0] ) { |
| 213 |
continue; |
| 214 |
} |
| 215 |
$function = strtolower( $token[1] ); |
| 216 |
if ( ! in_array( $function, array( 'add_action', 'add_filter' ), true ) ) { |
| 217 |
continue; |
| 218 |
} |
| 219 |
|
| 220 |
$arg = self::first_string_argument( $tokens, $i + 1 ); |
| 221 |
if ( ! $arg || $arg['value'] !== $execution_hook ) { |
| 222 |
continue; |
| 223 |
} |
| 224 |
|
| 225 |
$hits[] = array( |
| 226 |
'function' => $function, |
| 227 |
'hook' => $arg['value'], |
| 228 |
'line' => $arg['line'], |
| 229 |
); |
| 230 |
} |
| 231 |
|
| 232 |
return $hits; |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* Detect top-level output on hooks that run before headers are safe. |
| 237 |
* |
| 238 |
* Direct echo/print on `init` or `wp_loaded` sends output before REST, |
| 239 |
* redirects, cookies, and admin headers can be emitted. Output snippets |
| 240 |
* should run on a render hook such as `wp_head`, `wp_footer`, or register |
| 241 |
* their later callback from the early hook. |
| 242 |
* |
| 243 |
* @param string $php_code Raw snippet body (with or without `<?php`). |
| 244 |
* @param string $execution_hook Hook configured in execution.php.hook. |
| 245 |
* @return array<int,array{token:string,line:int,hook:string}> |
| 246 |
*/ |
| 247 |
public static function detect_early_direct_output( $php_code, $execution_hook ) { |
| 248 |
$no_direct_output_hooks = array( 'plugins_loaded', 'init', 'wp_loaded', 'wp_enqueue_scripts', 'template_redirect', 'admin_init' ); |
| 249 |
if ( ! in_array( (string) $execution_hook, $no_direct_output_hooks, true ) || '' === trim( (string) $php_code ) ) { |
| 250 |
return array(); |
| 251 |
} |
| 252 |
|
| 253 |
$prefixed = ltrim( $php_code ); |
| 254 |
$has_tag = stripos( $prefixed, '<?php' ) === 0 || stripos( $prefixed, '<?' ) === 0; |
| 255 |
$source = $has_tag ? $php_code : ( '<?php ' . $php_code ); |
| 256 |
|
| 257 |
try { |
| 258 |
$tokens = @token_get_all( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 259 |
} catch ( \Throwable $e ) { |
| 260 |
return array(); |
| 261 |
} |
| 262 |
|
| 263 |
$depth = 0; |
| 264 |
$hits = array(); |
| 265 |
foreach ( $tokens as $token ) { |
| 266 |
$text = is_array( $token ) ? $token[1] : $token; |
| 267 |
if ( '{' === $text ) { |
| 268 |
++$depth; |
| 269 |
continue; |
| 270 |
} |
| 271 |
if ( '}' === $text ) { |
| 272 |
$depth = max( 0, $depth - 1 ); |
| 273 |
continue; |
| 274 |
} |
| 275 |
if ( $depth > 0 || ! is_array( $token ) ) { |
| 276 |
continue; |
| 277 |
} |
| 278 |
if ( |
| 279 |
in_array( $token[0], array( T_ECHO, T_PRINT, T_OPEN_TAG_WITH_ECHO ), true ) |
| 280 |
|| ( T_INLINE_HTML === $token[0] && '' !== trim( $token[1] ) ) |
| 281 |
) { |
| 282 |
$hits[] = array( |
| 283 |
'token' => strtolower( token_name( $token[0] ) ), |
| 284 |
'line' => $token[2], |
| 285 |
'hook' => (string) $execution_hook, |
| 286 |
); |
| 287 |
} |
| 288 |
} |
| 289 |
|
| 290 |
return $hits; |
| 291 |
} |
| 292 |
|
| 293 |
/** |
| 294 |
* Find the first string argument after a function token. |
| 295 |
* |
| 296 |
* @param array<int,string|array{0:int,1:string,2:int}> $tokens Token stream from token_get_all(). |
| 297 |
* @param int $offset Search offset. |
| 298 |
* @return array{value:string,line:int}|null |
| 299 |
*/ |
| 300 |
private static function first_string_argument( array $tokens, $offset ) { |
| 301 |
$depth = 0; |
| 302 |
$count = count( $tokens ); |
| 303 |
for ( $i = $offset; $i < $count; $i++ ) { |
| 304 |
$token = $tokens[ $i ]; |
| 305 |
$text = is_array( $token ) ? $token[1] : $token; |
| 306 |
|
| 307 |
if ( '(' === $text ) { |
| 308 |
++$depth; |
| 309 |
continue; |
| 310 |
} |
| 311 |
if ( ')' === $text ) { |
| 312 |
--$depth; |
| 313 |
if ( $depth <= 0 ) { |
| 314 |
return null; |
| 315 |
} |
| 316 |
continue; |
| 317 |
} |
| 318 |
if ( $depth < 1 ) { |
| 319 |
continue; |
| 320 |
} |
| 321 |
if ( is_array( $token ) && in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { |
| 322 |
continue; |
| 323 |
} |
| 324 |
if ( is_array( $token ) && T_CONSTANT_ENCAPSED_STRING === $token[0] ) { |
| 325 |
$value = trim( $token[1], "'\"" ); |
| 326 |
return array( |
| 327 |
'value' => stripcslashes( $value ), |
| 328 |
'line' => $token[2], |
| 329 |
); |
| 330 |
} |
| 331 |
if ( ',' === $text ) { |
| 332 |
return null; |
| 333 |
} |
| 334 |
} |
| 335 |
return null; |
| 336 |
} |
| 337 |
|
| 338 |
/** |
| 339 |
* Format the detected hits into a single human + agent-readable error |
| 340 |
* string. Agents can parse the deterministic prefix; humans can read |
| 341 |
* the inline guidance. |
| 342 |
* |
| 343 |
* @param array<int,array{function:string,line:int,hint:string}> $hits Result of `detect_inline_targeting()`. |
| 344 |
* @return string |
| 345 |
*/ |
| 346 |
public static function format_violation( array $hits ) { |
| 347 |
if ( empty( $hits ) ) { |
| 348 |
return ''; |
| 349 |
} |
| 350 |
$lines = array(); |
| 351 |
$lines[] = 'Inline targeting/routing detected in snippet code. Targeting MUST be expressed via the `conditions[]` field on the run-snippet tool call — not as `if ( ... ) return;` guards inside the snippet body.'; |
| 352 |
$lines[] = 'Move each of the following calls out of the code and into `conditions[]`, then resubmit:'; |
| 353 |
foreach ( $hits as $h ) { |
| 354 |
$lines[] = sprintf( |
| 355 |
' • Line %d: `%s()` → %s', |
| 356 |
$h['line'], |
| 357 |
$h['function'], |
| 358 |
$h['hint'] |
| 359 |
); |
| 360 |
} |
| 361 |
$lines[] = 'If you genuinely need one of these for non-routing logic (e.g. `get_post_type()` inside a `save_post` handler), apply the `zip_ai_snippets_lint_targeting_functions` filter to whitelist it for this site.'; |
| 362 |
return implode( "\n", $lines ); |
| 363 |
} |
| 364 |
|
| 365 |
/** |
| 366 |
* Format redundant hook wrapper hits. |
| 367 |
* |
| 368 |
* @param array<int,array{function:string,hook:string,line:int}> $hits Result of `detect_redundant_hook_wrappers()`. |
| 369 |
* @return string |
| 370 |
*/ |
| 371 |
public static function format_redundant_hook_violation( array $hits ) { |
| 372 |
if ( empty( $hits ) ) { |
| 373 |
return ''; |
| 374 |
} |
| 375 |
|
| 376 |
$first = $hits[0]; |
| 377 |
return sprintf( |
| 378 |
'Redundant hook wrapper detected. This snippet is already configured to run on `%1$s`, so `%2$s( \'%1$s\', ... )` inside the snippet body registers too late and will not run for the current request. Remove the wrapper and perform the work directly in the snippet body, or choose an earlier execution hook if the body must register a later hook.', |
| 379 |
$first['hook'], |
| 380 |
$first['function'] |
| 381 |
); |
| 382 |
} |
| 383 |
|
| 384 |
/** |
| 385 |
* Format early direct output hits. |
| 386 |
* |
| 387 |
* @param array<int,array{token:string,line:int,hook:string}> $hits Result of `detect_early_direct_output()`. |
| 388 |
* @return string |
| 389 |
*/ |
| 390 |
public static function format_early_output_violation( array $hits ) { |
| 391 |
if ( empty( $hits ) ) { |
| 392 |
return ''; |
| 393 |
} |
| 394 |
|
| 395 |
$first = $hits[0]; |
| 396 |
return sprintf( |
| 397 |
'Direct output detected on early/bootstrap hook `%s` at line %d. This can send headers too early, print in the wrong document phase, or break REST, redirects, cookies, and admin screens. Move execution.php.hook to a render hook such as `wp_footer`/`wp_head`, or register a later callback instead of echoing at top level.', |
| 398 |
$first['hook'], |
| 399 |
$first['line'] |
| 400 |
); |
| 401 |
} |
| 402 |
|
| 403 |
/** |
| 404 |
* Detect HTML wrapper tags inside JS or CSS snippet bodies. |
| 405 |
* |
| 406 |
* The executor pipes JS/CSS snippet contents through wp_add_inline_script / |
| 407 |
* wp_add_inline_style, which already wraps the payload in a `<script>` / |
| 408 |
* `<style>` tag. Per HTML5 raw-text-element parsing, the *closing* tag |
| 409 |
* (`</script>`/`</style>`) inside the body terminates the wrapper — |
| 410 |
* everything after becomes regular HTML, so the intended JS/CSS never |
| 411 |
* executes (this is exactly the failure mode of pasting the canonical GA |
| 412 |
* gtag.js snippet into a `js` snippet — its `</script>` killed the inline |
| 413 |
* wrapper and the loader tag became dead text). |
| 414 |
* |
| 415 |
* We only flag the *closing* tag, not the opening: a literal `<script` or |
| 416 |
* `<style` substring inside raw-text is inert and shows up legitimately in |
| 417 |
* regex/string literals (`const re = /<script>/g`, `'<style>'.length`). An |
| 418 |
* over-aggressive opening-tag block would false-positive too often. |
| 419 |
* |
| 420 |
* `<!--` is also flagged because it puts the HTML parser into the |
| 421 |
* "script-data-escaped" state, changing how subsequent `</script>` tokens |
| 422 |
* are interpreted and confusing JS strict-mode parsers; modern JS/CSS |
| 423 |
* bodies have no reason to contain raw HTML comments — use JS/CSS comment syntax instead. |
| 424 |
* |
| 425 |
* @param string $code Raw snippet body. |
| 426 |
* @param string $type Snippet file type (js|css). |
| 427 |
* @return array<int,array{tag:string,line:int}> Hits — empty if clean. |
| 428 |
*/ |
| 429 |
public static function detect_wrapper_tags( $code, $type ) { |
| 430 |
if ( ! apply_filters( 'zip_ai_snippets_lint_enabled', true ) ) { |
| 431 |
return array(); |
| 432 |
} |
| 433 |
$code = (string) $code; |
| 434 |
if ( '' === trim( $code ) ) { |
| 435 |
return array(); |
| 436 |
} |
| 437 |
|
| 438 |
if ( 'js' === $type ) { |
| 439 |
$patterns = array( |
| 440 |
'</script>' => '/<\/script\s*>/i', |
| 441 |
'<!--' => '/<!--/', |
| 442 |
); |
| 443 |
} elseif ( 'css' === $type ) { |
| 444 |
$patterns = array( |
| 445 |
'</style>' => '/<\/style\s*>/i', |
| 446 |
'<!--' => '/<!--/', |
| 447 |
); |
| 448 |
} else { |
| 449 |
return array(); |
| 450 |
} |
| 451 |
|
| 452 |
$hits = array(); |
| 453 |
foreach ( $patterns as $label => $regex ) { |
| 454 |
if ( preg_match( $regex, $code, $m, PREG_OFFSET_CAPTURE ) ) { |
| 455 |
$offset = (int) $m[0][1]; |
| 456 |
$line = substr_count( substr( $code, 0, $offset ), "\n" ) + 1; |
| 457 |
$hits[] = array( |
| 458 |
'tag' => $label, |
| 459 |
'line' => $line, |
| 460 |
); |
| 461 |
} |
| 462 |
} |
| 463 |
return $hits; |
| 464 |
} |
| 465 |
|
| 466 |
/** |
| 467 |
* Format wrapper-tag violation for agent + human consumption. |
| 468 |
* |
| 469 |
* @param array<int,array{tag:string,line:int}> $hits Result of `detect_wrapper_tags()`. |
| 470 |
* @param string $type Snippet type (js|css). |
| 471 |
* @return string |
| 472 |
*/ |
| 473 |
public static function format_wrapper_tag_violation( array $hits, $type ) { |
| 474 |
if ( empty( $hits ) ) { |
| 475 |
return ''; |
| 476 |
} |
| 477 |
$type = (string) $type; |
| 478 |
$first = $hits[0]; |
| 479 |
$pipeline = 'js' === $type ? 'wp_add_inline_script' : 'wp_add_inline_style'; |
| 480 |
$wrapper = 'js' === $type ? '<script>' : '<style>'; |
| 481 |
$bullets = array(); |
| 482 |
foreach ( $hits as $h ) { |
| 483 |
$bullets[] = sprintf( ' • Line %d: `%s`', $h['line'], $h['tag'] ); |
| 484 |
} |
| 485 |
return sprintf( |
| 486 |
"%s snippet body contains HTML wrapper tags — found `%s` at line %d. The executor wraps %s snippets in a `%s...%s` tag via %s, so an inner closing tag terminates the wrapper prematurely and the surrounding markup never executes.\nHits:\n%s\nFix: pass only the body that belongs inside `%s` (no `<script>`/`<style>` tags, no HTML comments). For verbatim head/footer markup (external script loaders, JSON-LD, `<noscript>`, GA/GTM/FB Pixel tags), use `type:\"html\"` instead — its executor emits the file contents as-is on the configured hook.", |
| 487 |
strtoupper( $type ), |
| 488 |
$first['tag'], |
| 489 |
$first['line'], |
| 490 |
$type, |
| 491 |
$wrapper, |
| 492 |
str_replace( '<', '</', $wrapper ), |
| 493 |
$pipeline, |
| 494 |
implode( "\n", $bullets ), |
| 495 |
$wrapper |
| 496 |
); |
| 497 |
} |
| 498 |
|
| 499 |
/** |
| 500 |
* Lint a non-PHP snippet body. JS/CSS run the wrapper-tag detector; HTML |
| 501 |
* is emitted verbatim so it gets no body lint, but we reject `<?php` / |
| 502 |
* `<?=` open tags inside HTML because the executor `echo`s the file as a |
| 503 |
* static string — PHP open tags become literal text, never execute, and |
| 504 |
* the author thinks their server-side logic is broken. |
| 505 |
* |
| 506 |
* @param string $code Raw snippet body. |
| 507 |
* @param string $type Snippet type (js|css|html). |
| 508 |
* @return \WP_Error|null |
| 509 |
*/ |
| 510 |
public static function check_non_php( $code, $type ) { |
| 511 |
$type = (string) $type; |
| 512 |
if ( 'html' === $type ) { |
| 513 |
if ( preg_match( '/<\?(php|=)/i', (string) $code, $m, PREG_OFFSET_CAPTURE ) ) { |
| 514 |
$offset = (int) $m[0][1]; |
| 515 |
$line = substr_count( substr( (string) $code, 0, $offset ), "\n" ) + 1; |
| 516 |
return new \WP_Error( |
| 517 |
'php_in_html_forbidden', |
| 518 |
sprintf( |
| 519 |
'HTML snippet body contains a PHP open tag (`%s`) at line %d. HTML snippets are emitted verbatim by `echo $content` on the configured hook — PHP open tags become literal text and never execute. For server-side logic, switch to `type:"php"`. For verbatim head/footer markup, remove the PHP and inline the resolved value.', |
| 520 |
$m[0][0], |
| 521 |
$line |
| 522 |
), |
| 523 |
array( |
| 524 |
'line' => $line, |
| 525 |
'tag' => $m[0][0], |
| 526 |
) |
| 527 |
); |
| 528 |
} |
| 529 |
return null; |
| 530 |
} |
| 531 |
if ( ! in_array( $type, array( 'js', 'css' ), true ) ) { |
| 532 |
return null; |
| 533 |
} |
| 534 |
$hits = self::detect_wrapper_tags( $code, $type ); |
| 535 |
if ( empty( $hits ) ) { |
| 536 |
return null; |
| 537 |
} |
| 538 |
return new \WP_Error( |
| 539 |
'wrapper_tags_forbidden', |
| 540 |
self::format_wrapper_tag_violation( $hits, $type ), |
| 541 |
array( |
| 542 |
'hits' => $hits, |
| 543 |
'type' => $type, |
| 544 |
) |
| 545 |
); |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* Convenience: returns WP_Error if violation found, null otherwise. |
| 550 |
* Lets callers do `is_wp_error( Snippet_Lint::check( $code ) )`. |
| 551 |
* |
| 552 |
* @param string $php_code |
| 553 |
* @param string $execution_hook Optional configured execution hook. |
| 554 |
* @return \WP_Error|null |
| 555 |
*/ |
| 556 |
/** |
| 557 |
* Scan a snippet body (any type) for likely hard-coded secrets. |
| 558 |
* |
| 559 |
* Pattern-matches common credential shapes — `api[_-]?key`, `bearer`, |
| 560 |
* `secret`, `token`, AWS-style access keys, JWTs, GitHub tokens, etc. — |
| 561 |
* paired with a high-entropy value or an obvious assignment. Returns a |
| 562 |
* list of human-readable warning strings; an empty list means "looks |
| 563 |
* clean to the eye". Non-blocking by design: secrets are sometimes the |
| 564 |
* point of a snippet (analytics measurement IDs, public site keys), so |
| 565 |
* we surface a heads-up instead of refusing the write. |
| 566 |
* |
| 567 |
* @since 0.0.5 |
| 568 |
* @param string $code Raw snippet body. |
| 569 |
* @return array<int,string> |
| 570 |
*/ |
| 571 |
public static function detect_secrets( $code ) { |
| 572 |
$code = (string) $code; |
| 573 |
if ( '' === trim( $code ) ) { |
| 574 |
return array(); |
| 575 |
} |
| 576 |
$warnings = array(); |
| 577 |
|
| 578 |
// 1. Named secret-ish keys with a quoted/literal value. |
| 579 |
// Captures `api_key = "..."`, `apiKey: '...'`, `BEARER_TOKEN="..."`, etc. |
| 580 |
$named_regex = '/\b(api[_-]?key|api[_-]?secret|secret[_-]?key|client[_-]?secret|access[_-]?token|auth[_-]?token|bearer[_-]?token|private[_-]?key|password|passwd|webhook[_-]?secret)\b\s*[:=]\s*[\'"]([^\'"\\s]{8,})[\'"]/i'; |
| 581 |
if ( preg_match_all( $named_regex, $code, $matches, PREG_SET_ORDER ) ) { |
| 582 |
foreach ( $matches as $m ) { |
| 583 |
$key = strtolower( $m[1] ); |
| 584 |
$value = $m[2]; |
| 585 |
if ( self::looks_like_placeholder( $value ) ) { |
| 586 |
continue; |
| 587 |
} |
| 588 |
$warnings[] = sprintf( |
| 589 |
'Looks like a hard-coded `%s` literal — move to a constant in wp-config.php or store in an option/secrets manager.', |
| 590 |
$key |
| 591 |
); |
| 592 |
} |
| 593 |
} |
| 594 |
|
| 595 |
// 2. Well-known token formats with low false-positive rate. |
| 596 |
$token_patterns = array( |
| 597 |
'/\b(sk-[A-Za-z0-9]{20,})\b/' => 'OpenAI-style secret key', |
| 598 |
'/\bAKIA[0-9A-Z]{16}\b/' => 'AWS access key ID', |
| 599 |
'/\bghp_[A-Za-z0-9]{36,}\b/' => 'GitHub personal access token', |
| 600 |
'/\bgho_[A-Za-z0-9]{36,}\b/' => 'GitHub OAuth token', |
| 601 |
'/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/' => 'Slack token', |
| 602 |
'/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/' => 'JWT', |
| 603 |
); |
| 604 |
foreach ( $token_patterns as $pattern => $label ) { |
| 605 |
if ( preg_match( $pattern, $code ) ) { |
| 606 |
$warnings[] = sprintf( |
| 607 |
'%s detected in snippet body — store in wp-config.php or an encrypted option, not in code.', |
| 608 |
$label |
| 609 |
); |
| 610 |
} |
| 611 |
} |
| 612 |
|
| 613 |
return array_values( array_unique( $warnings ) ); |
| 614 |
} |
| 615 |
|
| 616 |
/** |
| 617 |
* True if the candidate looks like an obvious placeholder/example — |
| 618 |
* "YOUR_KEY_HERE", "G-0.0.210.0.21", repeated digits, dashes-only. |
| 619 |
* |
| 620 |
* @param string $value Candidate secret value. |
| 621 |
* @return bool |
| 622 |
*/ |
| 623 |
private static function looks_like_placeholder( $value ) { |
| 624 |
$lower = strtolower( $value ); |
| 625 |
if ( preg_match( '/^(your|sample|example|placeholder|test|fake|dummy)[-_]/', $lower ) ) { |
| 626 |
return true; |
| 627 |
} |
| 628 |
if ( preg_match( '/^[x]+$/i', $value ) || preg_match( '/^[-_]+$/', $value ) ) { |
| 629 |
return true; |
| 630 |
} |
| 631 |
// Very low character variety = unlikely real credential. |
| 632 |
if ( strlen( count_chars( $value, 3 ) ) <= 3 ) { |
| 633 |
return true; |
| 634 |
} |
| 635 |
return false; |
| 636 |
} |
| 637 |
|
| 638 |
/** |
| 639 |
* Detect a fatal PHP parse error in a snippet body WITHOUT executing it. |
| 640 |
* |
| 641 |
* The most common agent mistake: a `php` body that drops literal markup |
| 642 |
* (`<div>…`) into PHP context with no `?>` to exit first. That is a fatal |
| 643 |
* parse error — `include` aborts and the snippet renders nothing on every |
| 644 |
* hook — yet it writes and enables silently because the lenient |
| 645 |
* `token_get_all()` used elsewhere never flags it. `TOKEN_PARSE` makes |
| 646 |
* `token_get_all()` validate the body as a complete file and throw on |
| 647 |
* invalid syntax. Unlike an `if(false){…}` eval wrapper it does NOT |
| 648 |
* false-positive on a correct body that closes `?>` and ends in trailing |
| 649 |
* HTML, and it cannot fatal the request. |
| 650 |
* |
| 651 |
* @param string $php_code Raw snippet body (with or without `<?php`). |
| 652 |
* @return string|null Parser error message, or null when the body parses. |
| 653 |
*/ |
| 654 |
public static function detect_syntax_error( $php_code ) { |
| 655 |
// Own switch — NOT the style-lint kill-switch (`zip_ai_snippets_lint_enabled`). |
| 656 |
// Disabling style lints (inline targeting, redundant hook wrappers) to |
| 657 |
// dodge a false-positive must not silently void fatal-parse-error |
| 658 |
// protection — the exact failure this gate exists to catch. |
| 659 |
if ( ! apply_filters( 'zip_ai_snippets_syntax_check_enabled', true ) ) { |
| 660 |
return null; |
| 661 |
} |
| 662 |
$code = (string) $php_code; |
| 663 |
if ( '' === trim( $code ) ) { |
| 664 |
return null; |
| 665 |
} |
| 666 |
// Parse-mode tokenizer unavailable — cannot validate without executing. |
| 667 |
if ( ! defined( 'TOKEN_PARSE' ) ) { |
| 668 |
return null; |
| 669 |
} |
| 670 |
|
| 671 |
$trimmed = ltrim( $code ); |
| 672 |
$has_tag = stripos( $trimmed, '<?php' ) === 0 || stripos( $trimmed, '<?' ) === 0; |
| 673 |
$source = $has_tag ? $code : "<?php\n" . $code; |
| 674 |
$tag_offset = $has_tag ? 0 : 1; // We prepended one "<?php\n" line. |
| 675 |
|
| 676 |
try { |
| 677 |
// Called purely for its side effect: TOKEN_PARSE throws on invalid syntax. |
| 678 |
$parsed = token_get_all( $source, TOKEN_PARSE ); // phpcs:ignore |
| 679 |
unset( $parsed ); |
| 680 |
} catch ( \ParseError $e ) { |
| 681 |
// Report the body-relative line so the author/agent can self-correct. |
| 682 |
$line = max( 1, $e->getLine() - $tag_offset ); |
| 683 |
return sprintf( 'Line %d: %s', $line, $e->getMessage() ); |
| 684 |
} catch ( \Throwable $e ) { |
| 685 |
return $e->getMessage(); |
| 686 |
} |
| 687 |
return null; |
| 688 |
} |
| 689 |
|
| 690 |
/** |
| 691 |
* Run all PHP-snippet lint gates and return the first violation found. |
| 692 |
* |
| 693 |
* @param string $php_code Raw snippet body (with or without `<?php`). |
| 694 |
* @param string $execution_hook Optional configured execution hook. |
| 695 |
* @return \WP_Error|null WP_Error on the first failed gate, null when clean. |
| 696 |
*/ |
| 697 |
public static function check( $php_code, $execution_hook = '' ) { |
| 698 |
$syntax_error = self::detect_syntax_error( $php_code ); |
| 699 |
if ( null !== $syntax_error ) { |
| 700 |
return new \WP_Error( |
| 701 |
'php_syntax_error', |
| 702 |
sprintf( |
| 703 |
'PHP syntax error in snippet body: %s. All literal HTML must sit OUTSIDE <?php ?> — close PHP with ?> before any markup, and reopen <?php only around dynamic output.', |
| 704 |
$syntax_error |
| 705 |
), |
| 706 |
array( 'error' => $syntax_error ) |
| 707 |
); |
| 708 |
} |
| 709 |
$hits = self::detect_inline_targeting( $php_code ); |
| 710 |
if ( empty( $hits ) ) { |
| 711 |
$hook_hits = self::detect_redundant_hook_wrappers( $php_code, $execution_hook ); |
| 712 |
if ( ! empty( $hook_hits ) ) { |
| 713 |
return new \WP_Error( |
| 714 |
'redundant_hook_wrapper', |
| 715 |
self::format_redundant_hook_violation( $hook_hits ), |
| 716 |
array( 'hits' => $hook_hits ) |
| 717 |
); |
| 718 |
} |
| 719 |
$output_hits = self::detect_early_direct_output( $php_code, $execution_hook ); |
| 720 |
if ( ! empty( $output_hits ) ) { |
| 721 |
return new \WP_Error( |
| 722 |
'early_direct_output', |
| 723 |
self::format_early_output_violation( $output_hits ), |
| 724 |
array( 'hits' => $output_hits ) |
| 725 |
); |
| 726 |
} |
| 727 |
return null; |
| 728 |
} |
| 729 |
return new \WP_Error( |
| 730 |
'inline_targeting_forbidden', |
| 731 |
self::format_violation( $hits ), |
| 732 |
array( 'hits' => $hits ) |
| 733 |
); |
| 734 |
} |
| 735 |
} |
| 736 |
|