Plugin Directory

Changeset 3460160


Ignore:
Timestamp:
02/12/2026 04:40:35 PM (3 weeks ago)
Author:
shift8
Message:

Malware scanning autonomy, improved testing, bug fixes

Location:
atomic-edge-security/trunk
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • atomic-edge-security/trunk/atomicedge.php

    r3459867 r3460160  
    44 * Plugin URI: https://atomicedge.io/wordpress
    55 * Description: Connect your WordPress site to Atomic Edge WAF/CDN for advanced security protection, analytics, and access control management.
    6  * Version: 2.2.2
     6 * Version: 2.3.0
    77 * Requires at least: 5.8
    88 * Requires PHP: 7.4
     
    2626
    2727// Plugin constants.
    28 define( 'ATOMICEDGE_VERSION', '2.2.2' );
     28define( 'ATOMICEDGE_VERSION', '2.3.0' );
    2929define( 'ATOMICEDGE_PLUGIN_FILE', __FILE__ );
    3030define( 'ATOMICEDGE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • atomic-edge-security/trunk/includes/class-atomicedge-api.php

    r3459867 r3460160  
    5454     * Get the decrypted API key.
    5555     *
     56     * Handles migration from unencrypted to encrypted storage:
     57     * - If decryption fails and the stored value looks like a raw 64-char hex key,
     58     *   it will be re-encrypted and stored properly.
     59     *
    5660     * @return string|false API key or false if not set.
    5761     */
    5862    public function get_api_key() {
    59         $encrypted = get_option( 'atomicedge_api_key', '' );
    60         if ( empty( $encrypted ) ) {
     63        $stored = get_option( 'atomicedge_api_key', '' );
     64        if ( empty( $stored ) ) {
    6165            return false;
    6266        }
    63         return $this->decrypt_api_key( $encrypted );
     67
     68        // Try normal decryption first.
     69        $decrypted = $this->decrypt_api_key( $stored );
     70
     71        if ( false !== $decrypted && ! empty( $decrypted ) ) {
     72            return $decrypted;
     73        }
     74
     75        // Decryption failed - check if this is a raw hex key that wasn't encrypted.
     76        // Valid API keys are 64-character alphanumeric strings.
     77        if ( preg_match( '/^[a-f0-9]{64}$/i', $stored ) ) {
     78            // This is a raw key - re-encrypt it properly for future use.
     79            AtomicEdge::log( 'Migrating unencrypted API key to encrypted storage' );
     80            update_option( 'atomicedge_api_key', $this->encrypt_api_key( $stored ) );
     81            return $stored;
     82        }
     83
     84        // Neither valid encryption nor valid raw key format.
     85        return false;
    6486    }
    6587
     
    746768
    747769    /**
     770     * Make an unauthenticated request to a public API endpoint.
     771     *
     772     * This is used for endpoints that don't require an API key, such as
     773     * malware signatures, which should be available before site registration.
     774     *
     775     * @param string $method   HTTP method (GET, POST, etc.).
     776     * @param string $endpoint API endpoint (e.g., '/wp/public/malware-signatures').
     777     * @param array  $data     Request data.
     778     * @return array Response with success status and data/error.
     779     */
     780    private function public_request( $method, $endpoint, $data = array() ) {
     781        // Public endpoints use the base API URL (not under /v1 which requires auth).
     782        $base_url = preg_replace( '#/api/v1/?$#', '/api/v1', $this->api_url );
     783        $url      = $base_url . $endpoint;
     784
     785        // Add query params for GET requests.
     786        if ( 'GET' === $method && ! empty( $data ) ) {
     787            $url = add_query_arg( $data, $url );
     788        }
     789
     790        $args = array(
     791            'method'  => $method,
     792            'timeout' => $this->timeout,
     793            'headers' => array(
     794                'Content-Type' => 'application/json',
     795                'Accept'       => 'application/json',
     796            ),
     797        );
     798
     799        // Add body for non-GET requests.
     800        if ( 'GET' !== $method && ! empty( $data ) ) {
     801            $args['body'] = wp_json_encode( $data );
     802        }
     803
     804        AtomicEdge::log( "Public API Request: {$method} {$endpoint}" );
     805
     806        $response = wp_remote_request( $url, $args );
     807
     808        // Check for WP error.
     809        if ( is_wp_error( $response ) ) {
     810            AtomicEdge::log( 'Public API Error', $response->get_error_message() );
     811            return array(
     812                'success' => false,
     813                'error'   => $response->get_error_message(),
     814            );
     815        }
     816
     817        $code = wp_remote_retrieve_response_code( $response );
     818        $body = wp_remote_retrieve_body( $response );
     819        $data = json_decode( $body, true );
     820
     821        // Handle HTTP errors.
     822        if ( $code >= 400 ) {
     823            $error_message = isset( $data['error'] ) ? $data['error'] : __( 'An error occurred.', 'atomic-edge-security' );
     824            if ( isset( $data['message'] ) ) {
     825                $error_message = $data['message'];
     826            }
     827            AtomicEdge::log( "Public API Error ({$code})", $error_message );
     828            return array(
     829                'success' => false,
     830                'error'   => $error_message,
     831                'code'    => $code,
     832            );
     833        }
     834
     835        // Extract nested data if API returns standard response format.
     836        if ( isset( $data['success'] ) && true === $data['success'] && isset( $data['data'] ) ) {
     837            return array(
     838                'success' => true,
     839                'data'    => $data['data'],
     840            );
     841        }
     842
     843        // Handle API-level errors.
     844        if ( isset( $data['success'] ) && false === $data['success'] ) {
     845            $error_message = isset( $data['message'] ) ? $data['message'] : __( 'An error occurred.', 'atomic-edge-security' );
     846            if ( isset( $data['error'] ) ) {
     847                $error_message = $data['error'];
     848            }
     849            return array(
     850                'success' => false,
     851                'error'   => $error_message,
     852            );
     853        }
     854
     855        // Fallback for non-standard responses.
     856        return array(
     857            'success' => true,
     858            'data'    => $data,
     859        );
     860    }
     861
     862    /**
    748863     * Get normalized site URL (without protocol and www).
    749864     *
     
    810925     * The cache is refreshed every 24 hours or when manually cleared.
    811926     *
     927     * This endpoint is PUBLIC and does not require an API key, allowing
     928     * users to scan their site before registering with AtomicEdge.
     929     *
    812930     * @param bool $force_refresh Force a refresh from the API.
    813931     * @return array|false Signature data or false on error.
     
    824942        }
    825943
    826         // Fetch from API.
    827         $response = $this->request( 'GET', '/wp/malware-signatures' );
     944        // Fetch from public API endpoint (no authentication required).
     945        $response = $this->public_request( 'GET', '/wp/public/malware-signatures' );
    828946
    829947        if ( ! $response['success'] || empty( $response['data'] ) ) {
  • atomic-edge-security/trunk/includes/class-atomicedge-scanner.php

    r3459867 r3460160  
    326326                'wp-admin' => array( 'php_files_found' => 0, 'php_files_scanned' => 0 ),
    327327                'wp-includes' => array( 'php_files_found' => 0, 'php_files_scanned' => 0 ),
     328                'wp-content' => array( 'php_files_found' => 0, 'php_files_scanned' => 0 ),
    328329                'uploads' => array( 'php_files_found' => 0, 'php_files_scanned' => 0 ),
    329330                'themes' => array( 'php_files_found' => 0, 'php_files_scanned' => 0 ),
    330331                'plugins' => array( 'php_files_found' => 0, 'php_files_scanned' => 0 ),
     332                'unknown' => array( 'php_files_found' => 0, 'php_files_scanned' => 0 ),
    331333            ),
    332334            // Performance timing (milliseconds).
     
    15521554        $this->enqueue_queue_item( $run_id, 'dir', 'plugins', $mu_plugins_dir, array() );
    15531555
    1554         // Thorough scan: include core directories and uploads checks.
     1556        // Thorough scan: discover ALL directories under WordPress root.
     1557        // This catches malware hidden in arbitrary folders like /hidden-backdoor/ or /cache-evil/.
    15551558        if ( 'all' === $scan_mode ) {
    1556             $this->enqueue_queue_item( $run_id, 'dir', 'wp-admin', $admin_dir, array() );
    1557             $this->enqueue_queue_item( $run_id, 'dir', 'wp-includes', $includes_dir, array() );
    1558             $this->enqueue_queue_item( $run_id, 'dir', 'uploads', $uploads_dir, array() );
    1559         }
     1559            $this->discover_all_directories( $run_id, $root_path, $plugins_dir, $themes_dir, $mu_plugins_dir );
     1560        }
     1561    }
     1562
     1563    /**
     1564     * Discover and enqueue all directories under WordPress root for thorough scanning.
     1565     *
     1566     * This prevents malware from hiding in arbitrary folders that aren't part of
     1567     * the standard WordPress directory structure. Every PHP file anywhere in the
     1568     * WordPress installation will be scanned.
     1569     *
     1570     * @param string $run_id Run ID.
     1571     * @param string $root_path WordPress root path.
     1572     * @param string $plugins_dir Plugins directory (already scanned in quick mode).
     1573     * @param string $themes_dir Themes directory (already scanned in quick mode).
     1574     * @param string $mu_plugins_dir MU-plugins directory (already scanned in quick mode).
     1575     * @return void
     1576     */
     1577    private function discover_all_directories( $run_id, $root_path, $plugins_dir, $themes_dir, $mu_plugins_dir ) {
     1578        // Directories that were already enqueued in quick scan or should be skipped entirely.
     1579        $already_queued = array(
     1580            rtrim( $plugins_dir, '/' ),
     1581            rtrim( $themes_dir, '/' ),
     1582            rtrim( $mu_plugins_dir, '/' ),
     1583        );
     1584
     1585        // Directories to skip completely (non-PHP or handled separately).
     1586        $skip_dirs = array(
     1587            '.git',
     1588            '.svn',
     1589            '.hg',
     1590            'node_modules',
     1591            'vendor',
     1592        );
     1593
     1594        // Known WordPress directories that get specific area tags.
     1595        $known_areas = array(
     1596            'wp-admin'    => 'wp-admin',
     1597            'wp-includes' => 'wp-includes',
     1598            'wp-content'  => 'wp-content', // Generic wp-content subdirs.
     1599        );
     1600
     1601        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1602        $handle = @opendir( $root_path );
     1603        if ( ! $handle ) {
     1604            return;
     1605        }
     1606
     1607        while ( false !== ( $entry = readdir( $handle ) ) ) {
     1608            if ( '.' === $entry || '..' === $entry ) {
     1609                continue;
     1610            }
     1611
     1612            $full_path = rtrim( $root_path, '/' ) . '/' . $entry;
     1613
     1614            // Skip if not a directory or is a symlink.
     1615            if ( ! is_dir( $full_path ) || is_link( $full_path ) ) {
     1616                continue;
     1617            }
     1618
     1619            // Skip known-excluded directories.
     1620            if ( in_array( $entry, $skip_dirs, true ) ) {
     1621                continue;
     1622            }
     1623
     1624            // Skip if already queued (plugins, themes, mu-plugins).
     1625            if ( in_array( rtrim( $full_path, '/' ), $already_queued, true ) ) {
     1626                continue;
     1627            }
     1628
     1629            // Determine the area tag based on directory name.
     1630            $area = 'unknown';
     1631            if ( isset( $known_areas[ $entry ] ) ) {
     1632                $area = $known_areas[ $entry ];
     1633            }
     1634
     1635            // For wp-content, recursively discover subdirectories.
     1636            if ( 'wp-content' === $entry ) {
     1637                $this->discover_wp_content_directories( $run_id, $full_path, $already_queued );
     1638            } else {
     1639                // Enqueue this top-level directory for scanning.
     1640                $this->enqueue_queue_item( $run_id, 'dir', $area, $full_path, array() );
     1641            }
     1642        }
     1643
     1644        closedir( $handle );
     1645    }
     1646
     1647    /**
     1648     * Discover and enqueue directories within wp-content that weren't already queued.
     1649     *
     1650     * This catches non-standard directories like:
     1651     * - wp-content/cache-malware/
     1652     * - wp-content/hidden-folder/
     1653     * - wp-content/languages/ (if contains PHP, which is suspicious)
     1654     *
     1655     * @param string $run_id Run ID.
     1656     * @param string $wp_content_path Path to wp-content.
     1657     * @param array  $already_queued Directories already queued (plugins, themes, mu-plugins).
     1658     * @return void
     1659     */
     1660    private function discover_wp_content_directories( $run_id, $wp_content_path, $already_queued ) {
     1661        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1662        $handle = @opendir( $wp_content_path );
     1663        if ( ! $handle ) {
     1664            return;
     1665        }
     1666
     1667        // Known wp-content subdirectories and their area tags.
     1668        $known_wp_content_areas = array(
     1669            'plugins'    => 'plugins',    // Already handled, will be skipped.
     1670            'themes'     => 'themes',     // Already handled, will be skipped.
     1671            'mu-plugins' => 'plugins',    // Already handled, will be skipped.
     1672            'uploads'    => 'uploads',
     1673            'upgrade'    => 'wp-content',
     1674            'cache'      => 'wp-content', // Could contain PHP from caching plugins.
     1675            'languages'  => 'wp-content', // Normally only .mo/.po files, but PHP here is suspicious.
     1676        );
     1677
     1678        while ( false !== ( $entry = readdir( $handle ) ) ) {
     1679            if ( '.' === $entry || '..' === $entry ) {
     1680                continue;
     1681            }
     1682
     1683            $full_path = rtrim( $wp_content_path, '/' ) . '/' . $entry;
     1684
     1685            // Skip if not a directory or is a symlink.
     1686            if ( ! is_dir( $full_path ) || is_link( $full_path ) ) {
     1687                continue;
     1688            }
     1689
     1690            // Skip if already queued (plugins, themes, mu-plugins).
     1691            if ( in_array( rtrim( $full_path, '/' ), $already_queued, true ) ) {
     1692                continue;
     1693            }
     1694
     1695            // Determine the area tag.
     1696            $area = isset( $known_wp_content_areas[ $entry ] ) ? $known_wp_content_areas[ $entry ] : 'unknown';
     1697
     1698            // Enqueue for scanning.
     1699            $this->enqueue_queue_item( $run_id, 'dir', $area, $full_path, array() );
     1700        }
     1701
     1702        closedir( $handle );
    15601703    }
    15611704
     
    19692112
    19702113        $is_uploads = ( 'uploads' === $area );
     2114        $is_unknown_area = in_array( $area, array( 'unknown', 'wp-content' ), true );
    19712115        $pattern_groups = $this->get_malware_patterns();
    19722116        if ( in_array( $area, array( 'wp-admin', 'wp-includes' ), true ) ) {
     
    19752119            $pattern_groups = $this->get_refined_patterns_for_plugins();
    19762120        }
     2121        // Unknown areas and wp-content subdirs get full pattern matching (highest suspicion).
    19772122
    19782123        $result = $this->scan_file_for_patterns( $filepath, $pattern_groups, $is_uploads );
     
    19842129            } elseif ( 'wp-includes' === $area ) {
    19852130                $result['location_note'] = __( 'Suspicious pattern in wp-includes', 'atomic-edge-security' );
     2131            } elseif ( 'unknown' === $area ) {
     2132                $result['location_note'] = __( 'Suspicious file in non-standard directory (possible hidden malware)', 'atomic-edge-security' );
     2133                $result['severity'] = 'critical'; // Elevate severity for unknown directories.
     2134            } elseif ( 'wp-content' === $area ) {
     2135                $result['location_note'] = __( 'Suspicious pattern in non-standard wp-content subdirectory', 'atomic-edge-security' );
    19862136            }
    19872137            $state['results']['suspicious'][] = $result;
     
    19932143                'severity' => 'high',
    19942144                'reason'   => __( 'PHP file found in uploads directory', 'atomic-edge-security' ),
     2145            );
     2146        } elseif ( $is_unknown_area ) {
     2147            // Any PHP file in an unknown directory is suspicious, even without pattern matches.
     2148            $state['results']['suspicious'][] = array(
     2149                'file'      => $relative_path,
     2150                'file_path' => $filepath,
     2151                'type'      => 'php_in_unknown_dir',
     2152                'severity'  => 'high',
     2153                'reason'    => __( 'PHP file found in non-standard directory (possible hidden malware)', 'atomic-edge-security' ),
    19952154            );
    19962155        }
     
    22052364
    22062365        $is_uploads = ( 'uploads' === $area );
     2366        $is_unknown_area = in_array( $area, array( 'unknown', 'wp-content' ), true );
    22072367        $pattern_groups = $this->get_malware_patterns();
    22082368        if ( in_array( $area, array( 'wp-admin', 'wp-includes' ), true ) ) {
     
    22112371            $pattern_groups = $this->get_refined_patterns_for_plugins();
    22122372        }
     2373        // Unknown areas and wp-content subdirs get full pattern matching (highest suspicion).
    22132374
    22142375        $result = $this->scan_file_for_patterns( $filepath, $pattern_groups, $is_uploads );
     
    22202381            } elseif ( 'wp-includes' === $area ) {
    22212382                $result['location_note'] = __( 'Suspicious pattern in wp-includes', 'atomic-edge-security' );
     2383            } elseif ( 'unknown' === $area ) {
     2384                $result['location_note'] = __( 'Suspicious file in non-standard directory (possible hidden malware)', 'atomic-edge-security' );
     2385                $result['severity'] = 'critical'; // Elevate severity for unknown directories.
     2386            } elseif ( 'wp-content' === $area ) {
     2387                $result['location_note'] = __( 'Suspicious pattern in non-standard wp-content subdirectory', 'atomic-edge-security' );
    22222388            }
    22232389            $state['results']['suspicious'][] = $result;
     
    22292395                'severity' => 'high',
    22302396                'reason'   => __( 'PHP file found in uploads directory', 'atomic-edge-security' ),
     2397            );
     2398        } elseif ( $is_unknown_area ) {
     2399            // Any PHP file in an unknown directory is suspicious, even without pattern matches.
     2400            $state['results']['suspicious'][] = array(
     2401                'file'      => $relative_path,
     2402                'file_path' => $filepath,
     2403                'type'      => 'php_in_unknown_dir',
     2404                'severity'  => 'high',
     2405                'reason'    => __( 'PHP file found in non-standard directory (possible hidden malware)', 'atomic-edge-security' ),
    22312406            );
    22322407        }
  • atomic-edge-security/trunk/readme.txt

    r3459867 r3460160  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 2.2.2
     7Stable tag: 2.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    110110
    111111== Changelog ==
     112
     113= 2.3.0 =
     114* NEW: Malware scanner signatures now fetched from public API (no API key required)
     115* This allows users to scan their site before registering with Atomic Edge
     116* FIX: API key migration for users who had raw keys stored (automatic re-encryption on load)
     117* IMPROVED: Test coverage for scanner with mocked API signatures
    112118
    113119= 2.2.2 =
Note: See TracChangeset for help on using the changeset viewer.