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
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class GroupController extends Controller
{
public function index(Request $request): View
{
$user = $request->user();
$groups = Group::query()
->where('owner_id', $user->id)
->orWhereHas('members', fn ($q) => $q->where('users.id', $user->id))
->withCount('members')
->orderBy('name')
->get();
return view('groups.index', compact('groups'));
}
public function create(): View
{
$this->authorize('create', Group::class);
return view('groups.create');
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', Group::class);
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
]);
$group = Group::create([
...$data,
'owner_id' => $request->user()->id,
]);
return redirect()->route('groups.show', $group);
}
public function show(Request $request, Group $group): View
{
$this->authorize('view', $group);
$group->load('owner', 'members');
return view('groups.show', [
'group' => $group,
'canManage' => $request->user()->can('manageMembers', $group),
]);
}
public function addMember(Request $request, Group $group): RedirectResponse
{
$this->authorize('manageMembers', $group);
$data = $request->validate([
'email' => ['required', 'email'],
]);
$user = User::where('email', $data['email'])->first();
if (! $user) {
return back()->withErrors(['email' => 'No user found with that email address.']);
}
if ($user->id === $group->owner_id) {
return back()->withErrors(['email' => 'The group owner is already implicitly a member.']);
}
$group->members()->syncWithoutDetaching([$user->id]);
return redirect()->route('groups.show', $group);
}
public function removeMember(Request $request, Group $group, User $user): RedirectResponse
{
$this->authorize('manageMembers', $group);
$group->members()->detach($user->id);
return redirect()->route('groups.show', $group);
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\Investigation;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class InvestigationAccessController extends Controller
{
public function index(Request $request, Investigation $investigation): View
{
$this->authorize('manageAccess', $investigation);
$investigation->load('users', 'groups');
$availableGroups = Group::query()
->where('owner_id', $request->user()->id)
->orWhereHas('members', fn ($q) => $q->where('users.id', $request->user()->id))
->whereNotIn('id', $investigation->groups->pluck('id'))
->orderBy('name')
->get();
return view('investigations.access', [
'investigation' => $investigation,
'availableGroups' => $availableGroups,
]);
}
public function storeUser(Request $request, Investigation $investigation): RedirectResponse
{
$this->authorize('manageAccess', $investigation);
$data = $request->validate([
'email' => ['required', 'email'],
'role' => ['required', 'string', 'in:viewer,editor'],
]);
$user = User::where('email', $data['email'])->first();
if (! $user) {
return back()->withErrors(['email' => 'No user found with that email address.']);
}
if ($user->id === $investigation->owner_id) {
return back()->withErrors(['email' => 'The owner already has full access.']);
}
$investigation->users()->syncWithoutDetaching([$user->id => ['role' => $data['role']]]);
return redirect()->route('investigations.access.index', $investigation);
}
public function destroyUser(Investigation $investigation, User $user): RedirectResponse
{
$this->authorize('manageAccess', $investigation);
$investigation->users()->detach($user->id);
return redirect()->route('investigations.access.index', $investigation);
}
public function storeGroup(Request $request, Investigation $investigation): RedirectResponse
{
$this->authorize('manageAccess', $investigation);
$data = $request->validate([
'group_id' => ['required', 'integer', 'exists:groups,id'],
'role' => ['required', 'string', 'in:viewer,editor'],
]);
$group = Group::findOrFail($data['group_id']);
abort_unless($group->isAccessibleBy($request->user()), 403);
$investigation->groups()->syncWithoutDetaching([$group->id => ['role' => $data['role']]]);
return redirect()->route('investigations.access.index', $investigation);
}
public function destroyGroup(Investigation $investigation, Group $group): RedirectResponse
{
$this->authorize('manageAccess', $investigation);
$investigation->groups()->detach($group->id);
return redirect()->route('investigations.access.index', $investigation);
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers;
use App\Models\Investigation;
use App\Models\Source;
use App\Osint\RunnerRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class InvestigationController extends Controller
{
public function index(Request $request): View
{
$investigations = Investigation::query()
->accessibleBy($request->user())
->withCount('targets')
->latest()
->get();
return view('investigations.index', compact('investigations'));
}
public function create(): View
{
$this->authorize('create', Investigation::class);
return view('investigations.create');
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', Investigation::class);
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
]);
$investigation = Investigation::create([
...$data,
'owner_id' => $request->user()->id,
'status' => 'open',
]);
return redirect()->route('investigations.show', $investigation);
}
public function show(Request $request, Investigation $investigation, RunnerRegistry $registry): View
{
$this->authorize('view', $investigation);
$investigation->load(['owner', 'targets.creator', 'targets.findings.source']);
$sources = Source::where('enabled', true)->orderBy('name')->get();
return view('investigations.show', [
'investigation' => $investigation,
'canEdit' => $investigation->isEditableBy($request->user()),
'canManageAccess' => $request->user()->can('manageAccess', $investigation),
'sources' => $sources,
'runnableSourceKeys' => $sources->filter(fn (Source $s) => $registry->has($s->key))->pluck('key'),
]);
}
public function destroy(Investigation $investigation): RedirectResponse
{
$this->authorize('delete', $investigation);
$investigation->delete();
return redirect()->route('investigations.index');
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Models\Investigation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class InvestigationReportController extends Controller
{
public function show(Request $request, Investigation $investigation): View
{
$this->authorize('view', $investigation);
$investigation->load(['owner', 'targets.creator', 'targets.findings.source']);
return view('investigations.report', compact('investigation'));
}
public function csv(Request $request, Investigation $investigation): Response
{
$this->authorize('view', $investigation);
$investigation->load(['targets.findings.source']);
$rows = ["target_type,target_value,source,status,summary,ran_at\n"];
foreach ($investigation->targets as $target) {
foreach ($target->findings as $finding) {
$rows[] = implode(',', array_map(
fn ($v) => '"'.str_replace('"', '""', $this->csvSafe($v)).'"',
[
$target->type,
$target->value,
$finding->source->name,
$finding->status,
$finding->summary,
$finding->ran_at,
]
))."\n";
}
}
$filename = 'investigation-'.$investigation->id.'-findings.csv';
return response(implode('', $rows), 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]);
}
/**
* Prefix values that Excel/Sheets would interpret as a formula, to
* prevent CSV injection via user-controlled target values or finding
* summaries pulled from external tool output.
*/
private function csvSafe(mixed $value): string
{
$value = (string) $value;
return preg_match('/^[=+\-@\t\r]/', $value) ? "'".$value : $value;
}
public function json(Request $request, Investigation $investigation): JsonResponse
{
$this->authorize('view', $investigation);
$investigation->load(['owner', 'targets.creator', 'targets.findings.source']);
$filename = 'investigation-'.$investigation->id.'.json';
return response()->json([
'name' => $investigation->name,
'description' => $investigation->description,
'status' => $investigation->status,
'owner' => $investigation->owner->name,
'created_at' => $investigation->created_at,
'targets' => $investigation->targets->map(fn ($target) => [
'type' => $target->type,
'value' => $target->value,
'notes' => $target->notes,
'created_by' => $target->creator->name,
'findings' => $target->findings->map(fn ($finding) => [
'source' => $finding->source->name,
'status' => $finding->status,
'summary' => $finding->summary,
'raw_payload' => $finding->raw_payload,
'ran_at' => $finding->ran_at,
]),
]),
])->withHeaders([
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Models\Investigation;
use App\Models\Target;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SearchController extends Controller
{
public function index(Request $request): View
{
$query = trim((string) $request->query('q', ''));
$investigations = collect();
$targets = collect();
if ($query !== '') {
$investigations = Investigation::query()
->accessibleBy($request->user())
->where('name', 'like', "%{$query}%")
->orderBy('name')
->get();
$targets = Target::query()
->whereHas('investigation', fn ($q) => $q->accessibleBy($request->user()))
->where('value', 'like', "%{$query}%")
->with('investigation')
->orderBy('value')
->limit(100)
->get();
}
return view('search.index', [
'query' => $query,
'investigations' => $investigations,
'targets' => $targets,
]);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\RunFinding;
use App\Models\Finding;
use App\Models\Investigation;
use App\Models\Source;
use App\Models\Target;
use App\Osint\RunnerRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TargetController extends Controller
{
public function store(Request $request, Investigation $investigation): RedirectResponse
{
$this->authorize('update', $investigation);
$type = $request->validate([
'type' => ['required', 'string', 'in:'.implode(',', Target::TYPES)],
])['type'];
$data = $request->validate([
'value' => Target::valueRulesFor($type),
'notes' => ['nullable', 'string', 'max:5000'],
]);
$investigation->targets()->create([
...$data,
'type' => $type,
'created_by' => $request->user()->id,
]);
return redirect()->route('investigations.show', $investigation);
}
public function destroy(Investigation $investigation, Target $target): RedirectResponse
{
$this->authorize('update', $investigation);
abort_if($target->investigation_id !== $investigation->id, 404);
$target->delete();
return redirect()->route('investigations.show', $investigation);
}
public function run(Request $request, Investigation $investigation, Target $target, RunnerRegistry $registry): RedirectResponse
{
$this->authorize('update', $investigation);
abort_if($target->investigation_id !== $investigation->id, 404);
$data = $request->validate([
'source_id' => ['required', 'integer', 'exists:sources,id'],
]);
$source = Source::findOrFail($data['source_id']);
if (! in_array($target->type, $source->target_types, true) || ! $registry->has($source->key)) {
return back()->withErrors(['source_id' => "{$source->name} can't be run against this target."]);
}
$finding = Finding::create([
'target_id' => $target->id,
'source_id' => $source->id,
'run_by' => $request->user()->id,
'status' => 'pending',
]);
RunFinding::dispatch($finding->id);
return redirect()->route('investigations.show', $investigation)
->with('status', "Running {$source->name} against {$target->value}...");
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Models\Finding;
use App\Osint\RunnerRegistry;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class RunFinding implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(
public readonly int $findingId,
) {
}
public function handle(RunnerRegistry $registry): void
{
$finding = Finding::with(['target', 'source'])->findOrFail($this->findingId);
$finding->update(['status' => 'running']);
try {
$result = $registry->resolve($finding->source->key)->run($finding->target);
$finding->update([
'status' => 'completed',
'raw_payload' => $result->raw,
'summary' => $result->summary,
'ran_at' => now(),
]);
} catch (\Throwable $e) {
$finding->update([
'status' => 'failed',
'error' => $e->getMessage(),
'ran_at' => now(),
]);
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['target_id', 'source_id', 'run_by', 'status', 'raw_payload', 'summary', 'error', 'ran_at'])]
class Finding extends Model
{
protected function casts(): array
{
return [
'raw_payload' => 'array',
'ran_at' => 'datetime',
];
}
public function target(): BelongsTo
{
return $this->belongsTo(Target::class);
}
public function source(): BelongsTo
{
return $this->belongsTo(Source::class);
}
public function runByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'run_by');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'description', 'owner_id'])]
class Group extends Model
{
use HasFactory;
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class)->withTimestamps();
}
public function investigations(): BelongsToMany
{
return $this->belongsToMany(Investigation::class, 'investigation_group')
->withPivot('role')
->withTimestamps();
}
public function isAccessibleBy(User $user): bool
{
return $this->owner_id === $user->id
|| $this->members()->where('users.id', $user->id)->exists();
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'description', 'owner_id', 'status'])]
class Investigation extends Model
{
use HasFactory;
/**
* Investigations owned by, directly granted to, or group-granted to the user.
* Used for listing; per-item role/edit checks still go through roleFor().
*/
public function scopeAccessibleBy(Builder $query, User $user): Builder
{
return $query->where(function (Builder $q) use ($user) {
$q->where('owner_id', $user->id)
->orWhereHas('users', fn (Builder $q2) => $q2->where('users.id', $user->id))
->orWhereHas('groups', fn (Builder $q2) => $q2->whereIn(
'groups.id',
$user->groups()->pluck('groups.id')
));
});
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('role')
->withTimestamps();
}
public function groups(): BelongsToMany
{
return $this->belongsToMany(Group::class, 'investigation_group')
->withPivot('role')
->withTimestamps();
}
public function targets(): HasMany
{
return $this->hasMany(Target::class);
}
/**
* The role a user has on this investigation, or null if they have no access.
* Owner implicitly has the 'owner' role; direct grants win over group grants,
* and among group grants 'editor' wins over 'viewer'.
*/
public function roleFor(User $user): ?string
{
if ($this->owner_id === $user->id) {
return 'owner';
}
$directRole = $this->users()->where('users.id', $user->id)->value('investigation_user.role');
if ($directRole !== null) {
return $directRole;
}
$groupRoles = $this->groups()
->whereIn('groups.id', $user->groups()->pluck('groups.id'))
->pluck('investigation_group.role');
if ($groupRoles->isEmpty()) {
return null;
}
return $groupRoles->contains('editor') ? 'editor' : 'viewer';
}
public function isAccessibleBy(User $user): bool
{
return $this->roleFor($user) !== null;
}
public function isEditableBy(User $user): bool
{
return in_array($this->roleFor($user), ['owner', 'editor'], true);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['key', 'name', 'description', 'target_types', 'runner', 'enabled'])]
class Source extends Model
{
protected function casts(): array
{
return [
'target_types' => 'array',
'enabled' => 'boolean',
];
}
public function findings(): HasMany
{
return $this->hasMany(Finding::class);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['investigation_id', 'created_by', 'type', 'value', 'notes'])]
class Target extends Model
{
public const TYPES = ['person', 'domain', 'email', 'username', 'ip', 'org'];
/**
* Format validation for the `value` column, per target type.
* Kept in sync with runners that assume a given shape (e.g.
* SherlockRunner's username charset check).
*
* @return array<int, string>
*/
public static function valueRulesFor(string $type): array
{
return match ($type) {
'email' => ['required', 'string', 'email', 'max:255'],
'domain' => ['required', 'string', 'max:255', 'regex:/^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
'ip' => ['required', 'ip'],
'username' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9._-]+$/'],
default => ['required', 'string', 'max:255'],
};
}
public function investigation(): BelongsTo
{
return $this->belongsTo(Investigation::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function findings(): HasMany
{
return $this->hasMany(Finding::class);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function ownedGroups(): HasMany
{
return $this->hasMany(Group::class, 'owner_id');
}
public function groups(): BelongsToMany
{
return $this->belongsToMany(Group::class)->withTimestamps();
}
public function ownedInvestigations(): HasMany
{
return $this->hasMany(Investigation::class, 'owner_id');
}
public function investigations(): BelongsToMany
{
return $this->belongsToMany(Investigation::class)
->withPivot('role')
->withTimestamps();
}
}
+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;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Policies;
use App\Models\Group;
use App\Models\User;
class GroupPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Group $group): bool
{
return $group->isAccessibleBy($user);
}
public function create(User $user): bool
{
return true;
}
public function update(User $user, Group $group): bool
{
return $group->owner_id === $user->id;
}
public function delete(User $user, Group $group): bool
{
return $group->owner_id === $user->id;
}
/**
* Only the owner can add/remove members.
*/
public function manageMembers(User $user, Group $group): bool
{
return $group->owner_id === $user->id;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Policies;
use App\Models\Investigation;
use App\Models\User;
class InvestigationPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Investigation $investigation): bool
{
return $investigation->isAccessibleBy($user);
}
public function create(User $user): bool
{
return true;
}
public function update(User $user, Investigation $investigation): bool
{
return $investigation->isEditableBy($user);
}
public function delete(User $user, Investigation $investigation): bool
{
return $investigation->owner_id === $user->id;
}
public function restore(User $user, Investigation $investigation): bool
{
return $investigation->owner_id === $user->id;
}
public function forceDelete(User $user, Investigation $investigation): bool
{
return $investigation->owner_id === $user->id;
}
/**
* Only the owner can grant/revoke access to users or groups.
*/
public function manageAccess(User $user, Investigation $investigation): bool
{
return $investigation->owner_id === $user->id;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}