to_array(); if ( 'Pass' === $data['level'] || 'Correlation' === $data['category'] ) { continue; } $ref = array( 'category' => (string) $data['category'], 'location' => (string) $data['location'], ); foreach ( (array) $data['artifacts'] as $artifact ) { $artifact = strtolower( trim( (string) $artifact ) ); if ( '' !== $artifact && $this->meaningful_artifact( $artifact ) ) { $artifact_map[ $artifact ][] = $ref; } } foreach ( (array) $data['identifiers'] as $identifier ) { $identifier = strtolower( trim( (string) $identifier ) ); if ( $this->meaningful_identifier( $identifier ) ) { $identifier_map[ $identifier ][] = $ref; } } } $candidates = array(); foreach ( $artifact_map as $artifact => $refs ) { $candidate = $this->candidate( 'artifact', $artifact, $refs ); if ( null !== $candidate ) { $candidates[] = $candidate; } } foreach ( $identifier_map as $identifier => $refs ) { $candidate = $this->candidate( 'identifier', $identifier, $refs ); if ( null !== $candidate ) { $candidates[] = $candidate; } } if ( empty( $candidates ) ) { return array(); } return $this->aggregate_candidates( $candidates ); } private function candidate( $type, $needle, array $refs ) { $categories = array_values( array_unique( wp_list_pluck( $refs, 'category' ) ) ); if ( count( $categories ) < 2 ) { return null; } return array( 'type' => $type, 'needle' => $needle, 'categories' => $categories, 'locations' => array_values( array_unique( array_filter( wp_list_pluck( $refs, 'location' ) ) ) ), ); } private function aggregate_candidates( array $candidates ) { $groups = array(); foreach ( $candidates as $candidate ) { $matched = array(); foreach ( $groups as $index => $group ) { if ( $this->same_chain( $group, $candidate ) ) { $matched[] = $index; } } if ( empty( $matched ) ) { $groups[] = array( 'categories' => $candidate['categories'], 'locations' => $candidate['locations'], 'members' => array( $candidate ), ); continue; } $base = array_shift( $matched ); $groups[ $base ] = $this->merge_group_candidate( $groups[ $base ], $candidate ); rsort( $matched ); foreach ( $matched as $index ) { $groups[ $base ] = $this->merge_groups( $groups[ $base ], $groups[ $index ] ); array_splice( $groups, $index, 1 ); if ( $index < $base ) { $base--; } } } $out = array(); foreach ( $groups as $group ) { $out[] = $this->finding_from_group( $group ); } return $out; } private function same_chain( array $group, array $candidate ) { $shared_locations = array_intersect( $group['locations'], $candidate['locations'] ); if ( empty( $shared_locations ) ) { return false; } // A shared location plus a shared persistence layer is enough to treat // related identifiers/artifacts as evidence belonging to one chain. return ! empty( array_intersect( $group['categories'], $candidate['categories'] ) ); } private function merge_group_candidate( array $group, array $candidate ) { $group['categories'] = array_values( array_unique( array_merge( $group['categories'], $candidate['categories'] ) ) ); $group['locations'] = array_values( array_unique( array_merge( $group['locations'], $candidate['locations'] ) ) ); $group['members'][] = $candidate; return $group; } private function merge_groups( array $left, array $right ) { $left['categories'] = array_values( array_unique( array_merge( $left['categories'], $right['categories'] ) ) ); $left['locations'] = array_values( array_unique( array_merge( $left['locations'], $right['locations'] ) ) ); $left['members'] = array_merge( $left['members'], $right['members'] ); return $left; } private function finding_from_group( array $group ) { $category_count = count( $group['categories'] ); $level = $category_count >= 3 ? 'Warning' : 'Review'; $identifiers = array(); $artifacts = array(); $relationships = array(); foreach ( $group['members'] as $member ) { $relationships[] = $member['type'] . ':' . $member['needle']; if ( 'identifier' === $member['type'] ) { $identifiers[] = $member['needle']; } else { $artifacts[] = $this->display_value( $member['needle'] ); } } $identifiers = array_values( array_unique( $identifiers ) ); $artifacts = array_values( array_unique( $artifacts ) ); $chain_label = ! empty( $identifiers ) ? $identifiers[0] : ( ! empty( $artifacts ) ? $artifacts[0] : 'cross-layer relationship' ); $explanation = $category_count >= 3 ? __( 'Related persistence indicators were observed across three or more layers and were aggregated into a single chain. Manual incident review is strongly recommended.', 'aiutocomputerhelp-persistence-audit' ) : __( 'Related persistence indicators were observed across multiple layers and were aggregated into a single chain for manual review.', 'aiutocomputerhelp-persistence-audit' ); $preview_lines = array(); $preview_lines[] = 'Observed layers:'; foreach ( array_slice( $group['categories'], 0, 16 ) as $category ) { $preview_lines[] = '- ' . sanitize_text_field( $category ); } if ( ! empty( $identifiers ) ) { $preview_lines[] = ''; $preview_lines[] = 'Identifiers:'; foreach ( array_slice( $identifiers, 0, 12 ) as $identifier ) { $preview_lines[] = '- ' . sanitize_text_field( $identifier ); } } if ( ! empty( $artifacts ) ) { $preview_lines[] = ''; $preview_lines[] = 'Related artifacts:'; foreach ( array_slice( $artifacts, 0, 16 ) as $artifact ) { $preview_lines[] = '- ' . sanitize_text_field( $artifact ); } } $preview_lines[] = ''; $preview_lines[] = 'Locations:'; foreach ( array_slice( $group['locations'], 0, 16 ) as $location ) { $preview_lines[] = '- ' . sanitize_text_field( $location ); } $preview = implode( "\n", $preview_lines ); if ( strlen( $preview ) > 2200 ) { $preview = substr( $preview, 0, 2200 ) . "\n[preview truncated]"; } return new ACHWPA_Finding( array( 'category' => 'Correlation', 'level' => $level, 'location' => implode( ' ↔ ', array_slice( $group['locations'], 0, 6 ) ), 'indicator' => 'Persistence chain: ' . $chain_label, 'explanation' => $explanation, 'preview' => $preview, 'artifacts' => $artifacts, 'identifiers' => $identifiers, 'relationships' => array_values( array_unique( $relationships ) ), ) ); } private function meaningful_artifact( $artifact ) { $parts = explode( ':', strtolower( trim( (string) $artifact ) ), 2 ); $type = isset( $parts[0] ) ? $parts[0] : ''; $value = isset( $parts[1] ) ? trim( $parts[1] ) : trim( $artifact ); // Generic basenames are common throughout WordPress and cannot by // themselves establish a meaningful cross-layer persistence relation. if ( 'file' === $type && in_array( basename( $value ), array( 'index.php', 'functions.php', 'plugin.php', 'readme.php', ), true ) ) { return false; } if ( in_array( $value, array( 'wp-content', 'wp-includes', 'wp-admin', 'uploads', 'plugins', 'cache', 'assets', ), true ) ) { return false; } return strlen( $value ) >= 4; } private function meaningful_identifier( $identifier ) { if ( strlen( $identifier ) < 6 || in_array( $identifier, $this->stop_identifiers, true ) ) { return false; } if ( ctype_digit( $identifier ) ) { return false; } return (bool) preg_match( '/[a-f]/i', $identifier ); } private function display_value( $value ) { $parts = explode( ':', $value, 2 ); return isset( $parts[1] ) ? $parts[1] : $value; } }