diff --git a/examples/recipes/wordpress-adversarial-vulnerable.json b/examples/recipes/wordpress-adversarial-vulnerable.json new file mode 100644 index 00000000..442d3bae --- /dev/null +++ b/examples/recipes/wordpress-adversarial-vulnerable.json @@ -0,0 +1,94 @@ +{ + "schema": "wp-codebox/workspace-recipe/v1", + "runtime": { + "backend": "wordpress-playground", + "name": "wordpress-adversarial-vulnerable", + "wp": "latest" + }, + "inputs": { + "mounts": [ + { + "source": "../../tests/fixtures/wordpress-adversarial/vulnerable-plugin", + "target": "/wordpress/wp-content/plugins/wp-codebox-adversarial-vulnerable", + "mode": "readonly", + "metadata": { "kind": "fixture", "disposableOnly": true } + }, + { + "source": "../../tests/fixtures/wordpress-adversarial/vulnerable-theme", + "target": "/wordpress/wp-content/themes/wp-codebox-adversarial-vulnerable", + "mode": "readonly", + "metadata": { "kind": "fixture", "disposableOnly": true } + } + ] + }, + "workflow": { + "steps": [ + { + "command": "wordpress.run-php", + "args": ["code=require_once ABSPATH . 'wp-admin/includes/plugin.php'; activate_plugin('wp-codebox-adversarial-vulnerable/vulnerable-plugin.php'); switch_theme('wp-codebox-adversarial-vulnerable'); delete_option('wp_codebox_vulnerable_authorization'); delete_option('wp_codebox_vulnerable_partial_state');"] + } + ] + }, + "adversarialCampaigns": [ + { + "schema": "wp-codebox/adversarial-recipe-campaign/v1", + "id": "wordpress-vulnerable-authorization", + "seed": "wordpress-vulnerable-authorization-v1", + "corpus": [ + { "id": "unauthenticated-rest", "actions": [{ "type": "authorization-rest", "input": { "role": "subscriber", "secret": "fixture-secret" } }], "signals": ["route:authorization"] } + ], + "caseTemplates": [ + { + "id": "authorization-rest", + "phases": { + "action": [{ "command": "wordpress.rest-request", "args": ["method=POST", "path=/wp-codebox-vulnerable/v1/authorization", "body-json={{action.input}}", "capture-diagnostics=wpdb-queries"] }], + "assert": [{ "command": "wordpress.run-php", "args": ["code=if (get_option('wp_codebox_vulnerable_authorization', null) !== null) { throw new RuntimeException('authorization-bypass'); }"] }], + "teardown": [{ "command": "wordpress.run-php", "args": ["code=delete_option('wp_codebox_vulnerable_authorization');"] }] + } + } + ], + "mutators": ["scalar", "structured", "sequence"], + "oracles": [{ "id": "runtime-status", "severity": "critical", "description": "Unauthenticated state changes must fail closed." }], + "concurrency": 1, + "budgets": { "maxCases": 4, "maxActionsPerCase": 4, "maxInputBytes": 4096, "maxCaseTimeMs": 15000, "maxWallTimeMs": 120000, "maxArtifactBytes": 10485760 }, + "resetPolicy": { "mode": "checkpoint-per-case", "checkpointName": "wordpress-vulnerable-authorization-baseline" }, + "requiredCapabilities": ["adversarial-campaign", "artifact-export", "bounded-evidence", "replay", "command:wordpress.rest-request", "command:wordpress.run-php"], + "optionalCapabilities": ["transport-faults"], + "novelty": { "retainSignals": true }, + "shrinking": { "enabled": true }, + "replay": { "selection": "findings" }, + "metadata": { "fixtureDefect": "authorization", "network": "deny" } + }, + { + "schema": "wp-codebox/adversarial-recipe-campaign/v1", + "id": "wordpress-vulnerable-state", + "seed": "wordpress-vulnerable-state-v1", + "corpus": [ + { "id": "partial-state", "actions": [{ "type": "partial-state-rest", "input": {} }], "signals": ["route:partial-state"] } + ], + "caseTemplates": [ + { + "id": "partial-state-rest", + "phases": { + "action": [{ "command": "wordpress.rest-request", "args": ["method=POST", "path=/wp-codebox-vulnerable/v1/partial-state", "body-json={{action.input}}", "capture-diagnostics=wpdb-queries"] }], + "assert": [{ "command": "wordpress.run-php", "args": ["code=if (get_option('wp_codebox_vulnerable_partial_state', null) !== null) { throw new RuntimeException('partial-state-commit'); }"] }], + "teardown": [{ "command": "wordpress.run-php", "args": ["code=delete_option('wp_codebox_vulnerable_partial_state');"] }] + } + } + ], + "mutators": ["scalar", "sequence"], + "oracles": [{ "id": "runtime-status", "severity": "high", "description": "Failed requests must not commit partial state." }], + "concurrency": 1, + "budgets": { "maxCases": 3, "maxActionsPerCase": 3, "maxInputBytes": 4096, "maxCaseTimeMs": 15000, "maxWallTimeMs": 120000, "maxArtifactBytes": 10485760 }, + "resetPolicy": { "mode": "checkpoint-per-case", "checkpointName": "wordpress-vulnerable-state-baseline" }, + "requiredCapabilities": ["adversarial-campaign", "artifact-export", "bounded-evidence", "replay", "command:wordpress.rest-request", "command:wordpress.run-php"], + "novelty": { "retainSignals": true }, + "shrinking": { "enabled": true }, + "replay": { "selection": "findings" }, + "metadata": { "fixtureDefect": "state-corruption", "network": "deny" } + } + ], + "artifacts": { + "directory": "./artifacts/wordpress-adversarial-vulnerable" + } +} diff --git a/package.json b/package.json index 85a39c58..cdbeaffd 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,8 @@ "test:recipe-run-provenance": "tsx tests/recipe-run-provenance.test.ts", "test:fuzz-run-recipe": "tsx tests/fuzz-run-recipe.test.ts", "test:fuzz-suite-runner": "tsx tests/fuzz-suite-runner.test.ts", - "test:adversarial-runtime": "tsx --test tests/transport-faults.test.ts tests/adversarial-campaign.test.ts tests/adversarial-browser.test.ts tests/browser-clock-control.test.ts tests/adversarial-recipe-orchestration.test.ts", + "test:adversarial-runtime": "tsx --test tests/transport-faults.test.ts tests/adversarial-campaign.test.ts tests/adversarial-browser.test.ts tests/browser-clock-control.test.ts tests/adversarial-recipe-orchestration.test.ts tests/wordpress-adversarial-adapter.test.ts", + "test:wordpress-adversarial-adapter": "tsx tests/wordpress-adversarial-adapter.test.ts", "test:playground-fuzz-suite-public": "tsx tests/playground-fuzz-suite-public.test.ts", "test:nested-fuzz-suite-recipe-command": "tsx tests/nested-fuzz-suite-recipe-command.test.ts", "test:wordpress-fuzz-suite-builders": "tsx tests/wordpress-fuzz-suite-builders.test.ts", diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 49ec6ddc..a7b51bc6 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -17,6 +17,7 @@ export { preflightPhpWasmRuntimeAssets, PhpWasmRuntimeAssetIntegrityError, type export { browserPreviewAuthCookieUrls, browserPreviewNetworkPolicySummary, browserPreviewReadinessError, browserPreviewRouting, browserPreviewSecureContextError, browserPreviewTopology, browserPreviewOrigins, resolveBrowserPreviewUrl, type BrowserPreviewNetworkPolicy, type BrowserPreviewTopology } from "./browser-preview-routing.js" export { BROWSER_TRANSPORT_FAULT_CAPABILITIES, applyBrowserTransportFault, createBrowserTransportFaultAdapter, type BrowserTransportFaultAdapter } from "./browser-transport-faults.js" export { PLAYWRIGHT_CLOCK_CONTROL_CAPABILITIES, createBrowserClockController, type BrowserClockController } from "./browser-clock-control.js" +export { WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA, WORDPRESS_ADVERSARIAL_CAPABILITIES, WORDPRESS_ADVERSARIAL_ORACLES, WORDPRESS_CLOCK_CONTROL_CAPABILITIES, WORDPRESS_HTTP_TRANSPORT_FAULT_CAPABILITIES, createWordPressAdversarialAdapter, evaluateWordPressAdversarialOracles, negotiateWordPressHttpTransportFaults, wordpressAdversarialActionSpec, wordpressHttpFaultConfigurationAction, wordpressNoveltySignals, wordpressSchedulerClockAction, type WordPressAdapterFidelity, type WordPressAdversarialAction, type WordPressAdversarialAdapter, type WordPressAdversarialCapability, type WordPressAdversarialSurface } from "./wordpress-adversarial-adapter.js" export { normalizePreviewReviewerAccess, previewReviewerAccess } from "./preview-reviewer-access.js" export { applyVfsMountSnapshots, materializePlaygroundMountsFromVfs, materializePlaygroundStagedInputs, type HostMountSnapshot, type MountMaterializationResult, type StagedInputMaterializationResult, type VfsMountSnapshot } from "./mount-materialization.js" export { buildReplayExportBlueprint, buildReplayableWordPressSiteBlueprint, buildReplayableWordPressSiteLimitations, writeReplayExportPackage, writeReplayableWordPressSiteBundle, type ReplayExportPackage, type ReplayExportPackageOptions, type ReplayableWordPressSiteBundle, type ReplayableWordPressSiteBundleManifest, type ReplayableWordPressSiteBundleOptions } from "./replayable-wordpress-site-bundle.js" diff --git a/packages/runtime-playground/src/wordpress-adversarial-adapter.ts b/packages/runtime-playground/src/wordpress-adversarial-adapter.ts new file mode 100644 index 00000000..5aad9c0a --- /dev/null +++ b/packages/runtime-playground/src/wordpress-adversarial-adapter.ts @@ -0,0 +1,311 @@ +import { createHash } from "node:crypto" + +import { + ADVERSARIAL_ORACLE_SCHEMA, + clockControlCapabilities, + negotiateTransportFaults, + transportFaultCapabilities, + type AdversarialCasePlan, + type AdversarialExecutionObservation, + type AdversarialOracleContract, + type AdversarialOracleResult, + type ClockControlCapabilities, + type RuntimeEpisodeActionSpec, + type TransportFaultCapabilities, + type TransportFaultModel, + type TransportFaultNegotiation, +} from "@automattic/wp-codebox-core" + +export const WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA = "wp-codebox/wordpress-adversarial-adapter/v1" as const + +export type WordPressAdversarialSurface = + | "rest" + | "ajax" + | "xmlrpc" + | "block" + | "shortcode" + | "serialized-value" + | "option" + | "meta" + | "file" + | "cron" + | "cli" + | "role-capability" + | "multisite-membership" + +export type WordPressAdapterFidelity = "exact" | "emulated" | "unsupported" + +export interface WordPressAdversarialCapability { + surface: WordPressAdversarialSurface + fidelity: WordPressAdapterFidelity + reason: string +} + +export interface WordPressAdversarialAction { + surface: WordPressAdversarialSurface + operation: string + target?: string + input?: unknown + actor?: { userId?: number; role?: string; blogId?: number; nonce?: string } + metadata?: Record +} + +export interface WordPressAdversarialAdapter { + schema: typeof WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA + id: "wordpress-playground" + capabilities: WordPressAdversarialCapability[] + transportFaults: TransportFaultCapabilities + clocks: ClockControlCapabilities + oracleIds: string[] +} + +export const WORDPRESS_ADVERSARIAL_CAPABILITIES: readonly WordPressAdversarialCapability[] = [ + { surface: "rest", fidelity: "exact", reason: "Dispatched through WP_REST_Request and rest_do_request()." }, + { surface: "ajax", fidelity: "unsupported", reason: "In-process admin-ajax callbacks may terminate PHP; use browser/server requests when an HTTP runtime is available." }, + { surface: "xmlrpc", fidelity: "unsupported", reason: "The disposable in-process runner does not provide a faithful XML-RPC HTTP request boundary." }, + { surface: "block", fidelity: "exact", reason: "Parsed, serialized, and rendered with WordPress block APIs." }, + { surface: "shortcode", fidelity: "exact", reason: "Executed through do_shortcode()." }, + { surface: "serialized-value", fidelity: "exact", reason: "Round-tripped through maybe_serialize() and maybe_unserialize()." }, + { surface: "option", fidelity: "exact", reason: "Mutated through the WordPress options API." }, + { surface: "meta", fidelity: "exact", reason: "Mutated through the WordPress metadata API." }, + { surface: "file", fidelity: "exact", reason: "Confined to a disposable uploads subdirectory with canonical-path escape evidence." }, + { surface: "cron", fidelity: "exact", reason: "Scheduled and invoked through WordPress cron and hook APIs using explicit timestamps." }, + { surface: "cli", fidelity: "exact", reason: "Executed by the existing WordPress WP-CLI runtime command." }, + { surface: "role-capability", fidelity: "exact", reason: "Mutated through WP_Role and WP_User capability APIs." }, + { surface: "multisite-membership", fidelity: "exact", reason: "Uses add_user_to_blog()/remove_user_from_blog() when multisite is enabled; otherwise reports unsupported." }, +] as const + +const transportCapabilities = [ + { semantic: "response-substitution", fidelity: "exact", reason: "WordPress HTTP API pre_http_request substitution returns the declared response." }, + { semantic: "malformed-response", fidelity: "emulated", reason: "Represented as a malformed WordPress HTTP API response, not malformed socket bytes." }, + { semantic: "truncated-response", fidelity: "emulated", reason: "Body truncation occurs at the WordPress HTTP API interception boundary." }, + { semantic: "delay", fidelity: "emulated", reason: "Delay is applied before returning from pre_http_request." }, + { semantic: "jitter", fidelity: "unsupported", reason: "Faithful deterministic jitter requires transport scheduling tracked by #2018." }, + { semantic: "timeout", fidelity: "emulated", reason: "Returned as a WP_Error timeout without a socket-level timeout." }, + { semantic: "connection-refusal", fidelity: "emulated", reason: "Returned as a WP_Error connection refusal without opening a socket." }, + { semantic: "connection-reset", fidelity: "emulated", reason: "Returned as a WP_Error reset without a socket-level reset." }, + { semantic: "chunked-response", fidelity: "unsupported", reason: "Exact transfer framing requires the socket proxy tracked by #2018." }, + { semantic: "bandwidth", fidelity: "unsupported", reason: "Exact byte pacing requires the socket proxy tracked by #2018." }, + { semantic: "half-close", fidelity: "unsupported", reason: "Exact connection state requires the socket proxy tracked by #2018." }, + { semantic: "disconnect-after-bytes", fidelity: "unsupported", reason: "Exact connection state requires the socket proxy tracked by #2018." }, + { semantic: "host-remap", fidelity: "unsupported", reason: "Host remapping is not performed at the WordPress HTTP API interception boundary." }, + { semantic: "request-corruption", fidelity: "unsupported", reason: "Wire request corruption requires the socket proxy tracked by #2018." }, + { semantic: "response-corruption", fidelity: "unsupported", reason: "Wire response corruption requires the socket proxy tracked by #2018." }, +] as const + +export const WORDPRESS_HTTP_TRANSPORT_FAULT_CAPABILITIES = transportFaultCapabilities("wordpress-http-api", [...transportCapabilities]) + +export const WORDPRESS_CLOCK_CONTROL_CAPABILITIES = clockControlCapabilities("wordpress-playground", [ + { surface: "runtime", freeze: false, advance: false, skew: false, restore: false, fidelity: "unsupported", reason: "PHP time(), current_time(), and current_datetime() have no global supported clock injection primitive in this runtime." }, + { surface: "browser", freeze: true, advance: true, skew: false, restore: true, fidelity: "exact", reason: "Use the existing Playwright clock controller for browser time." }, + { surface: "scheduler", freeze: true, advance: true, skew: true, restore: true, fidelity: "emulated", reason: "Campaigns select and invoke due cron events against an explicit timestamp; background spawn timing is not changed." }, + { surface: "database", freeze: false, advance: false, skew: false, restore: false, fidelity: "unsupported", reason: "The default SQLite runtime database clock is independent and exposes no supported injection primitive." }, +]) + +export const WORDPRESS_ADVERSARIAL_ORACLES: readonly AdversarialOracleContract[] = [ + oracle("wordpress-authorization", "critical", "Authorization, nonce, and multisite tenant isolation must fail closed."), + oracle("wordpress-injection", "critical", "Mutated input must not create execution or injection indicators."), + oracle("wordpress-transactional-consistency", "high", "Failed operations must not leave partial state."), + oracle("wordpress-duplicate-effect", "high", "Retries and concurrent actions must not produce duplicate effects."), + oracle("wordpress-fail-open", "high", "External-service failures must not be treated as successful authorization or writes."), + oracle("wordpress-filesystem-escape", "critical", "File mutations must remain inside their declared disposable root."), + oracle("wordpress-secret-leakage", "critical", "Evidence and responses must not expose secrets."), +] as const + +export function createWordPressAdversarialAdapter(): WordPressAdversarialAdapter { + return { + schema: WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA, + id: "wordpress-playground", + capabilities: WORDPRESS_ADVERSARIAL_CAPABILITIES.map((capability) => ({ ...capability })), + transportFaults: WORDPRESS_HTTP_TRANSPORT_FAULT_CAPABILITIES, + clocks: WORDPRESS_CLOCK_CONTROL_CAPABILITIES, + oracleIds: WORDPRESS_ADVERSARIAL_ORACLES.map(({ id }) => id), + } +} + +export function negotiateWordPressHttpTransportFaults(model: TransportFaultModel): TransportFaultNegotiation { + return negotiateTransportFaults(model, WORDPRESS_HTTP_TRANSPORT_FAULT_CAPABILITIES) +} + +export function wordpressAdversarialActionSpec(action: WordPressAdversarialAction): RuntimeEpisodeActionSpec { + const capability = WORDPRESS_ADVERSARIAL_CAPABILITIES.find((candidate) => candidate.surface === action.surface) + if (!capability || capability.fidelity === "unsupported") { + throw new Error(`WordPress adversarial surface ${action.surface} is unsupported: ${capability?.reason ?? "not declared"}`) + } + if (action.surface === "cli") { + const command = typeof action.input === "string" ? action.input : "" + if (!command) throw new Error("WordPress adversarial CLI actions require a non-empty string input.") + return { kind: "command", command: "wordpress.wp-cli", args: [`command=${command}`], operation: `adversarial:${action.surface}`, metadata: actionMetadata(action, capability) } + } + return { + kind: "command", + command: "wordpress.run-php", + args: [`code=${wordpressAdversarialHarnessPhp(action)}`, "capture-diagnostics=wpdb-queries"], + operation: `adversarial:${action.surface}`, + metadata: actionMetadata(action, capability), + } +} + +export function wordpressSchedulerClockAction(timestamp: number, hook: string, args: unknown[] = []): RuntimeEpisodeActionSpec { + if (!Number.isSafeInteger(timestamp) || timestamp <= 0) throw new Error("Scheduler clock timestamp must be a positive Unix timestamp.") + return wordpressAdversarialActionSpec({ surface: "cron", operation: "run-due", target: hook, input: { timestamp, args } }) +} + +export function wordpressHttpFaultConfigurationAction(model: TransportFaultModel): RuntimeEpisodeActionSpec { + const negotiation = negotiateWordPressHttpTransportFaults(model) + if (!negotiation.supported) { + throw new Error(`WordPress HTTP fault schedule requires unsupported semantics: ${negotiation.unsupported.map(({ semantic }) => semantic).join(", ")}`) + } + const encodedModel = Buffer.from(JSON.stringify(model), "utf8").toString("base64") + const encodedPlugin = Buffer.from(wordpressHttpFaultMuPluginPhp(), "utf8").toString("base64") + return { + kind: "command", + command: "wordpress.run-php", + args: [`code=wp_mkdir_p(WPMU_PLUGIN_DIR); file_put_contents(WPMU_PLUGIN_DIR . '/wp-codebox-adversarial-http.php', base64_decode('${encodedPlugin}')); update_option('wp_codebox_adversarial_http_faults', json_decode(base64_decode('${encodedModel}'), true), false);`], + operation: "adversarial:http-transport-faults", + metadata: { adapter: WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA, transportFaultNegotiation: negotiation, networkDefault: "deny" }, + } +} + +export function evaluateWordPressAdversarialOracles( + _plan: AdversarialCasePlan, + observation: AdversarialExecutionObservation, + oracles: readonly AdversarialOracleContract[] = WORDPRESS_ADVERSARIAL_ORACLES, +): AdversarialOracleResult[] { + const evidence = recordValue(observation.metadata?.wordpressAdversarial) + const violations = Array.isArray(evidence?.violations) ? evidence.violations.flatMap((item) => typeof item === "string" ? [item] : []) : [] + return oracles.map((contract) => { + const matching = violations.filter((violation) => violationOracleId(violation) === contract.id) + return { + oracleId: contract.id, + failed: matching.length > 0, + ...(matching.length > 0 ? { code: matching[0], message: `${contract.description ?? contract.id} Evidence: ${matching.join(", ")}.`, evidence: { violations: matching } } : {}), + } + }) +} + +export function wordpressNoveltySignals(value: unknown, maximum = 128): string[] { + const evidence = recordValue(value) + const signals: string[] = [] + for (const category of ["hooks", "routes", "queries", "filesystem", "cache", "locks"] as const) { + const entries = Array.isArray(evidence?.[category]) ? evidence[category] : [] + for (const entry of entries.slice(0, 32)) signals.push(`${category}:${boundedFingerprint(entry)}`) + } + const metrics = recordValue(evidence?.metrics) + for (const name of ["memoryBytes", "cpuMs", "durationMs"] as const) { + const numeric = metrics?.[name] + if (typeof numeric === "number" && Number.isFinite(numeric)) signals.push(`${name}:${metricBucket(numeric)}`) + } + return [...new Set(signals)].sort().slice(0, Math.max(1, Math.min(maximum, 512))) +} + +function oracle(id: string, severity: AdversarialOracleContract["severity"], description: string): AdversarialOracleContract { + return { schema: ADVERSARIAL_ORACLE_SCHEMA, id, severity, description } +} + +function actionMetadata(action: WordPressAdversarialAction, capability: WordPressAdversarialCapability): Record { + return { ...action.metadata, adapter: WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA, surface: action.surface, fidelity: capability.fidelity, fidelityReason: capability.reason } +} + +function wordpressAdversarialHarnessPhp(action: WordPressAdversarialAction): string { + const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64") + return `$request = json_decode(base64_decode('${payload}'), true); +$started = microtime(true); $cpu_started = function_exists('getrusage') ? getrusage() : array(); $memory_started = memory_get_usage(true); +$result = array('schema' => 'wp-codebox/wordpress-adversarial-observation/v1', 'surface' => $request['surface'], 'operation' => $request['operation'], 'status' => 'ok', 'violations' => array()); +$input = $request['input'] ?? null; $target = isset($request['target']) ? (string) $request['target'] : ''; +try { + switch ($request['surface']) { + case 'rest': + $method = strtoupper((string) ($request['operation'] ?: 'GET')); $rest = new WP_REST_Request($method, $target); + if (is_array($input)) { $rest->set_body_params($input); } + $response = rest_do_request($rest); $result['response'] = array('status' => $response->get_status(), 'data' => $response->get_data()); break; + case 'block': + $blocks = parse_blocks((string) $input); $result['response'] = array('parsed' => count($blocks), 'serialized' => serialize_blocks($blocks), 'rendered' => do_blocks((string) $input)); break; + case 'shortcode': $result['response'] = do_shortcode((string) $input); break; + case 'serialized-value': $serialized = maybe_serialize($input); $result['response'] = array('serialized' => $serialized, 'roundTrip' => maybe_unserialize($serialized)); break; + case 'option': + if ($request['operation'] === 'delete') { $result['response'] = delete_option($target); } + elseif ($request['operation'] === 'read') { $result['response'] = get_option($target, null); } + else { $result['response'] = update_option($target, $input, false); } break; + case 'meta': + $object_id = (int) ($request['actor']['userId'] ?? 0); if ($object_id <= 0) { throw new RuntimeException('meta actions require actor.userId as the object id'); } + if ($request['operation'] === 'delete') { $result['response'] = delete_post_meta($object_id, $target); } + elseif ($request['operation'] === 'read') { $result['response'] = get_post_meta($object_id, $target, true); } + else { $result['response'] = update_post_meta($object_id, $target, $input); } break; + case 'file': + $uploads = wp_upload_dir(); $root = trailingslashit($uploads['basedir']) . 'wp-codebox-adversarial'; wp_mkdir_p($root); + $candidate = wp_normalize_path($root . '/' . ltrim($target, '/')); $root_normalized = trailingslashit(wp_normalize_path($root)); + if (strpos($candidate, $root_normalized) !== 0 || strpos($candidate, '..') !== false) { $result['status'] = 'denied'; $result['violations'][] = 'filesystem-escape-attempt'; break; } + wp_mkdir_p(dirname($candidate)); $result['response'] = array('bytes' => file_put_contents($candidate, is_string($input) ? $input : wp_json_encode($input)), 'relativePath' => substr($candidate, strlen($root_normalized))); break; + case 'cron': + $clock = is_array($input) ? (int) ($input['timestamp'] ?? time()) : time(); $args = is_array($input['args'] ?? null) ? $input['args'] : array(); + if ($request['operation'] === 'schedule') { $result['response'] = wp_schedule_single_event($clock, $target, $args, true); } + else { $executed = 0; foreach ((array) _get_cron_array() as $timestamp => $hooks) { if ((int) $timestamp > $clock || empty($hooks[$target])) { continue; } foreach ($hooks[$target] as $event) { $event_args = (array) ($event['args'] ?? array()); do_action_ref_array($target, $event_args); wp_unschedule_event((int) $timestamp, $target, $event_args); $executed++; } } $result['response'] = array('hook' => $target, 'clock' => $clock, 'executed' => $executed); } break; + case 'role-capability': + $role = get_role((string) ($request['actor']['role'] ?? '')); if (!$role) { throw new RuntimeException('role not found'); } + if ($request['operation'] === 'remove') { $role->remove_cap($target); } else { $role->add_cap($target, $input !== false); } $result['response'] = $role->has_cap($target); break; + case 'multisite-membership': + if (!is_multisite()) { $result['status'] = 'unsupported'; $result['reason'] = 'runtime-is-not-multisite'; break; } + $user_id = (int) ($request['actor']['userId'] ?? 0); $blog_id = (int) ($request['actor']['blogId'] ?? 0); + $result['response'] = $request['operation'] === 'remove' ? remove_user_from_blog($user_id, $blog_id) : add_user_to_blog($blog_id, $user_id, (string) ($request['actor']['role'] ?? 'subscriber')); break; + default: $result['status'] = 'unsupported'; $result['reason'] = 'surface-not-executable-in-process'; + } +} catch (Throwable $error) { $result['status'] = 'error'; $result['error'] = array('class' => get_class($error), 'message' => $error->getMessage()); } +$queries = array(); global $wpdb; foreach (array_slice((array) ($wpdb->queries ?? array()), -32) as $query) { $queries[] = preg_replace('/\\s+/', ' ', (string) ($query[0] ?? '')); } +$cpu_finished = function_exists('getrusage') ? getrusage() : array(); +$result['novelty'] = array('hooks' => array_slice(array_keys($GLOBALS['wp_filter'] ?? array()), 0, 64), 'routes' => array_slice(array_keys(rest_get_server()->get_routes()), 0, 64), 'queries' => $queries, 'filesystem' => array(), 'cache' => array(), 'locks' => array(), 'metrics' => array('memoryBytes' => max(0, memory_get_usage(true) - $memory_started), 'cpuMs' => max(0, (($cpu_finished['ru_utime.tv_usec'] ?? 0) - ($cpu_started['ru_utime.tv_usec'] ?? 0)) / 1000), 'durationMs' => round((microtime(true) - $started) * 1000, 3))); +echo wp_json_encode($result, JSON_UNESCAPED_SLASHES);` +} + +function violationOracleId(violation: string): string | undefined { + if (/authorization|nonce|tenant|membership/i.test(violation)) return "wordpress-authorization" + if (/injection|execution|xss|sql/i.test(violation)) return "wordpress-injection" + if (/partial-state|transaction|state-corruption/i.test(violation)) return "wordpress-transactional-consistency" + if (/duplicate|race|lost-update/i.test(violation)) return "wordpress-duplicate-effect" + if (/fail-open|service-failure/i.test(violation)) return "wordpress-fail-open" + if (/filesystem|path-escape/i.test(violation)) return "wordpress-filesystem-escape" + if (/secret|credential|token-leak/i.test(violation)) return "wordpress-secret-leakage" + return undefined +} + +function wordpressHttpFaultMuPluginPhp(): string { + return `= count($sequence) && ($rule['repeat'] ?? 'last') === 'cycle') $index = $index % count($sequence); + elseif ($index >= count($sequence) && ($rule['repeat'] ?? 'last') === 'last') $index = count($sequence) - 1; + elseif ($index >= count($sequence)) return $preempt; + $outcome = (array) ($sequence[$index] ?? array()); + if (isset($outcome['delayMs'])) usleep(min(1000000, max(0, (int) $outcome['delayMs']) * 1000)); + if (isset($outcome['timeoutMs'])) return new WP_Error('http_request_failed', 'Operation timed out (emulated at WordPress HTTP API boundary).'); + if (!empty($outcome['connection'])) return new WP_Error('http_request_failed', 'Connection failure emulated at WordPress HTTP API boundary.'); + $body = isset($outcome['body']) ? (string) $outcome['body'] : ''; + if (isset($outcome['truncateAfterBytes'])) $body = substr($body, 0, max(0, (int) $outcome['truncateAfterBytes'])); + if (!empty($outcome['malformed'])) return array('body' => $body); + return array('headers' => (array) ($outcome['headers'] ?? array()), 'body' => $body, 'response' => array('code' => (int) ($outcome['status'] ?? 200), 'message' => 'Adversarial response'), 'cookies' => array(), 'filename' => null); + } + return $preempt; +}, 10, 3);` +} + +function boundedFingerprint(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)?.slice(0, 4096) ?? "undefined").digest("hex").slice(0, 16) +} + +function metricBucket(value: number): string { + if (value <= 0) return "0" + return String(2 ** Math.floor(Math.log2(value))) +} + +function recordValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined +} diff --git a/tests/fixtures/wordpress-adversarial/vulnerable-plugin/vulnerable-plugin.php b/tests/fixtures/wordpress-adversarial/vulnerable-plugin/vulnerable-plugin.php new file mode 100644 index 00000000..134d85a8 --- /dev/null +++ b/tests/fixtures/wordpress-adversarial/vulnerable-plugin/vulnerable-plugin.php @@ -0,0 +1,121 @@ + 'POST', + 'permission_callback' => '__return_true', // Intentionally vulnerable: no authorization or nonce check. + 'callback' => static function (WP_REST_Request $request): WP_REST_Response { + update_option('wp_codebox_vulnerable_authorization', $request->get_json_params(), false); + return new WP_REST_Response(array('saved' => true, 'violations' => array('authorization-bypass', 'nonce-bypass'))); + }, + )); + + register_rest_route('wp-codebox-vulnerable/v1', '/injection', array( + 'methods' => 'POST', + 'permission_callback' => '__return_true', + 'callback' => static function (WP_REST_Request $request): WP_REST_Response { + $expression = (string) $request->get_param('expression'); + // Intentionally vulnerable. This fixture is mounted only in a disposable, network-denied runtime. + $result = eval('return ' . $expression . ';'); // phpcs:ignore Squiz.PHP.Eval.Discouraged + return new WP_REST_Response(array('result' => $result, 'violations' => array('code-execution-indicator'))); + }, + )); + + register_rest_route('wp-codebox-vulnerable/v1', '/partial-state', array( + 'methods' => 'POST', + 'permission_callback' => '__return_true', + 'callback' => static function (): WP_Error { + update_option('wp_codebox_vulnerable_partial_state', 'committed-before-error', false); + return new WP_Error('fixture_failure', 'The fixture failed after committing state.', array('status' => 500, 'violations' => array('partial-state-commit'))); + }, + )); + + register_rest_route('wp-codebox-vulnerable/v1', '/race', array( + 'methods' => 'POST', + 'permission_callback' => '__return_true', + 'callback' => static function (): WP_REST_Response { + $count = (int) get_option('wp_codebox_vulnerable_race_count', 0); + usleep(50000); // Intentionally widens the read-modify-write race window. + update_option('wp_codebox_vulnerable_race_count', $count + 1, false); + return new WP_REST_Response(array('count' => $count + 1, 'violations' => array('lost-update-race'))); + }, + )); + + register_rest_route('wp-codebox-vulnerable/v1', '/external-service', array( + 'methods' => 'POST', + 'permission_callback' => '__return_true', + 'callback' => static function (): WP_REST_Response { + $response = wp_remote_get('https://fixture.invalid/authorize', array('timeout' => 1)); + if (is_wp_error($response)) { + // Intentionally vulnerable: dependency failure is treated as authorization success. + return new WP_REST_Response(array('authorized' => true, 'violations' => array('external-service-fail-open'))); + } + return new WP_REST_Response(array('authorized' => 200 === wp_remote_retrieve_response_code($response))); + }, + )); +}); + +add_action('wp_ajax_wp_codebox_vulnerable_save', static function (): void { + // Intentionally vulnerable: no check_ajax_referer() or capability check. + update_option('wp_codebox_vulnerable_ajax', wp_unslash($_POST['value'] ?? ''), false); + wp_send_json_success(array('violations' => array('ajax-nonce-bypass'))); +}); + +add_shortcode('wp_codebox_vulnerable', static function (array $attributes): string { + // Intentionally vulnerable reflected markup for injection campaigns. + return '
' . ($attributes['value'] ?? '') . '
'; +}); + +add_action('admin_menu', static function (): void { + add_management_page('Vulnerable Fixture', 'Vulnerable Fixture', 'read', 'wp-codebox-vulnerable', static function (): void { + ?> +

Vulnerable Fixture

+ + 0 +
+ + $outcome['headers'] ?? array(), + 'body' => $body, + 'response' => array('code' => (int) ($outcome['status'] ?? 200), 'message' => 'Fixture response'), + 'cookies' => array(), + 'filename' => null, + ); +}, 10, 3); diff --git a/tests/fixtures/wordpress-adversarial/vulnerable-theme/functions.php b/tests/fixtures/wordpress-adversarial/vulnerable-theme/functions.php new file mode 100644 index 00000000..25d8a9da --- /dev/null +++ b/tests/fixtures/wordpress-adversarial/vulnerable-theme/functions.php @@ -0,0 +1,6 @@ +
diff --git a/tests/fixtures/wordpress-adversarial/vulnerable-theme/style.css b/tests/fixtures/wordpress-adversarial/vulnerable-theme/style.css new file mode 100644 index 00000000..3838aadc --- /dev/null +++ b/tests/fixtures/wordpress-adversarial/vulnerable-theme/style.css @@ -0,0 +1,4 @@ +/* +Theme Name: WP Codebox Adversarial Vulnerable Fixture +Version: 0.0.0 +*/ diff --git a/tests/wordpress-adversarial-adapter.test.ts b/tests/wordpress-adversarial-adapter.test.ts new file mode 100644 index 00000000..ce0643ca --- /dev/null +++ b/tests/wordpress-adversarial-adapter.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict" + +import { transportFaultModel, type AdversarialCasePlan } from "../packages/runtime-core/src/index.js" +import { + WORDPRESS_ADVERSARIAL_CAPABILITIES, + WORDPRESS_ADVERSARIAL_ORACLES, + WORDPRESS_CLOCK_CONTROL_CAPABILITIES, + createWordPressAdversarialAdapter, + evaluateWordPressAdversarialOracles, + negotiateWordPressHttpTransportFaults, + wordpressAdversarialActionSpec, + wordpressHttpFaultConfigurationAction, + wordpressNoveltySignals, + wordpressSchedulerClockAction, +} from "../packages/runtime-playground/src/wordpress-adversarial-adapter.js" + +const adapter = createWordPressAdversarialAdapter() +assert.equal(adapter.schema, "wp-codebox/wordpress-adversarial-adapter/v1") +assert.equal(WORDPRESS_ADVERSARIAL_CAPABILITIES.find(({ surface }) => surface === "rest")?.fidelity, "exact") +assert.equal(WORDPRESS_ADVERSARIAL_CAPABILITIES.find(({ surface }) => surface === "ajax")?.fidelity, "unsupported") +assert.equal(WORDPRESS_CLOCK_CONTROL_CAPABILITIES.capabilities.find(({ surface }) => surface === "runtime")?.fidelity, "unsupported") +assert.equal(WORDPRESS_CLOCK_CONTROL_CAPABILITIES.capabilities.find(({ surface }) => surface === "scheduler")?.fidelity, "emulated") + +const rest = wordpressAdversarialActionSpec({ surface: "rest", operation: "POST", target: "/fixture/v1/action", input: { value: "mutated" } }) +assert.equal(rest.command, "wordpress.run-php") +assert.equal(rest.metadata?.fidelity, "exact") +assert.match(rest.args?.[0] ?? "", /WP_REST_Request/) +assert.throws(() => wordpressAdversarialActionSpec({ surface: "xmlrpc", operation: "POST" }), /unsupported/) + +const cli = wordpressAdversarialActionSpec({ surface: "cli", operation: "run", input: "option get home" }) +assert.deepEqual(cli.args, ["command=option get home"]) +const scheduler = wordpressSchedulerClockAction(1900000000, "fixture_hook") +assert.equal(scheduler.operation, "adversarial:cron") +assert.equal(scheduler.metadata?.fidelity, "exact") + +const emulated = negotiateWordPressHttpTransportFaults(transportFaultModel({ + seed: "faults", + rules: [{ id: "timeout", match: { host: "fixture.invalid" }, sequence: [{ timeoutMs: 100 }] }], +})) +assert.equal(emulated.supported, true) +assert.equal(emulated.capabilities.capabilities.find(({ semantic }) => semantic === "timeout")?.fidelity, "emulated") +const configuredFaults = transportFaultModel({ seed: "configured", rules: [{ id: "response", match: { host: "fixture.invalid" }, sequence: [{ status: 503, body: "down" }] }] }) +assert.match(wordpressHttpFaultConfigurationAction(configuredFaults).args?.[0] ?? "", /WPMU_PLUGIN_DIR/) + +const unsupported = negotiateWordPressHttpTransportFaults(transportFaultModel({ + seed: "faults", + rules: [{ id: "half-close", match: { host: "fixture.invalid" }, sequence: [{ connection: "half-close" }] }], +})) +assert.equal(unsupported.supported, false) +assert.deepEqual(unsupported.unsupported.map(({ semantic }) => semantic), ["half-close"]) + +const plan = { caseId: "case", corpusId: "seed", iteration: 0, workerId: 0, matrix: {}, mutation: { kind: "scalar", path: "$", description: "test" }, id: "case", actions: [] } as AdversarialCasePlan +const oracleResults = evaluateWordPressAdversarialOracles(plan, { + status: "passed", + metadata: { wordpressAdversarial: { violations: ["nonce-bypass", "partial-state-commit", "token-leak"] } }, +}, WORDPRESS_ADVERSARIAL_ORACLES) +assert.deepEqual(oracleResults.filter(({ failed }) => failed).map(({ oracleId }) => oracleId), ["wordpress-authorization", "wordpress-transactional-consistency", "wordpress-secret-leakage"]) + +const novelty = wordpressNoveltySignals({ + hooks: ["init", "rest_api_init", "secret-value-is-hashed"], + routes: ["/fixture/v1/action"], + queries: ["SELECT * FROM wp_posts WHERE ID = 1"], + filesystem: ["uploads/fixture.txt"], + cache: ["fixture:key"], + locks: ["fixture-lock"], + metrics: { memoryBytes: 4097, cpuMs: 4.2, durationMs: 17 }, +}) +assert(novelty.some((signal) => signal === "memoryBytes:4096")) +assert(novelty.every((signal) => !signal.includes("secret-value"))) +assert(novelty.length <= 128) + +console.log("wordpress adversarial adapter ok")