Add Gravatar and DNS Records collectors
Two pure-PHP collectors, no Python/venv/subprocess needed:
- GravatarRunner (email): checks gravatar.com/avatar/{md5}?d=404 for a
200 vs 404. Scoped to that binary signal since Gravatar's profile
JSON endpoint now requires API auth even for real, public profiles
(confirmed against Gravatar's own creator's email).
- DnsRunner (domain): dns_get_record() for A/AAAA/MX/NS/TXT/CNAME. Raw
payload is naturally keyed by record type, which the existing
generic list-renderer on the investigation page turns into one
collapsible section per type with no UI changes needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bb15f13bac
commit
86f73c46b2
@@ -3,6 +3,8 @@
|
||||
namespace App\Osint;
|
||||
|
||||
use App\Osint\Runners\CrtShRunner;
|
||||
use App\Osint\Runners\DnsRunner;
|
||||
use App\Osint\Runners\GravatarRunner;
|
||||
use App\Osint\Runners\HoleheRunner;
|
||||
use App\Osint\Runners\IpApiRunner;
|
||||
use App\Osint\Runners\SherlockRunner;
|
||||
@@ -25,6 +27,8 @@ class RunnerRegistry
|
||||
'ip-api' => IpApiRunner::class,
|
||||
'theharvester' => TheHarvesterRunner::class,
|
||||
'holehe' => HoleheRunner::class,
|
||||
'gravatar' => GravatarRunner::class,
|
||||
'dns' => DnsRunner::class,
|
||||
];
|
||||
|
||||
public function has(string $sourceKey): bool
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Osint\Runners;
|
||||
|
||||
use App\Models\Target;
|
||||
use App\Osint\Runner;
|
||||
use App\Osint\RunnerException;
|
||||
use App\Osint\RunnerResult;
|
||||
|
||||
class DnsRunner implements Runner
|
||||
{
|
||||
/**
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private const RECORD_TYPES = [
|
||||
'A' => DNS_A,
|
||||
'AAAA' => DNS_AAAA,
|
||||
'MX' => DNS_MX,
|
||||
'NS' => DNS_NS,
|
||||
'TXT' => DNS_TXT,
|
||||
'CNAME' => DNS_CNAME,
|
||||
];
|
||||
|
||||
public function run(Target $target): RunnerResult
|
||||
{
|
||||
if ($target->type !== 'domain') {
|
||||
throw new RunnerException('DNS lookup only supports "domain" targets.');
|
||||
}
|
||||
|
||||
if (! preg_match('/^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/', $target->value)) {
|
||||
throw new RunnerException('Domain contains characters that are not safe to look up.');
|
||||
}
|
||||
|
||||
$records = [];
|
||||
|
||||
foreach (self::RECORD_TYPES as $label => $type) {
|
||||
// dns_get_record() emits a warning (not an exception) when a
|
||||
// record type doesn't resolve for the domain — expected for
|
||||
// most domains on most types, not a real error.
|
||||
$found = @dns_get_record($target->value, $type);
|
||||
|
||||
if ($found === false || empty($found)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$records[$label] = array_values(array_unique(array_map(
|
||||
fn (array $record) => $this->formatRecord($label, $record),
|
||||
$found
|
||||
)));
|
||||
}
|
||||
|
||||
$total = array_sum(array_map('count', $records));
|
||||
|
||||
$summary = $total > 0
|
||||
? sprintf('Found %d DNS record%s across %d type%s (%s).',
|
||||
$total, $total === 1 ? '' : 's',
|
||||
count($records), count($records) === 1 ? '' : 's',
|
||||
implode(', ', array_keys($records)))
|
||||
: 'No DNS records found.';
|
||||
|
||||
return new RunnerResult(raw: $records, summary: $summary);
|
||||
}
|
||||
|
||||
private function formatRecord(string $label, array $record): string
|
||||
{
|
||||
return match ($label) {
|
||||
'A', 'AAAA' => $record['ip'] ?? '',
|
||||
'MX' => sprintf('%d %s', $record['pri'] ?? 0, $record['target'] ?? ''),
|
||||
'NS', 'CNAME' => $record['target'] ?? '',
|
||||
'TXT' => $record['txt'] ?? '',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Osint\Runners;
|
||||
|
||||
use App\Models\Target;
|
||||
use App\Osint\Runner;
|
||||
use App\Osint\RunnerException;
|
||||
use App\Osint\RunnerResult;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class GravatarRunner implements Runner
|
||||
{
|
||||
public function run(Target $target): RunnerResult
|
||||
{
|
||||
if ($target->type !== 'email') {
|
||||
throw new RunnerException('Gravatar only supports "email" targets.');
|
||||
}
|
||||
|
||||
if (! filter_var($target->value, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RunnerException('Value is not a valid email address.');
|
||||
}
|
||||
|
||||
// Gravatar's public profile JSON endpoint now requires API auth
|
||||
// (returns 404 even for real profiles without a key), but the
|
||||
// plain avatar-existence check is still open and reliable.
|
||||
$hash = md5(strtolower(trim($target->value)));
|
||||
$avatarUrl = "https://www.gravatar.com/avatar/{$hash}";
|
||||
|
||||
$response = Http::timeout((int) config('osint.run_timeout'))
|
||||
->get($avatarUrl, ['d' => '404']);
|
||||
|
||||
if ($response->status() !== 200 && $response->status() !== 404) {
|
||||
throw new RunnerException('Gravatar request failed with status '.$response->status().'.');
|
||||
}
|
||||
|
||||
$hasAvatar = $response->status() === 200;
|
||||
|
||||
return new RunnerResult(
|
||||
raw: [
|
||||
'has_avatar' => $hasAvatar,
|
||||
'avatar_url' => $hasAvatar ? $avatarUrl : null,
|
||||
'profile_url' => "https://gravatar.com/{$hash}",
|
||||
],
|
||||
summary: $hasAvatar
|
||||
? 'Gravatar image found — this email is registered with Gravatar.'
|
||||
: 'No Gravatar found for this email.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,5 +30,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
RateLimiter::for('osint-runner:sherlock', fn () => Limit::perMinute(10));
|
||||
RateLimiter::for('osint-runner:theharvester', fn () => Limit::perMinute(5));
|
||||
RateLimiter::for('osint-runner:holehe', fn () => Limit::perMinute(15));
|
||||
RateLimiter::for('osint-runner:gravatar', fn () => Limit::perMinute(45));
|
||||
RateLimiter::for('osint-runner:dns', fn () => Limit::perMinute(60));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user