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}...");
}
}