Initial commit: Laravel OSINT platform
Multi-user OSINT investigation tool with owner-controlled per-user and per-group access grants. Includes Sherlock, theHarvester, crt.sh, WHOIS, and ip-api.com collectors wired through a pluggable Runner/queue pipeline, investigation search, and printable/CSV/JSON reporting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
<?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 CrtShRunner implements Runner
|
||||
{
|
||||
public function run(Target $target): RunnerResult
|
||||
{
|
||||
if ($target->type !== 'domain') {
|
||||
throw new RunnerException('crt.sh only supports "domain" targets.');
|
||||
}
|
||||
|
||||
$response = Http::timeout((int) config('osint.run_timeout'))
|
||||
->get('https://crt.sh/', [
|
||||
'q' => $target->value,
|
||||
'output' => 'json',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RunnerException('crt.sh request failed with status '.$response->status().'.');
|
||||
}
|
||||
|
||||
// crt.sh returns an empty body (not valid JSON) when there are no matches.
|
||||
$certificates = $response->json() ?? [];
|
||||
|
||||
$subdomains = collect($certificates)
|
||||
->flatMap(fn (array $cert) => explode("\n", $cert['name_value'] ?? ''))
|
||||
->map(fn (string $name) => strtolower(trim($name)))
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$summary = $subdomains->isNotEmpty()
|
||||
? sprintf('Found %d unique subdomain%s in certificate logs.', $subdomains->count(), $subdomains->count() === 1 ? '' : 's')
|
||||
: 'No certificates found.';
|
||||
|
||||
return new RunnerResult(raw: ['subdomains' => $subdomains->all()], summary: $summary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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 IpApiRunner implements Runner
|
||||
{
|
||||
public function run(Target $target): RunnerResult
|
||||
{
|
||||
if ($target->type !== 'ip') {
|
||||
throw new RunnerException('ip-api.com only supports "ip" targets.');
|
||||
}
|
||||
|
||||
$response = Http::timeout((int) config('osint.run_timeout'))
|
||||
->get("http://ip-api.com/json/{$target->value}", [
|
||||
'fields' => 'status,message,country,regionName,city,zip,lat,lon,timezone,isp,org,as,query',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RunnerException('ip-api.com request failed with status '.$response->status().'.');
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (($data['status'] ?? null) !== 'success') {
|
||||
throw new RunnerException('ip-api.com lookup failed: '.($data['message'] ?? 'unknown error'));
|
||||
}
|
||||
|
||||
$summary = sprintf(
|
||||
'%s, %s, %s (%s)',
|
||||
$data['city'] ?? 'Unknown city',
|
||||
$data['regionName'] ?? 'Unknown region',
|
||||
$data['country'] ?? 'Unknown country',
|
||||
$data['isp'] ?? $data['org'] ?? 'Unknown ISP'
|
||||
);
|
||||
|
||||
return new RunnerResult(raw: $data, summary: $summary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?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 SherlockRunner implements Runner
|
||||
{
|
||||
public function run(Target $target): RunnerResult
|
||||
{
|
||||
if ($target->type !== 'username') {
|
||||
throw new RunnerException('Sherlock only supports "username" targets.');
|
||||
}
|
||||
|
||||
if (! preg_match('/^[a-zA-Z0-9._-]+$/', $target->value)) {
|
||||
throw new RunnerException('Username contains characters that are not safe to check.');
|
||||
}
|
||||
|
||||
$binary = config('osint.python_venv').'/bin/sherlock';
|
||||
if (! is_executable($binary)) {
|
||||
throw new RunnerException("Sherlock binary not found or not executable at [{$binary}]. Run the setup in scripts/python/README.md.");
|
||||
}
|
||||
|
||||
$runDir = storage_path('app/osint/runs/'.Str::uuid());
|
||||
File::ensureDirectoryExists($runDir);
|
||||
|
||||
try {
|
||||
$result = Process::path($runDir)
|
||||
->timeout((int) config('osint.run_timeout'))
|
||||
->run([
|
||||
$binary,
|
||||
$target->value,
|
||||
'--csv',
|
||||
'--no-color',
|
||||
'--no-txt',
|
||||
'--timeout', '30',
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
throw new RunnerException('Sherlock exited with an error: '.$result->errorOutput());
|
||||
}
|
||||
|
||||
$csvPath = $runDir.'/'.$target->value.'.csv';
|
||||
if (! File::exists($csvPath)) {
|
||||
throw new RunnerException('Sherlock did not produce the expected CSV output.');
|
||||
}
|
||||
|
||||
$rows = $this->parseCsv($csvPath);
|
||||
|
||||
$summary = count($rows) > 0
|
||||
? sprintf('Found on %d site%s: %s', count($rows), count($rows) === 1 ? '' : 's', collect($rows)->pluck('name')->implode(', '))
|
||||
: 'No matching profiles found.';
|
||||
|
||||
return new RunnerResult(raw: ['matches' => $rows], summary: $summary);
|
||||
} finally {
|
||||
File::deleteDirectory($runDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, url: string, http_status: int|null}>
|
||||
*/
|
||||
private function parseCsv(string $path): array
|
||||
{
|
||||
$handle = fopen($path, 'r');
|
||||
$header = fgetcsv($handle);
|
||||
$rows = [];
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
$data = array_combine($header, $row);
|
||||
$rows[] = [
|
||||
'name' => $data['name'],
|
||||
'url' => $data['url_user'],
|
||||
'http_status' => $data['http_status'] !== '' ? (int) $data['http_status'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?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 TheHarvesterRunner implements Runner
|
||||
{
|
||||
public function run(Target $target): RunnerResult
|
||||
{
|
||||
if ($target->type !== 'domain') {
|
||||
throw new RunnerException('theHarvester 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.');
|
||||
}
|
||||
|
||||
$python = config('osint.theharvester_venv').'/bin/python3';
|
||||
$script = config('osint.theharvester_script');
|
||||
|
||||
if (! is_executable($python) || ! is_file($script)) {
|
||||
throw new RunnerException('theHarvester not set up. Run scripts/python/setup.sh.');
|
||||
}
|
||||
|
||||
$runDir = storage_path('app/osint/runs/'.Str::uuid());
|
||||
File::ensureDirectoryExists($runDir);
|
||||
|
||||
try {
|
||||
$result = Process::path($runDir)
|
||||
->timeout((int) config('osint.run_timeout'))
|
||||
->run([
|
||||
$python,
|
||||
$script,
|
||||
'-d', $target->value,
|
||||
'-b', config('osint.theharvester_sources'),
|
||||
'-f', 'result',
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
throw new RunnerException('theHarvester exited with an error: '.$result->errorOutput());
|
||||
}
|
||||
|
||||
$jsonPath = $runDir.'/result.json';
|
||||
if (! File::exists($jsonPath)) {
|
||||
// No results at all means theHarvester skips writing a file.
|
||||
return new RunnerResult(raw: [], summary: 'No results found.');
|
||||
}
|
||||
|
||||
$data = json_decode(File::get($jsonPath), true) ?? [];
|
||||
|
||||
$hosts = collect($data['hosts'] ?? [])
|
||||
->map(fn (string $entry) => explode(':', $entry, 2)[0])
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
$emails = collect($data['emails'] ?? [])->unique()->sort()->values();
|
||||
$ips = collect($data['ips'] ?? [])->unique()->sort()->values();
|
||||
|
||||
$summary = sprintf(
|
||||
'Found %d host%s, %d email%s, %d IP%s.',
|
||||
$hosts->count(), $hosts->count() === 1 ? '' : 's',
|
||||
$emails->count(), $emails->count() === 1 ? '' : 's',
|
||||
$ips->count(), $ips->count() === 1 ? '' : 's',
|
||||
);
|
||||
|
||||
return new RunnerResult(
|
||||
raw: ['hosts' => $hosts->all(), 'emails' => $emails->all(), 'ips' => $ips->all()],
|
||||
summary: $summary,
|
||||
);
|
||||
} finally {
|
||||
File::deleteDirectory($runDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?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\Process;
|
||||
|
||||
class WhoisRunner implements Runner
|
||||
{
|
||||
public function run(Target $target): RunnerResult
|
||||
{
|
||||
if ($target->type !== 'domain') {
|
||||
throw new RunnerException('WHOIS only supports "domain" targets here.');
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
$python = config('osint.python_venv').'/bin/python3';
|
||||
$script = config('osint.whois_script');
|
||||
|
||||
if (! is_executable($python) || ! is_file($script)) {
|
||||
throw new RunnerException("WHOIS script not set up. Run scripts/python/setup.sh.");
|
||||
}
|
||||
|
||||
$result = Process::timeout((int) config('osint.run_timeout'))
|
||||
->run([$python, $script, $target->value]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$error = json_decode($result->errorOutput(), true)['error'] ?? $result->errorOutput();
|
||||
throw new RunnerException("WHOIS lookup failed: {$error}");
|
||||
}
|
||||
|
||||
$data = json_decode($result->output(), true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
throw new RunnerException('WHOIS lookup returned unparseable output.');
|
||||
}
|
||||
|
||||
if (empty($data['domain_name']) && empty($data['registrar'])) {
|
||||
return new RunnerResult(raw: $data, summary: 'No WHOIS record found.');
|
||||
}
|
||||
|
||||
$summary = sprintf(
|
||||
'Registrar: %s. Created: %s. Expires: %s. Name servers: %s.',
|
||||
$this->stringify($data['registrar'] ?? null) ?? 'unknown',
|
||||
$this->stringify($data['creation_date'] ?? null) ?? 'unknown',
|
||||
$this->stringify($data['expiration_date'] ?? null) ?? 'unknown',
|
||||
$this->stringify($data['name_servers'] ?? null) ?? 'none'
|
||||
);
|
||||
|
||||
return new RunnerResult(raw: $data, summary: $summary);
|
||||
}
|
||||
|
||||
private function stringify(mixed $value): ?string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return implode(', ', $value) ?: null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user