Add Holehe collector for email targets

Closes the email target-type gap (previously had zero collectors).
Checks whether an email is registered on 120+ sites via the
password-reset-oracle technique. Shares the Sherlock/whois venv.

HoleheRunner gates success on the CSV file existing rather than the
process exit code: holehe's CLI calls Python's exit("message") right
after writing results on its normal success path, which always yields
exit code 1 regardless of outcome — discovered while verifying this
end-to-end against the real dev server and queue worker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
cedry2k1992
2026-07-20 11:49:09 +03:00
co-authored by Claude Sonnet 5
parent e4fa57c3d6
commit bb15f13bac
7 changed files with 126 additions and 7 deletions
+2
View File
@@ -3,6 +3,7 @@
namespace App\Osint;
use App\Osint\Runners\CrtShRunner;
use App\Osint\Runners\HoleheRunner;
use App\Osint\Runners\IpApiRunner;
use App\Osint\Runners\SherlockRunner;
use App\Osint\Runners\TheHarvesterRunner;
@@ -23,6 +24,7 @@ class RunnerRegistry
'whois' => WhoisRunner::class,
'ip-api' => IpApiRunner::class,
'theharvester' => TheHarvesterRunner::class,
'holehe' => HoleheRunner::class,
];
public function has(string $sourceKey): bool
+91
View File
@@ -0,0 +1,91 @@
<?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\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
class HoleheRunner implements Runner
{
public function run(Target $target): RunnerResult
{
if ($target->type !== 'email') {
throw new RunnerException('holehe only supports "email" targets.');
}
if (! filter_var($target->value, FILTER_VALIDATE_EMAIL)) {
throw new RunnerException('Value is not a valid email address.');
}
$binary = config('osint.python_venv').'/bin/holehe';
if (! is_executable($binary)) {
throw new RunnerException("holehe binary not found or not executable at [{$binary}]. Run scripts/python/setup.sh.");
}
$runDir = storage_path('app/osint/runs/'.Str::uuid());
File::ensureDirectoryExists($runDir);
try {
// holehe's CLI calls Python's exit("message") on the success
// path after writing the CSV (see core.py: export_csv), which
// always yields exit code 1 — confirmed by hand, "successful"
// runs and real errors are otherwise indistinguishable by exit
// code alone. So: don't gate on successful(), gate on whether
// the CSV actually exists.
Process::path($runDir)
->timeout((int) config('osint.run_timeout'))
->run([
$binary,
$target->value,
'--no-color',
'--no-clear',
'--csv',
'-T', '15',
]);
// Filename is holehe_<unix-timestamp>_<email>_results.csv — not
// predictable up front, so glob for it.
$csvFiles = File::glob($runDir.'/holehe_*_results.csv');
if (empty($csvFiles)) {
throw new RunnerException('holehe did not produce the expected CSV output.');
}
$sites = $this->parseCsv($csvFiles[0]);
$summary = count($sites) > 0
? sprintf('Found on %d site%s: %s', count($sites), count($sites) === 1 ? '' : 's', implode(', ', $sites))
: 'No accounts found.';
return new RunnerResult(raw: ['sites' => $sites], summary: $summary);
} finally {
File::deleteDirectory($runDir);
}
}
/**
* @return array<int, string>
*/
private function parseCsv(string $path): array
{
$handle = fopen($path, 'r');
$header = fgetcsv($handle);
$sites = [];
while (($row = fgetcsv($handle)) !== false) {
$data = array_combine($header, $row);
if (strcasecmp($data['exists'] ?? '', 'True') === 0) {
$sites[] = $data['domain'] ?: $data['name'];
}
}
fclose($handle);
return $sites;
}
}
+1
View File
@@ -29,5 +29,6 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('osint-runner:whois', fn () => Limit::perMinute(20));
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));
}
}