Add bulk-run actions and per-source rate limiting

Run-all buttons (per target and per investigation) queue every applicable,
not-already-in-flight source via a new FindingDispatcher, re-attempting
prior failures automatically. RunFinding now applies a per-source
RateLimited queue middleware so a bulk run can't hammer the free public
APIs (ip-api, crt.sh, whois, sherlock, theharvester) it depends on;
$tries raised to 30 to survive rate-limiter releases, with a failed()
handler so exhausted retries still resolve the Finding instead of leaving
it stuck pending.

Also fixes CrtShRunner treating crt.sh's intermittent 404/502 (a known
flaky free service) as fatal on the first try — added a bounded retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
cedry2k1992
2026-07-20 11:29:26 +03:00
co-authored by Claude Sonnet 5
parent 853686f3f3
commit e4fa57c3d6
13 changed files with 387 additions and 5 deletions
+50
View File
@@ -160,6 +160,56 @@ commands).
worker process directly and confirmed systemd auto-restarted it worker process directly and confirmed systemd auto-restarted it
within seconds. See `deploy/systemd/README.md` for install/operate within seconds. See `deploy/systemd/README.md` for install/operate
commands. commands.
9. **Bulk actions + rate limiting** (done) — the original 8 phases
were all complete; this closed the biggest remaining workflow
friction (running each source against each target one at a time).
- `App\Osint\FindingDispatcher::dispatchAllApplicable()` — queues
every enabled, registered source that applies to a target's type
and doesn't already have a `pending`/`running` finding for it
(a `completed`/`failed` one *is* re-queued, so bulk-running
naturally retries prior failures). Shared by a "Run all" button
per target and a "Run all targets" button per investigation.
- Per-source rate limiting via Laravel's `RateLimited` queue
middleware (`RunFinding::middleware()`), limits defined in
`AppServiceProvider::boot()`: ip-api.com 45/min (their published
limit), crt.sh 30/min, WHOIS 20/min, Sherlock 10/min,
theHarvester 5/min (all courtesy limits of ours, protecting the
free infra these runners depend on from a bulk run hammering it).
- `RunFinding::$tries` raised from 1 to 30: a rate-limiter release
counts as a queue attempt, and a bulk run can easily queue dozens
of same-source lookups behind one rate-limit window — needs
enough attempts to ride that out. Added `RunFinding::failed()` so
a job that does exhaust retries still marks its `Finding` as
`failed` with a clear error instead of leaving it stuck at
`pending` forever. (Business-logic failures — a runner throwing —
are unaffected; `handle()` already catches those internally and
never lets them bubble up to Laravel's retry mechanism.)
- Bug found and fixed via this work: `CrtShRunner` treated any
non-2xx as fatal, but crt.sh (a free, frequently overloaded
public service) intermittently returns 404/502/503/timeouts for
queries that succeed moments later — confirmed by hand, same
query, 404 then 200 with no code changes in between. Added
`->retry(3, 2000, throw: false)`. This doesn't fully solve it —
hit a sustained bad patch during testing where even 3 retries
over ~6s didn't survive it — but it's real instability on crt.sh's
end, not something worth over-engineering client-side retries
around; the failure path (clear error, re-runnable, doesn't block
other queued jobs) already handles it reasonably.
- Also reconciled `deploy/systemd/osint-queue-worker.service`'s
`--tries=1` → `--tries=3`: a job's own `$tries` property overrides
this CLI default, so `RunFinding`'s `$tries=30` was never at risk,
but leaving the generic default at 1 was a footgun for any future
job type that doesn't set its own `$tries` (Laravel's real
default if `--tries` were omitted entirely is unlimited retries).
- 8 new tests (75/75 passing): `FindingDispatcher` skip/re-queue
logic, `RunFinding::middleware()`/`failed()`, HTTP-level bulk-run
authorization. Plus a full manual pass against the real dev
server + the live systemd worker (not `queue:work --once`):
bulk-ran 3 sources against one target, bulk-ran across two
targets in one investigation, watched theHarvester/crt.sh/whois
resolve sequentially through the single worker, hit and diagnosed
the crt.sh flakiness live, confirmed a retried lookup completing
cleanly. All test data cleaned up afterward.
## Open questions ## Open questions
- Any legal/ToS constraints on the sources to be queried? - Any legal/ToS constraints on the sources to be queried?
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Investigation; use App\Models\Investigation;
use App\Models\Source; use App\Models\Source;
use App\Osint\FindingDispatcher;
use App\Osint\RunnerRegistry; use App\Osint\RunnerRegistry;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -72,4 +73,18 @@ class InvestigationController extends Controller
return redirect()->route('investigations.index'); return redirect()->route('investigations.index');
} }
public function runAll(Request $request, Investigation $investigation, FindingDispatcher $dispatcher): RedirectResponse
{
$this->authorize('update', $investigation);
$queued = $investigation->targets->sum(
fn ($target) => $dispatcher->dispatchAllApplicable($target, $request->user())
);
return redirect()->route('investigations.show', $investigation)
->with('status', $queued > 0
? "Queued {$queued} lookup(s) across all targets..."
: 'Nothing to queue — already up to date.');
}
} }
+15
View File
@@ -7,6 +7,7 @@ use App\Models\Finding;
use App\Models\Investigation; use App\Models\Investigation;
use App\Models\Source; use App\Models\Source;
use App\Models\Target; use App\Models\Target;
use App\Osint\FindingDispatcher;
use App\Osint\RunnerRegistry; use App\Osint\RunnerRegistry;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -74,4 +75,18 @@ class TargetController extends Controller
return redirect()->route('investigations.show', $investigation) return redirect()->route('investigations.show', $investigation)
->with('status', "Running {$source->name} against {$target->value}..."); ->with('status', "Running {$source->name} against {$target->value}...");
} }
public function runAll(Request $request, Investigation $investigation, Target $target, FindingDispatcher $dispatcher): RedirectResponse
{
$this->authorize('update', $investigation);
abort_if($target->investigation_id !== $investigation->id, 404);
$queued = $dispatcher->dispatchAllApplicable($target, $request->user());
return redirect()->route('investigations.show', $investigation)
->with('status', $queued > 0
? "Queued {$queued} lookup(s) for {$target->value}..."
: "Nothing to queue for {$target->value} — already up to date.");
}
} }
+37 -1
View File
@@ -6,18 +6,41 @@ use App\Models\Finding;
use App\Osint\RunnerRegistry; use App\Osint\RunnerRegistry;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\RateLimited;
class RunFinding implements ShouldQueue class RunFinding implements ShouldQueue
{ {
use Queueable; use Queueable;
public int $tries = 1; /**
* Business-logic failures (a runner throwing) are caught in handle()
* below and never bubble up to the queue, so this only needs to cover
* genuine retries: rate-limiter releases (see middleware()) and worker
* crashes. A bulk run against a busy investigation can easily queue
* dozens of same-source lookups behind one rate limit, so this is set
* high enough to ride that out rather than give up mid-queue.
*/
public int $tries = 30;
public function __construct( public function __construct(
public readonly int $findingId, public readonly int $findingId,
) { ) {
} }
/**
* @return array<int, object>
*/
public function middleware(): array
{
$sourceKey = Finding::find($this->findingId)?->source?->key;
if ($sourceKey === null) {
return [];
}
return [new RateLimited('osint-runner:'.$sourceKey)];
}
public function handle(RunnerRegistry $registry): void public function handle(RunnerRegistry $registry): void
{ {
$finding = Finding::with(['target', 'source'])->findOrFail($this->findingId); $finding = Finding::with(['target', 'source'])->findOrFail($this->findingId);
@@ -41,4 +64,17 @@ class RunFinding implements ShouldQueue
]); ]);
} }
} }
/**
* Only reached if the job itself fails outside handle()'s own
* try/catch — e.g. retries exhausted while rate-limited.
*/
public function failed(\Throwable $exception): void
{
Finding::where('id', $this->findingId)->update([
'status' => 'failed',
'error' => 'Gave up after retries: '.$exception->getMessage(),
'ran_at' => now(),
]);
}
} }
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Osint;
use App\Jobs\RunFinding;
use App\Models\Source;
use App\Models\Target;
use App\Models\User;
class FindingDispatcher
{
public function __construct(private readonly RunnerRegistry $registry)
{
}
/**
* Queue a lookup for every enabled, registered source that applies to
* the target's type and doesn't already have a pending/running finding
* for it. Returns how many were queued.
*/
public function dispatchAllApplicable(Target $target, User $runBy): int
{
$applicableSources = Source::where('enabled', true)->get()
->filter(fn (Source $source) => in_array($target->type, $source->target_types, true)
&& $this->registry->has($source->key));
$busySourceIds = $target->findings()
->whereIn('status', ['pending', 'running'])
->pluck('source_id');
$queued = 0;
foreach ($applicableSources as $source) {
if ($busySourceIds->contains($source->id)) {
continue;
}
$finding = $target->findings()->create([
'source_id' => $source->id,
'run_by' => $runBy->id,
'status' => 'pending',
]);
RunFinding::dispatch($finding->id);
$queued++;
}
return $queued;
}
}
+7 -1
View File
@@ -16,14 +16,20 @@ class CrtShRunner implements Runner
throw new RunnerException('crt.sh only supports "domain" targets.'); throw new RunnerException('crt.sh only supports "domain" targets.');
} }
// crt.sh is a free, frequently overloaded public service that
// intermittently returns 404/502/503 for queries that succeed
// moments later (confirmed by hand: same query, 404 then 200 with
// no code changes) — worth a few retries before treating it as a
// real failure.
$response = Http::timeout((int) config('osint.run_timeout')) $response = Http::timeout((int) config('osint.run_timeout'))
->retry(3, 2000, throw: false)
->get('https://crt.sh/', [ ->get('https://crt.sh/', [
'q' => $target->value, 'q' => $target->value,
'output' => 'json', 'output' => 'json',
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
throw new RunnerException('crt.sh request failed with status '.$response->status().'.'); throw new RunnerException('crt.sh request failed with status '.$response->status().' after retries.');
} }
// crt.sh returns an empty body (not valid JSON) when there are no matches. // crt.sh returns an empty body (not valid JSON) when there are no matches.
+10 -1
View File
@@ -2,6 +2,8 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +21,13 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// // Self-imposed limits so bulk-running lookups doesn't hammer the
// free public infrastructure these runners depend on. ip-api.com's
// limit (45/min) is theirs; the rest are courtesy limits of ours.
RateLimiter::for('osint-runner:ip-api', fn () => Limit::perMinute(45));
RateLimiter::for('osint-runner:crtsh', fn () => Limit::perMinute(30));
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));
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ StartLimitIntervalSec=0
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/bin/php /home/m105/projects/osint/artisan queue:work --queue=default --sleep=3 --tries=1 --timeout=180 --max-time=3600 ExecStart=/usr/bin/php /home/m105/projects/osint/artisan queue:work --queue=default --sleep=3 --tries=3 --timeout=180 --max-time=3600
WorkingDirectory=/home/m105/projects/osint WorkingDirectory=/home/m105/projects/osint
Environment="PATH=/home/m105/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" Environment="PATH=/home/m105/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Restart=always Restart=always
+15 -1
View File
@@ -59,7 +59,15 @@
@endif @endif
<div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-6"> <div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold text-gray-800 dark:text-gray-200 mb-4">{{ __('Targets') }}</h3> <div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-gray-800 dark:text-gray-200">{{ __('Targets') }}</h3>
@if ($canEdit && $investigation->targets->count() > 1)
<form method="POST" action="{{ route('investigations.run-all', $investigation) }}">
@csrf
<x-secondary-button type="submit">{{ __('Run all targets') }}</x-secondary-button>
</form>
@endif
</div>
@forelse ($investigation->targets as $target) @forelse ($investigation->targets as $target)
<div class="border-t border-gray-200 dark:border-gray-700 py-4 first:border-t-0 first:pt-0"> <div class="border-t border-gray-200 dark:border-gray-700 py-4 first:border-t-0 first:pt-0">
@@ -90,6 +98,12 @@
<x-secondary-button type="submit">{{ __('Run') }}</x-secondary-button> <x-secondary-button type="submit">{{ __('Run') }}</x-secondary-button>
</form> </form>
@endif @endif
@if ($applicableSources->filter(fn ($s) => $runnableSourceKeys->contains($s->key))->count() > 1)
<form method="POST" action="{{ route('investigations.targets.run-all', [$investigation, $target]) }}">
@csrf
<x-secondary-button type="submit">{{ __('Run all') }}</x-secondary-button>
</form>
@endif
<form method="POST" action="{{ route('investigations.targets.destroy', [$investigation, $target]) }}" onsubmit="return confirm('{{ __('Delete this target and its findings?') }}');"> <form method="POST" action="{{ route('investigations.targets.destroy', [$investigation, $target]) }}" onsubmit="return confirm('{{ __('Delete this target and its findings?') }}');">
@csrf @csrf
@method('DELETE') @method('DELETE')
+2
View File
@@ -25,9 +25,11 @@ Route::middleware('auth')->group(function () {
Route::get('/search', [SearchController::class, 'index'])->name('search.index'); Route::get('/search', [SearchController::class, 'index'])->name('search.index');
Route::resource('investigations', InvestigationController::class)->only(['index', 'create', 'store', 'show', 'destroy']); Route::resource('investigations', InvestigationController::class)->only(['index', 'create', 'store', 'show', 'destroy']);
Route::post('investigations/{investigation}/run-all', [InvestigationController::class, 'runAll'])->name('investigations.run-all');
Route::post('investigations/{investigation}/targets', [TargetController::class, 'store'])->name('investigations.targets.store'); Route::post('investigations/{investigation}/targets', [TargetController::class, 'store'])->name('investigations.targets.store');
Route::delete('investigations/{investigation}/targets/{target}', [TargetController::class, 'destroy'])->name('investigations.targets.destroy'); Route::delete('investigations/{investigation}/targets/{target}', [TargetController::class, 'destroy'])->name('investigations.targets.destroy');
Route::post('investigations/{investigation}/targets/{target}/run', [TargetController::class, 'run'])->name('investigations.targets.run'); Route::post('investigations/{investigation}/targets/{target}/run', [TargetController::class, 'run'])->name('investigations.targets.run');
Route::post('investigations/{investigation}/targets/{target}/run-all', [TargetController::class, 'runAll'])->name('investigations.targets.run-all');
Route::get('investigations/{investigation}/access', [InvestigationAccessController::class, 'index'])->name('investigations.access.index'); Route::get('investigations/{investigation}/access', [InvestigationAccessController::class, 'index'])->name('investigations.access.index');
Route::post('investigations/{investigation}/access/users', [InvestigationAccessController::class, 'storeUser'])->name('investigations.access.users.store'); Route::post('investigations/{investigation}/access/users', [InvestigationAccessController::class, 'storeUser'])->name('investigations.access.users.store');
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace Tests\Feature;
use App\Jobs\RunFinding;
use App\Models\Investigation;
use App\Models\Source;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class BulkRunTest extends TestCase
{
use RefreshDatabase;
public function test_editor_can_run_all_applicable_sources_for_a_target(): void
{
Queue::fake();
$owner = User::factory()->create();
$investigation = Investigation::factory()->for($owner, 'owner')->create();
$target = $investigation->targets()->create(['type' => 'domain', 'value' => 'example.com', 'created_by' => $owner->id]);
Source::create(['key' => 'crtsh', 'name' => 'crt.sh', 'target_types' => ['domain'], 'enabled' => true]);
Source::create(['key' => 'whois', 'name' => 'WHOIS', 'target_types' => ['domain'], 'enabled' => true]);
$this->actingAs($owner)
->post(route('investigations.targets.run-all', [$investigation, $target]))
->assertRedirect(route('investigations.show', $investigation));
Queue::assertPushed(RunFinding::class, 2);
$this->assertDatabaseCount('findings', 2);
}
public function test_run_all_for_investigation_covers_every_target(): void
{
Queue::fake();
$owner = User::factory()->create();
$investigation = Investigation::factory()->for($owner, 'owner')->create();
$domainTarget = $investigation->targets()->create(['type' => 'domain', 'value' => 'example.com', 'created_by' => $owner->id]);
$ipTarget = $investigation->targets()->create(['type' => 'ip', 'value' => '8.8.8.8', 'created_by' => $owner->id]);
Source::create(['key' => 'crtsh', 'name' => 'crt.sh', 'target_types' => ['domain'], 'enabled' => true]);
Source::create(['key' => 'ip-api', 'name' => 'ip-api.com', 'target_types' => ['ip'], 'enabled' => true]);
$this->actingAs($owner)
->post(route('investigations.run-all', $investigation))
->assertRedirect(route('investigations.show', $investigation));
Queue::assertPushed(RunFinding::class, 2);
}
public function test_viewer_cannot_bulk_run(): void
{
$owner = User::factory()->create();
$viewer = User::factory()->create();
$investigation = Investigation::factory()->for($owner, 'owner')->create();
$investigation->users()->attach($viewer, ['role' => 'viewer']);
$target = $investigation->targets()->create(['type' => 'domain', 'value' => 'example.com', 'created_by' => $owner->id]);
$this->actingAs($viewer)
->post(route('investigations.targets.run-all', [$investigation, $target]))
->assertForbidden();
$this->actingAs($viewer)
->post(route('investigations.run-all', $investigation))
->assertForbidden();
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Tests\Unit;
use App\Jobs\RunFinding;
use App\Models\Investigation;
use App\Models\Source;
use App\Models\User;
use App\Osint\FindingDispatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class FindingDispatcherTest extends TestCase
{
use RefreshDatabase;
public function test_queues_only_applicable_registered_sources(): void
{
Queue::fake();
$user = User::factory()->create();
$investigation = Investigation::factory()->for($user, 'owner')->create();
$target = $investigation->targets()->create(['type' => 'username', 'value' => 'someone', 'created_by' => $user->id]);
Source::create(['key' => 'sherlock', 'name' => 'Sherlock', 'target_types' => ['username'], 'enabled' => true]);
Source::create(['key' => 'crtsh', 'name' => 'crt.sh', 'target_types' => ['domain'], 'enabled' => true]);
Source::create(['key' => 'unregistered', 'name' => 'Unregistered', 'target_types' => ['username'], 'enabled' => true]);
$queued = app(FindingDispatcher::class)->dispatchAllApplicable($target, $user);
$this->assertSame(1, $queued);
$this->assertDatabaseCount('findings', 1);
Queue::assertPushed(RunFinding::class, 1);
}
public function test_skips_sources_already_pending_or_running(): void
{
Queue::fake();
$user = User::factory()->create();
$investigation = Investigation::factory()->for($user, 'owner')->create();
$target = $investigation->targets()->create(['type' => 'username', 'value' => 'someone', 'created_by' => $user->id]);
$sherlock = Source::create(['key' => 'sherlock', 'name' => 'Sherlock', 'target_types' => ['username'], 'enabled' => true]);
$target->findings()->create(['source_id' => $sherlock->id, 'status' => 'pending']);
$queued = app(FindingDispatcher::class)->dispatchAllApplicable($target, $user);
$this->assertSame(0, $queued);
$this->assertDatabaseCount('findings', 1);
}
public function test_does_not_skip_a_source_whose_last_finding_already_completed(): void
{
Queue::fake();
$user = User::factory()->create();
$investigation = Investigation::factory()->for($user, 'owner')->create();
$target = $investigation->targets()->create(['type' => 'username', 'value' => 'someone', 'created_by' => $user->id]);
$sherlock = Source::create(['key' => 'sherlock', 'name' => 'Sherlock', 'target_types' => ['username'], 'enabled' => true]);
$target->findings()->create(['source_id' => $sherlock->id, 'status' => 'completed']);
$queued = app(FindingDispatcher::class)->dispatchAllApplicable($target, $user);
$this->assertSame(1, $queued);
$this->assertDatabaseCount('findings', 2);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Tests\Unit\Jobs;
use App\Jobs\RunFinding;
use App\Models\Finding;
use App\Models\Investigation;
use App\Models\Source;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Middleware\RateLimited;
use Tests\TestCase;
class RunFindingTest extends TestCase
{
use RefreshDatabase;
public function test_middleware_rate_limits_by_the_findings_source_key(): void
{
$user = User::factory()->create();
$investigation = Investigation::factory()->for($user, 'owner')->create();
$target = $investigation->targets()->create(['type' => 'ip', 'value' => '8.8.8.8', 'created_by' => $user->id]);
$source = Source::create(['key' => 'ip-api', 'name' => 'ip-api.com', 'target_types' => ['ip'], 'enabled' => true]);
$finding = $target->findings()->create(['source_id' => $source->id, 'status' => 'pending']);
$middleware = (new RunFinding($finding->id))->middleware();
$this->assertCount(1, $middleware);
$this->assertInstanceOf(RateLimited::class, $middleware[0]);
}
public function test_failed_marks_the_finding_as_failed(): void
{
$user = User::factory()->create();
$investigation = Investigation::factory()->for($user, 'owner')->create();
$target = $investigation->targets()->create(['type' => 'ip', 'value' => '8.8.8.8', 'created_by' => $user->id]);
$source = Source::create(['key' => 'ip-api', 'name' => 'ip-api.com', 'target_types' => ['ip'], 'enabled' => true]);
$finding = $target->findings()->create(['source_id' => $source->id, 'status' => 'pending']);
(new RunFinding($finding->id))->failed(new \RuntimeException('retries exhausted'));
$finding->refresh();
$this->assertSame('failed', $finding->status);
$this->assertStringContainsString('retries exhausted', $finding->error);
}
}