| 1 |
<?php |
| 2 |
/** |
| 3 |
* Security Analyzer — Reputation / blacklist category (informational, 0 pts). |
| 4 |
* |
| 5 |
* DNS-based reputation lookups against public blacklists. Zero HTTP APIs, no |
| 6 |
* account required: everything is a cheap DNS A-record query. Because a listing |
| 7 |
* can be transient (a shared IP or mass scanner's fault), these checks are |
| 8 |
* informational — they surface listings without deducting from the overall score. |
| 9 |
* |
| 10 |
* Queried blacklists: |
| 11 |
* - Spamhaus ZEN (zen.spamhaus.org) — aggregated SBL+XBL+PBL. |
| 12 |
* - Barracuda BRBL (b.barracudacentral.org). |
| 13 |
* - SpamCop SCBL (bl.spamcop.net). |
| 14 |
* |
| 15 |
* The server IP is resolved from the site URL's host (same physical host in |
| 16 |
* most self-hosted WP installs). Reverse the octets and prepend them to each |
| 17 |
* blacklist zone; a successful A lookup means the IP is listed. |
| 18 |
* |
| 19 |
* @package Vigilante |
| 20 |
* @since 2.1.0 |
| 21 |
*/ |
| 22 |
|
| 23 |
// Prevent direct access. |
| 24 |
if ( ! defined( 'ABSPATH' ) ) { |
| 25 |
exit; |
| 26 |
} |
| 27 |
|
| 28 |
/** |
| 29 |
* DNS-based reputation checks (all informational). |
| 30 |
*/ |
| 31 |
class Vigilante_SA_Category_Reputation { |
| 32 |
|
| 33 |
const SLUG = 'reputation'; |
| 34 |
|
| 35 |
/** |
| 36 |
* Blacklists we query. DNS-only, no authentication. |
| 37 |
* |
| 38 |
* @var array<string,array{label:string,zone:string,info_url:string}> |
| 39 |
*/ |
| 40 |
private static $blacklists = array( |
| 41 |
'spamhaus' => array( |
| 42 |
'label' => 'Spamhaus ZEN', |
| 43 |
'zone' => 'zen.spamhaus.org', |
| 44 |
'info_url' => 'https://check.spamhaus.org/', |
| 45 |
), |
| 46 |
'barracuda' => array( |
| 47 |
'label' => 'Barracuda BRBL', |
| 48 |
'zone' => 'b.barracudacentral.org', |
| 49 |
'info_url' => 'https://www.barracudacentral.org/rbl/removal-request', |
| 50 |
), |
| 51 |
'spamcop' => array( |
| 52 |
'label' => 'SpamCop SCBL', |
| 53 |
'zone' => 'bl.spamcop.net', |
| 54 |
'info_url' => 'https://www.spamcop.net/bl.shtml', |
| 55 |
), |
| 56 |
); |
| 57 |
|
| 58 |
/** |
| 59 |
* @var Vigilante_Settings |
| 60 |
*/ |
| 61 |
private $settings; |
| 62 |
|
| 63 |
public function __construct( Vigilante_Settings $settings ) { |
| 64 |
$this->settings = $settings; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Run the category. |
| 69 |
* |
| 70 |
* All reputation checks require DNS + external network, so they live in the |
| 71 |
* 'slow' phase. |
| 72 |
* |
| 73 |
* @param string $phase 'fast' | 'slow' | 'all'. |
| 74 |
* @return Vigilante_SA_Check_Result[] |
| 75 |
*/ |
| 76 |
public function run( $phase = 'all' ) { |
| 77 |
if ( 'fast' === $phase ) { |
| 78 |
return array(); |
| 79 |
} |
| 80 |
|
| 81 |
$results = array(); |
| 82 |
|
| 83 |
$ip = $this->resolve_site_ip(); |
| 84 |
|
| 85 |
// Intro/diagnostic row telling the user what was tested and on what IP. |
| 86 |
$results[] = $this->build_intro( $ip ); |
| 87 |
|
| 88 |
if ( '' === $ip ) { |
| 89 |
// Without an IP we can't query — return just the intro (already explains why). |
| 90 |
return $results; |
| 91 |
} |
| 92 |
|
| 93 |
foreach ( self::$blacklists as $key => $meta ) { |
| 94 |
$results[] = $this->check_blacklist( $key, $meta, $ip ); |
| 95 |
} |
| 96 |
|
| 97 |
return $results; |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Introductory info row that documents the IP we tested. |
| 102 |
* |
| 103 |
* @param string $ip |
| 104 |
* @return Vigilante_SA_Check_Result |
| 105 |
*/ |
| 106 |
private function build_intro( $ip ) { |
| 107 |
$args = array( |
| 108 |
'id' => 'reputation_overview', |
| 109 |
'category' => self::SLUG, |
| 110 |
'max' => 0, |
| 111 |
'label' => __( 'Site IP for blacklist queries', 'vigilante' ), |
| 112 |
'fix_link' => '', |
| 113 |
); |
| 114 |
|
| 115 |
if ( '' === $ip ) { |
| 116 |
$args['detail'] = __( 'Could not resolve the site\'s public IP (DNS lookup failed). Reputation checks skipped — this is typically a transient resolver issue.', 'vigilante' ); |
| 117 |
return Vigilante_SA_Check_Result::skip( $args ); |
| 118 |
} |
| 119 |
|
| 120 |
$args['detail'] = sprintf( |
| 121 |
/* translators: 1: site host, 2: resolved IPv4 */ |
| 122 |
__( '%1$s resolves to %2$s. We query public DNS blacklists (DNSBLs) against this address. These checks are informational — a listing does not deduct from your score.', 'vigilante' ), |
| 123 |
wp_parse_url( home_url(), PHP_URL_HOST ), |
| 124 |
$ip |
| 125 |
); |
| 126 |
$args['data'] = array( 'ip' => $ip ); |
| 127 |
return Vigilante_SA_Check_Result::info( $args ); |
| 128 |
} |
| 129 |
|
| 130 |
/** |
| 131 |
* Query a single DNSBL for the given IP. |
| 132 |
* |
| 133 |
* @param string $key Slug (spamhaus|barracuda|spamcop). |
| 134 |
* @param array $meta Metadata (label, zone, info_url). |
| 135 |
* @param string $ip IPv4 address. |
| 136 |
* @return Vigilante_SA_Check_Result |
| 137 |
*/ |
| 138 |
private function check_blacklist( $key, array $meta, $ip ) { |
| 139 |
$args = array( |
| 140 |
'id' => 'reputation_' . $key, |
| 141 |
'category' => self::SLUG, |
| 142 |
'max' => 0, |
| 143 |
'label' => sprintf( |
| 144 |
/* translators: %s: blacklist name */ |
| 145 |
__( '%s blacklist', 'vigilante' ), |
| 146 |
$meta['label'] |
| 147 |
), |
| 148 |
'fix_link' => $meta['info_url'], |
| 149 |
); |
| 150 |
|
| 151 |
$reversed = $this->reverse_ip( $ip ); |
| 152 |
if ( '' === $reversed ) { |
| 153 |
$args['detail'] = __( 'Invalid IPv4 address, skipping.', 'vigilante' ); |
| 154 |
return Vigilante_SA_Check_Result::skip( $args ); |
| 155 |
} |
| 156 |
|
| 157 |
$hostname = $reversed . '.' . $meta['zone']; |
| 158 |
|
| 159 |
// Use gethostbynamel() to avoid long single-host timeouts and get all A records back. |
| 160 |
// Listings typically respond with 127.0.0.x codes; non-listings return no record. |
| 161 |
$records = @gethostbynamel( $hostname ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 162 |
|
| 163 |
if ( false === $records || empty( $records ) ) { |
| 164 |
$args['detail'] = sprintf( |
| 165 |
/* translators: %s: blacklist name */ |
| 166 |
__( 'Not listed on %s.', 'vigilante' ), |
| 167 |
$meta['label'] |
| 168 |
); |
| 169 |
return Vigilante_SA_Check_Result::info( $args ); |
| 170 |
} |
| 171 |
|
| 172 |
// Any returned record (typically 127.0.0.x) means listed. Informational in terms |
| 173 |
// of scoring (max=0), but visually flagged as a warning so it stands out. |
| 174 |
$codes = array_filter( (array) $records, 'is_string' ); |
| 175 |
$args['detail'] = sprintf( |
| 176 |
/* translators: 1: blacklist name, 2: response codes (127.0.0.x) */ |
| 177 |
__( 'Listed on %1$s (response: %2$s). Shared hosting? Check if the listing belongs to your IP range and request delisting from the blacklist operator.', 'vigilante' ), |
| 178 |
$meta['label'], |
| 179 |
implode( ', ', $codes ) |
| 180 |
); |
| 181 |
$args['data'] = array( 'response' => array_values( $codes ) ); |
| 182 |
return Vigilante_SA_Check_Result::warn( $args ); |
| 183 |
} |
| 184 |
|
| 185 |
/** |
| 186 |
* Resolve the site's public IPv4 address. |
| 187 |
* |
| 188 |
* @return string IPv4 or '' on failure. |
| 189 |
*/ |
| 190 |
private function resolve_site_ip() { |
| 191 |
$host = wp_parse_url( home_url(), PHP_URL_HOST ); |
| 192 |
if ( ! $host ) { |
| 193 |
return ''; |
| 194 |
} |
| 195 |
|
| 196 |
// Already an IP? Short-circuit. |
| 197 |
if ( filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { |
| 198 |
return $host; |
| 199 |
} |
| 200 |
|
| 201 |
$ip = @gethostbyname( $host ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 202 |
if ( $ip === $host ) { |
| 203 |
return ''; // gethostbyname returns the input on failure. |
| 204 |
} |
| 205 |
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { |
| 206 |
return ''; // DNSBLs we use are IPv4-only. |
| 207 |
} |
| 208 |
return $ip; |
| 209 |
} |
| 210 |
|
| 211 |
/** |
| 212 |
* Reverse an IPv4 address (1.2.3.4 → 4.3.2.1). Return '' if invalid. |
| 213 |
* |
| 214 |
* @param string $ip |
| 215 |
* @return string |
| 216 |
*/ |
| 217 |
private function reverse_ip( $ip ) { |
| 218 |
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { |
| 219 |
return ''; |
| 220 |
} |
| 221 |
$parts = array_reverse( explode( '.', $ip ) ); |
| 222 |
return implode( '.', $parts ); |
| 223 |
} |
| 224 |
} |
| 225 |
|