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