소스 검색

adding spf lookup tool

Josef Straßl 1 개월 전
부모
커밋
493a76a40f
1개의 변경된 파일442개의 추가작업 그리고 0개의 파일을 삭제
  1. 442 0
      spf-check.php

+ 442 - 0
spf-check.php

@@ -0,0 +1,442 @@
+<?php
+declare(strict_types=1);
+
+const LOOKUP_LIMIT = 10; // RFC 7208 section 4.6.4 limit on DNS-querying mechanisms
+
+/**
+ * Recursively resolves an SPF record, following include/redirect, and counts
+ * the DNS-querying mechanisms toward the RFC 7208 limit of 10.
+ */
+class SpfResolver
+{
+    public int $lookupCount = 0;
+    /** @var string[] */
+    public array $warnings = [];
+    /** @var array<int,array{display:string,query:string,source:string,mechanism:string}> */
+    public array $ips = [];
+    /** @var array<string,bool> */
+    private array $visited = [];
+
+    public function resolve(string $domain, int $depth = 0): array
+    {
+        $node = ['domain' => $domain, 'record' => null, 'error' => null, 'terms' => []];
+
+        if ($depth > 20) {
+            $node['error'] = 'Maximum recursion depth exceeded.';
+            return $node;
+        }
+        if (isset($this->visited[$domain])) {
+            $node['error'] = 'Already evaluated — skipped to avoid an include loop.';
+            return $node;
+        }
+        $this->visited[$domain] = true;
+
+        $record = self::getSpfRecord($domain);
+        if ($record === null) {
+            $node['error'] = 'No "v=spf1" TXT record found.';
+            return $node;
+        }
+        $node['record'] = $record;
+
+        foreach (preg_split('/\s+/', trim($record)) as $term) {
+            if ($term === '' || strtolower($term) === 'v=spf1') {
+                continue;
+            }
+            $node['terms'][] = $this->processTerm($term, $domain, $depth);
+        }
+        return $node;
+    }
+
+    private function processTerm(string $term, string $domain, int $depth): array
+    {
+        $result = ['raw' => $term, 'type' => 'unknown', 'value' => null, 'lookup' => false, 'child' => null];
+        $lower = strtolower($term);
+
+        // Modifiers (no qualifier prefix)
+        if (str_starts_with($lower, 'redirect=')) {
+            $target = substr($term, 9);
+            $this->countLookup('redirect=' . $target);
+            $result['type'] = 'redirect';
+            $result['value'] = $target;
+            $result['lookup'] = true;
+            $result['child'] = $this->resolve($target, $depth + 1);
+            return $result;
+        }
+        if (str_starts_with($lower, 'exp=')) {
+            $result['type'] = 'exp';
+            $result['value'] = substr($term, 4);
+            return $result;
+        }
+
+        // Mechanisms may carry a qualifier (+ - ~ ?)
+        $qualifier = '';
+        if (strlen($term) && in_array($term[0], ['+', '-', '~', '?'], true)) {
+            $qualifier = $term[0];
+            $term = substr($term, 1);
+            $lower = strtolower($term);
+        }
+        $result['qualifier'] = $qualifier;
+
+        if ($lower === 'all') {
+            $result['type'] = 'all';
+            $result['value'] = ($qualifier ?: '+') . 'all';
+            return $result;
+        }
+        if (str_starts_with($lower, 'include:')) {
+            $target = substr($term, 8);
+            $this->countLookup('include:' . $target);
+            $result['type'] = 'include';
+            $result['value'] = $target;
+            $result['lookup'] = true;
+            $result['child'] = $this->resolve($target, $depth + 1);
+            return $result;
+        }
+        if ($lower === 'a' || str_starts_with($lower, 'a:') || str_starts_with($lower, 'a/')) {
+            $this->countLookup($term);
+            $result['type'] = 'a';
+            $result['value'] = $term;
+            $result['lookup'] = true;
+            $this->collectHostIps(self::mechanismHost($term, 'a', $domain), $term, $domain);
+            return $result;
+        }
+        if ($lower === 'mx' || str_starts_with($lower, 'mx:') || str_starts_with($lower, 'mx/')) {
+            $this->countLookup($term);
+            $result['type'] = 'mx';
+            $result['value'] = $term;
+            $result['lookup'] = true;
+            $host = self::mechanismHost($term, 'mx', $domain);
+            foreach ((@dns_get_record($host, DNS_MX) ?: []) as $mx) {
+                if (!empty($mx['target'])) {
+                    $this->collectHostIps($mx['target'], $term, $domain);
+                }
+            }
+            return $result;
+        }
+        if (str_starts_with($lower, 'ip4:') || str_starts_with($lower, 'ip6:')) {
+            $value = substr($term, 4);
+            $result['type'] = strtolower(substr($term, 0, 3));
+            $result['value'] = $value;
+            $this->ips[] = [
+                'display' => $value,
+                'query' => explode('/', $value)[0],
+                'source' => $domain,
+                'mechanism' => $result['type'],
+            ];
+            return $result;
+        }
+        if ($lower === 'ptr' || str_starts_with($lower, 'ptr:')) {
+            $this->countLookup($term);
+            $result['type'] = 'ptr';
+            $result['value'] = $term;
+            $result['lookup'] = true;
+            return $result;
+        }
+        if (str_starts_with($lower, 'exists:')) {
+            $this->countLookup($term);
+            $result['type'] = 'exists';
+            $result['value'] = substr($term, 7);
+            $result['lookup'] = true;
+            return $result;
+        }
+        return $result;
+    }
+
+    private function countLookup(string $label): void
+    {
+        $this->lookupCount++;
+        if ($this->lookupCount === LOOKUP_LIMIT) {
+            $this->warnings[] = sprintf(
+                'Reached the RFC 7208 limit of %d DNS lookups at "%s". Any further lookup mechanism will make evaluators return a PermError.',
+                LOOKUP_LIMIT,
+                $label
+            );
+        } elseif ($this->lookupCount > LOOKUP_LIMIT) {
+            $this->warnings[] = sprintf(
+                'Exceeded the %d-lookup limit (now %d) at "%s" — this SPF record will fail with a PermError.',
+                LOOKUP_LIMIT,
+                $this->lookupCount,
+                $label
+            );
+        }
+    }
+
+    private function collectHostIps(string $host, string $mechanism, string $source): void
+    {
+        foreach (self::hostIps($host) as $ip) {
+            $this->ips[] = [
+                'display' => $ip,
+                'query' => $ip,
+                'source' => $source,
+                'mechanism' => $mechanism,
+            ];
+        }
+    }
+
+    /** Returns the target host for an a/mx mechanism, stripping any CIDR suffix. */
+    private static function mechanismHost(string $term, string $name, string $domain): string
+    {
+        $rest = substr($term, strlen($name)); // "", ":host", "/24", ":host/24"
+        if ($rest === '' || $rest[0] === '/') {
+            return $domain;
+        }
+        $rest = ltrim($rest, ':');
+        return explode('/', $rest)[0];
+    }
+
+    /** @return string[] */
+    private static function hostIps(string $host): array
+    {
+        $ips = [];
+        foreach ((@dns_get_record($host, DNS_A) ?: []) as $r) {
+            if (!empty($r['ip'])) {
+                $ips[] = $r['ip'];
+            }
+        }
+        foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $r) {
+            if (!empty($r['ipv6'])) {
+                $ips[] = $r['ipv6'];
+            }
+        }
+        return $ips;
+    }
+
+    private static function getSpfRecord(string $domain): ?string
+    {
+        foreach ((@dns_get_record($domain, DNS_TXT) ?: []) as $r) {
+            $txt = $r['txt'] ?? (isset($r['entries']) ? implode('', $r['entries']) : '');
+            if (stripos($txt, 'v=spf1') === 0) {
+                return $txt;
+            }
+        }
+        return null;
+    }
+}
+
+/**
+ * Enriches IPs with ASN / country / company via ip-api.com's free batch endpoint.
+ * @param string[] $ips
+ * @return array<string,array>
+ */
+function lookupIpInfo(array $ips): array
+{
+    $out = [];
+    $ips = array_values(array_unique(array_filter($ips)));
+    if (empty($ips)) {
+        return $out;
+    }
+    $fields = 'query,status,message,country,countryCode,as,asname,isp,org';
+    foreach (array_chunk($ips, 100) as $chunk) {
+        $payload = array_map(static fn($ip) => ['query' => $ip, 'fields' => $fields], $chunk);
+        $ch = curl_init('http://ip-api.com/batch');
+        curl_setopt_array($ch, [
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => json_encode($payload),
+            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_TIMEOUT => 20,
+        ]);
+        $resp = curl_exec($ch);
+        curl_close($ch);
+        foreach ((json_decode((string) $resp, true) ?: []) as $item) {
+            if (isset($item['query'])) {
+                $out[$item['query']] = $item;
+            }
+        }
+    }
+    return $out;
+}
+
+$domain = trim((string) ($_GET['domain'] ?? ''));
+$resolver = null;
+$ipInfo = [];
+$inputError = null;
+
+if ($domain !== '') {
+    // Accept a bare hostname; strip scheme/path if a URL was pasted.
+    $domain = preg_replace('#^\w+://#', '', $domain);
+    $domain = explode('/', $domain)[0];
+    $domain = strtolower(trim($domain));
+
+    if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) {
+        $inputError = 'Please enter a valid domain name (e.g. example.com).';
+    } else {
+        $resolver = new SpfResolver();
+        $tree = $resolver->resolve($domain);
+        $ipInfo = lookupIpInfo(array_column($resolver->ips, 'query'));
+    }
+}
+
+/** Renders the resolved SPF tree as nested lists. */
+function renderTree(array $node): string
+{
+    $h = 'htmlspecialchars';
+    $out = '<div class="node">';
+    $out .= '<div class="node-domain">🌐 ' . $h($node['domain']) . '</div>';
+
+    if ($node['error']) {
+        $out .= '<div class="node-error">⚠️ ' . $h($node['error']) . '</div></div>';
+        return $out;
+    }
+    $out .= '<div class="record">' . $h((string) $node['record']) . '</div>';
+    $out .= '<ul class="terms">';
+    foreach ($node['terms'] as $term) {
+        $badge = strtoupper($h($term['type']));
+        $cls = $term['lookup'] ? 'term lookup' : 'term';
+        $out .= '<li class="' . $cls . '"><span class="badge badge-' . $h($term['type']) . '">' . $badge . '</span> '
+            . '<code>' . $h($term['raw']) . '</code>';
+        if ($term['lookup']) {
+            $out .= ' <span class="lk">DNS lookup</span>';
+        }
+        if (!empty($term['child'])) {
+            $out .= renderTree($term['child']);
+        }
+        $out .= '</li>';
+    }
+    $out .= '</ul></div>';
+    return $out;
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>SPF Record Checker</title>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            max-width: 1200px;
+            margin: 40px auto;
+            padding: 0 20px;
+            background: #f5f5f5;
+            color: #333;
+        }
+        h1 { color: #333; }
+        .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
+        form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
+        input[type=text] {
+            flex: 1; min-width: 240px; padding: 10px; font-size: 15px;
+            border: 1px solid #ccc; border-radius: 5px;
+        }
+        .btn {
+            display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
+            text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
+        }
+        .btn:hover { background: #1976D2; }
+        .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
+        .warning {
+            background: #fff8e1; color: #e65100; padding: 12px 15px; border-radius: 5px;
+            margin: 10px 0; border-left: 5px solid #ff9800;
+        }
+        .counter {
+            display: inline-block; padding: 8px 14px; border-radius: 5px; font-weight: 600;
+            background: #e8f5e9; color: #2e7d32;
+        }
+        .counter.warn { background: #fff8e1; color: #e65100; }
+        .counter.over { background: #ffebee; color: #c62828; }
+        .node { margin: 8px 0; }
+        .node-domain { font-weight: 600; margin-top: 6px; }
+        .node-error { color: #c62828; margin: 4px 0 4px 10px; }
+        .record {
+            background: #263238; color: #aed581; padding: 8px 12px; border-radius: 4px;
+            font-family: monospace; font-size: 13px; word-break: break-all; margin: 4px 0;
+        }
+        ul.terms { list-style: none; margin: 4px 0 4px 8px; padding-left: 16px; border-left: 2px solid #e0e0e0; }
+        .term { margin: 4px 0; }
+        .term code { background: #eceff1; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
+        .badge {
+            display: inline-block; min-width: 54px; text-align: center; padding: 2px 6px;
+            border-radius: 3px; font-size: 11px; font-weight: 700; color: white; background: #90a4ae;
+        }
+        .badge-include, .badge-redirect { background: #5c6bc0; }
+        .badge-ip4, .badge-ip6 { background: #26a69a; }
+        .badge-a, .badge-mx { background: #ec407a; }
+        .badge-all { background: #78909c; }
+        .badge-exists, .badge-ptr { background: #ab47bc; }
+        .lk { font-size: 11px; color: #ef6c00; font-weight: 600; }
+        table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
+        th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #ddd; font-size: 13px; }
+        th { background: #2196F3; color: white; }
+        tr:hover { background: #f5f5f5; }
+        td.ip { font-family: monospace; }
+        h2 { color: #333; margin-top: 30px; }
+        .empty { text-align: center; color: #999; padding: 40px; }
+    </style>
+</head>
+<body>
+    <h1>🛡️ SPF Record Checker</h1>
+
+    <div class="info">
+        Resolves a domain's <strong>SPF</strong> record, follows every <code>include:</code> and
+        <code>redirect=</code>, and enriches each authorised IP with its <strong>ASN, country and company</strong>.
+        Counts the DNS-querying mechanisms and warns when the
+        <strong>RFC 7208 limit of <?= LOOKUP_LIMIT ?> lookups</strong> is reached.
+    </div>
+
+    <form class="lookup" method="GET">
+        <input type="text" name="domain" placeholder="example.com" value="<?= htmlspecialchars($domain) ?>" autofocus>
+        <button type="submit" class="btn">🔍 Check SPF</button>
+    </form>
+
+    <?php if ($inputError): ?>
+        <div class="error"><?= htmlspecialchars($inputError) ?></div>
+    <?php endif; ?>
+
+    <?php if ($resolver !== null): ?>
+        <?php
+            $count = $resolver->lookupCount;
+            $counterCls = $count > LOOKUP_LIMIT ? 'over' : ($count >= LOOKUP_LIMIT ? 'warn' : '');
+        ?>
+        <div style="margin: 16px 0;">
+            <span class="counter <?= $counterCls ?>">
+                DNS lookups used: <?= $count ?> / <?= LOOKUP_LIMIT ?>
+            </span>
+        </div>
+
+        <?php foreach ($resolver->warnings as $w): ?>
+            <div class="warning">⚠️ <?= htmlspecialchars($w) ?></div>
+        <?php endforeach; ?>
+
+        <h2>Resolution tree</h2>
+        <?= renderTree($tree) ?>
+
+        <h2>Authorised IPs (<?= count($resolver->ips) ?>)</h2>
+        <?php if (empty($resolver->ips)): ?>
+            <div class="empty">No ip4/ip6/a/mx mechanisms produced any IP addresses.</div>
+        <?php else: ?>
+            <table>
+                <thead>
+                    <tr>
+                        <th>IP / Range</th>
+                        <th>Mechanism</th>
+                        <th>Via</th>
+                        <th>ASN</th>
+                        <th>Company / ISP</th>
+                        <th>Country</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <?php foreach ($resolver->ips as $entry): ?>
+                        <?php
+                            $info = $ipInfo[$entry['query']] ?? null;
+                            $ok = $info && ($info['status'] ?? '') === 'success';
+                            $asn = $ok ? ($info['as'] ?: '—') : '—';
+                            $company = $ok ? ($info['org'] ?: ($info['isp'] ?? '') ?: ($info['asname'] ?? '')) : '';
+                            $country = $ok ? trim(($info['country'] ?? '') . ' (' . ($info['countryCode'] ?? '') . ')', ' ()') : '';
+                        ?>
+                        <tr>
+                            <td class="ip"><?= htmlspecialchars($entry['display']) ?></td>
+                            <td><span class="badge badge-<?= htmlspecialchars($entry['mechanism']) ?>"><?= htmlspecialchars(strtoupper($entry['mechanism'])) ?></span></td>
+                            <td><?= htmlspecialchars($entry['source']) ?></td>
+                            <td><?= htmlspecialchars($asn) ?></td>
+                            <td><?= htmlspecialchars($company ?: '—') ?></td>
+                            <td><?= htmlspecialchars($country ?: '—') ?></td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+            <p style="color:#888;font-size:12px;">IP intelligence via ip-api.com (free tier).</p>
+        <?php endif; ?>
+    <?php endif; ?>
+</body>
+</html>