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:
cedry2k1992
2026-07-20 10:22:05 +03:00
co-authored by Claude Sonnet 5
commit 873699e23e
165 changed files with 18507 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Osint;
use App\Models\Target;
interface Runner
{
public function run(Target $target): RunnerResult;
}
+7
View File
@@ -0,0 +1,7 @@
<?php
namespace App\Osint;
class RunnerException extends \RuntimeException
{
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Osint;
use App\Osint\Runners\CrtShRunner;
use App\Osint\Runners\IpApiRunner;
use App\Osint\Runners\SherlockRunner;
use App\Osint\Runners\TheHarvesterRunner;
use App\Osint\Runners\WhoisRunner;
class RunnerRegistry
{
/**
* Maps a sources.key value to the Runner implementation that handles it.
* A source with no entry here (or a null `runner` column) isn't
* integrated yet.
*
* @var array<string, class-string<Runner>>
*/
private const RUNNERS = [
'sherlock' => SherlockRunner::class,
'crtsh' => CrtShRunner::class,
'whois' => WhoisRunner::class,
'ip-api' => IpApiRunner::class,
'theharvester' => TheHarvesterRunner::class,
];
public function has(string $sourceKey): bool
{
return isset(self::RUNNERS[$sourceKey]);
}
public function resolve(string $sourceKey): Runner
{
if (! $this->has($sourceKey)) {
throw new RunnerException("No runner registered for source [{$sourceKey}].");
}
return app(self::RUNNERS[$sourceKey]);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Osint;
class RunnerResult
{
/**
* @param array<mixed> $raw Raw, structured output from the tool (stored as-is in findings.raw_payload)
* @param string $summary Short human-readable summary shown in the UI
*/
public function __construct(
public readonly array $raw,
public readonly string $summary,
) {
}
}
+46
View File
@@ -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);
}
}
+44
View File
@@ -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);
}
}
+88
View File
@@ -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;
}
}
+81
View File
@@ -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);
}
}
}
+67
View File
@@ -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;
}
}