|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * Client script trust policy for imported website artifacts. |
| 4 | + * |
| 5 | + * @package StaticSiteImporter |
| 6 | + */ |
| 7 | + |
| 8 | +if ( ! defined( 'ABSPATH' ) ) { |
| 9 | + exit; |
| 10 | +} |
| 11 | + |
| 12 | +/** Applies an explicit, provenance-bound client-script policy before compilation. */ |
| 13 | +class Static_Site_Importer_Client_Script_Policy { |
| 14 | + /** |
| 15 | + * Make executable client code inert unless an isolated preview explicitly opts in. |
| 16 | + * |
| 17 | + * @return array{artifact:array<string,mixed>,report:array<string,mixed>} |
| 18 | + */ |
| 19 | + public static function apply( array $artifact, array $args ): array { |
| 20 | + $policy = self::policy_name( $args ); |
| 21 | + $provenance = self::provenance( $args ); |
| 22 | + $preserve = 'isolated_preview' === $policy && ! empty( $args['client_script_isolated'] ) && '' !== $provenance; |
| 23 | + $report = array( |
| 24 | + 'schema' => 'static-site-importer/client-script-policy-report/v1', |
| 25 | + 'policy' => $preserve ? 'isolated_preview' : 'inert', |
| 26 | + 'trust' => 'untrusted_imported_code', |
| 27 | + 'provenance' => $preserve ? $provenance : '', |
| 28 | + 'dropped' => array(), |
| 29 | + 'quarantined' => array(), |
| 30 | + 'preserved' => array(), |
| 31 | + ); |
| 32 | + $files = isset( $artifact['files'] ) && is_array( $artifact['files'] ) ? $artifact['files'] : array(); |
| 33 | + $filtered = array(); |
| 34 | + |
| 35 | + foreach ( $files as $file ) { |
| 36 | + if ( ! is_array( $file ) ) { |
| 37 | + continue; |
| 38 | + } |
| 39 | + $path = isset( $file['path'] ) && is_scalar( $file['path'] ) ? (string) $file['path'] : ''; |
| 40 | + if ( self::is_script_file( $file ) ) { |
| 41 | + self::record( $report, $preserve ? 'preserved' : 'dropped', self::file_row( $path, $file ) ); |
| 42 | + if ( ! $preserve ) { |
| 43 | + continue; |
| 44 | + } |
| 45 | + } |
| 46 | + if ( self::is_html_file( $file ) ) { |
| 47 | + $file['content'] = self::filter_html( (string) ( $file['content'] ?? '' ), $path, $preserve, $report ); |
| 48 | + } |
| 49 | + $filtered[] = $file; |
| 50 | + } |
| 51 | + |
| 52 | + $artifact['files'] = $filtered; |
| 53 | + return array( 'artifact' => $artifact, 'report' => $report ); |
| 54 | + } |
| 55 | + |
| 56 | + private static function policy_name( array $args ): string { |
| 57 | + return 'isolated_preview' === (string) ( $args['client_script_policy'] ?? '' ) ? 'isolated_preview' : 'inert'; |
| 58 | + } |
| 59 | + |
| 60 | + private static function provenance( array $args ): string { |
| 61 | + $provenance = $args['client_script_provenance'] ?? null; |
| 62 | + if ( is_scalar( $provenance ) ) { |
| 63 | + return trim( (string) $provenance ); |
| 64 | + } |
| 65 | + if ( is_array( $provenance ) && isset( $provenance['ref'] ) && is_scalar( $provenance['ref'] ) ) { |
| 66 | + return trim( (string) $provenance['ref'] ); |
| 67 | + } |
| 68 | + return ''; |
| 69 | + } |
| 70 | + |
| 71 | + private static function is_html_file( array $file ): bool { |
| 72 | + $path = strtolower( (string) ( $file['path'] ?? '' ) ); |
| 73 | + $mime = strtolower( (string) ( $file['mime_type'] ?? '' ) ); |
| 74 | + return str_ends_with( $path, '.html' ) || str_ends_with( $path, '.htm' ) || str_contains( $mime, 'html' ); |
| 75 | + } |
| 76 | + |
| 77 | + private static function is_script_file( array $file ): bool { |
| 78 | + $path = strtolower( (string) ( $file['path'] ?? '' ) ); |
| 79 | + $mime = strtolower( (string) ( $file['mime_type'] ?? '' ) ); |
| 80 | + return (bool) preg_match( '/\.(?:js|mjs|cjs)$/', $path ) || str_contains( $mime, 'javascript' ) || str_contains( $mime, 'ecmascript' ); |
| 81 | + } |
| 82 | + |
| 83 | + private static function filter_html( string $html, string $path, bool $preserve, array &$report ): string { |
| 84 | + return (string) preg_replace_callback( |
| 85 | + '#<script\b([^>]*)>(.*?)</script\s*>#is', |
| 86 | + static function ( array $matches ) use ( $path, $preserve, &$report ): string { |
| 87 | + $attributes = $matches[1]; |
| 88 | + $source = self::attribute( $attributes, 'src' ); |
| 89 | + $type = strtolower( trim( (string) self::attribute( $attributes, 'type' ) ) ); |
| 90 | + $row = array( |
| 91 | + 'path' => $path, |
| 92 | + 'class' => self::script_class( $source, $type, $matches[2] ), |
| 93 | + 'type' => '' !== $type ? $type : 'classic', |
| 94 | + 'sha256' => hash( 'sha256', $matches[0] ), |
| 95 | + ); |
| 96 | + if ( null !== $source ) { |
| 97 | + $row['src'] = $source; |
| 98 | + } |
| 99 | + if ( $preserve ) { |
| 100 | + self::record( $report, 'preserved', $row ); |
| 101 | + return $matches[0]; |
| 102 | + } |
| 103 | + self::record( $report, 'data' === $row['class'] ? 'quarantined' : 'dropped', $row ); |
| 104 | + return ''; |
| 105 | + }, |
| 106 | + $html |
| 107 | + ); |
| 108 | + } |
| 109 | + |
| 110 | + private static function attribute( string $attributes, string $name ): ?string { |
| 111 | + if ( ! preg_match( '/\s' . preg_quote( $name, '/' ) . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $attributes, $matches ) ) { |
| 112 | + return null; |
| 113 | + } |
| 114 | + return '' !== (string) ( $matches[1] ?? '' ) ? $matches[1] : ( '' !== (string) ( $matches[2] ?? '' ) ? $matches[2] : (string) ( $matches[3] ?? '' ) ); |
| 115 | + } |
| 116 | + |
| 117 | + private static function script_class( ?string $source, string $type, string $content ): string { |
| 118 | + if ( in_array( $type, array( 'application/json', 'application/ld+json', 'application/manifest+json' ), true ) || ( null !== $source && str_starts_with( strtolower( $source ), 'data:' ) ) ) { |
| 119 | + return 'data'; |
| 120 | + } |
| 121 | + if ( 'module' === $type ) { |
| 122 | + return 'module'; |
| 123 | + } |
| 124 | + if ( preg_match( '/(?:google-analytics|googletagmanager|gtag\s*\(|segment\.|mixpanel|hotjar|clarity|sentry|telemetry|analytics)/i', (string) $source . "\n" . $content ) ) { |
| 125 | + return 'telemetry'; |
| 126 | + } |
| 127 | + if ( null === $source ) { |
| 128 | + return 'inline'; |
| 129 | + } |
| 130 | + return preg_match( '#^(?:https?:)?//#i', $source ) ? 'remote' : 'local'; |
| 131 | + } |
| 132 | + |
| 133 | + private static function file_row( string $path, array $file ): array { |
| 134 | + return array( |
| 135 | + 'path' => $path, |
| 136 | + 'class' => 'local', |
| 137 | + 'type' => 'asset', |
| 138 | + 'sha256' => hash( 'sha256', (string) ( $file['content'] ?? '' ) ), |
| 139 | + ); |
| 140 | + } |
| 141 | + |
| 142 | + private static function record( array &$report, string $disposition, array $row ): void { |
| 143 | + $report[ $disposition ][] = $row; |
| 144 | + } |
| 145 | +} |
0 commit comments