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,117 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ __('Manage Access') }} — {{ $investigation->name }}
|
||||
</h2>
|
||||
<a href="{{ route('investigations.show', $investigation) }}" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{{ __('Back to investigation') }}
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 text-sm rounded-md p-4">
|
||||
<ul>
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-gray-200 mb-4">{{ __('Users') }}</h3>
|
||||
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<li class="py-2 flex justify-between items-center">
|
||||
<span>{{ $investigation->owner->name }} <span class="text-xs text-gray-500 dark:text-gray-400">({{ $investigation->owner->email }})</span></span>
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('owner') }}</span>
|
||||
</li>
|
||||
@forelse ($investigation->users as $user)
|
||||
<li class="py-2 flex justify-between items-center">
|
||||
<span>{{ $user->name }} <span class="text-xs text-gray-500 dark:text-gray-400">({{ $user->email }})</span></span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $user->pivot->role }}</span>
|
||||
<form method="POST" action="{{ route('investigations.access.users.destroy', [$investigation, $user]) }}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-600 dark:text-red-400 hover:underline">{{ __('Revoke') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
@endforelse
|
||||
</ul>
|
||||
|
||||
<form method="POST" action="{{ route('investigations.access.users.store', $investigation) }}" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700 flex items-end gap-3">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<x-input-label for="email" :value="__('User email')" />
|
||||
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="role" :value="__('Role')" />
|
||||
<select id="role" name="role" class="mt-1 text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 rounded-md">
|
||||
<option value="viewer">{{ __('viewer') }}</option>
|
||||
<option value="editor">{{ __('editor') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<x-primary-button type="submit">{{ __('Grant') }}</x-primary-button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-gray-200 mb-4">{{ __('Groups') }}</h3>
|
||||
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@forelse ($investigation->groups as $group)
|
||||
<li class="py-2 flex justify-between items-center">
|
||||
<a href="{{ route('groups.show', $group) }}" class="hover:underline">{{ $group->name }}</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $group->pivot->role }}</span>
|
||||
<form method="POST" action="{{ route('investigations.access.groups.destroy', [$investigation, $group]) }}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-600 dark:text-red-400 hover:underline">{{ __('Revoke') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="py-2 text-sm text-gray-500 dark:text-gray-400">{{ __('No groups granted access yet.') }}</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
|
||||
@if ($availableGroups->isNotEmpty())
|
||||
<form method="POST" action="{{ route('investigations.access.groups.store', $investigation) }}" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700 flex items-end gap-3">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<x-input-label for="group_id" :value="__('Group')" />
|
||||
<select id="group_id" name="group_id" class="mt-1 block w-full text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 rounded-md">
|
||||
@foreach ($availableGroups as $group)
|
||||
<option value="{{ $group->id }}">{{ $group->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="group_role" :value="__('Role')" />
|
||||
<select id="group_role" name="role" class="mt-1 text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 rounded-md">
|
||||
<option value="viewer">{{ __('viewer') }}</option>
|
||||
<option value="editor">{{ __('editor') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<x-primary-button type="submit">{{ __('Grant') }}</x-primary-button>
|
||||
</form>
|
||||
@else
|
||||
<p class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __("You don't own or belong to any other groups to grant access to.") }}
|
||||
<a href="{{ route('groups.create') }}" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ __('Create one') }}</a>.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,37 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ __('New Investigation') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<form method="POST" action="{{ route('investigations.store') }}" class="p-6 space-y-6">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name')" required autofocus />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="description" :value="__('Description')" />
|
||||
<textarea id="description" name="description" rows="4"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm">{{ old('description') }}</textarea>
|
||||
<x-input-error :messages="$errors->get('description')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Create') }}</x-primary-button>
|
||||
<a href="{{ route('investigations.index') }}" class="text-sm text-gray-500 dark:text-gray-400 hover:underline">
|
||||
{{ __('Cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,42 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ __('Investigations') }}
|
||||
</h2>
|
||||
<a href="{{ route('investigations.create') }}" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{{ __('New investigation') }}
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900 dark:text-gray-100">
|
||||
@if ($investigations->isEmpty())
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{{ __("You don't have access to any investigations yet.") }}
|
||||
</p>
|
||||
@else
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach ($investigations as $investigation)
|
||||
<li class="py-3">
|
||||
<a href="{{ route('investigations.show', $investigation) }}" class="flex justify-between items-center">
|
||||
<div>
|
||||
<span class="font-medium">{{ $investigation->name }}</span>
|
||||
<span class="ml-2 text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $investigation->status }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $investigation->targets_count }} {{ Str::plural('target', $investigation->targets_count) }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $investigation->name }} — {{ __('Report') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-white text-gray-900 p-8 max-w-4xl mx-auto">
|
||||
<div class="no-print mb-6 flex justify-between items-center">
|
||||
<a href="{{ route('investigations.show', $investigation) }}" class="text-sm text-indigo-600 hover:underline">
|
||||
{{ __('Back to investigation') }}
|
||||
</a>
|
||||
<button onclick="window.print()" class="text-sm bg-gray-800 text-white px-3 py-1.5 rounded-md">
|
||||
{{ __('Print / Save as PDF') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold">{{ $investigation->name }}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{ __('Owner') }}: {{ $investigation->owner->name }} ·
|
||||
{{ __('Status') }}: {{ $investigation->status }} ·
|
||||
{{ __('Generated') }}: {{ now()->toDayDateTimeString() }}
|
||||
</p>
|
||||
|
||||
@if ($investigation->description)
|
||||
<p class="mt-4 text-gray-700">{{ $investigation->description }}</p>
|
||||
@endif
|
||||
|
||||
<h2 class="text-lg font-semibold mt-8 mb-3 border-b border-gray-300 pb-1">{{ __('Targets & Findings') }}</h2>
|
||||
|
||||
@forelse ($investigation->targets as $target)
|
||||
<div class="mt-5 break-inside-avoid">
|
||||
<p class="font-medium">
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500">{{ $target->type }}</span>
|
||||
{{ $target->value }}
|
||||
</p>
|
||||
@if ($target->notes)
|
||||
<p class="text-sm text-gray-600">{{ $target->notes }}</p>
|
||||
@endif
|
||||
|
||||
@forelse ($target->findings as $finding)
|
||||
<div class="ml-4 mt-2 text-sm">
|
||||
<p>
|
||||
<span class="font-medium">{{ $finding->source->name }}</span>
|
||||
<span class="text-gray-500">({{ $finding->status }})</span>
|
||||
</p>
|
||||
@if ($finding->summary)
|
||||
<p class="text-gray-700">{{ $finding->summary }}</p>
|
||||
@endif
|
||||
@if ($finding->error)
|
||||
<p class="text-red-600">{{ $finding->error }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="ml-4 mt-2 text-sm text-gray-400">{{ __('No lookups run yet.') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-500">{{ __('No targets in this investigation.') }}</p>
|
||||
@endforelse
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,186 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ $investigation->name }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('Owned by') }} {{ $investigation->owner->name }} · {{ $investigation->status }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="{{ route('investigations.report', $investigation) }}" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{{ __('Report') }}
|
||||
</a>
|
||||
<a href="{{ route('investigations.export.csv', $investigation) }}" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{{ __('Export CSV') }}
|
||||
</a>
|
||||
<a href="{{ route('investigations.export.json', $investigation) }}" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{{ __('Export JSON') }}
|
||||
</a>
|
||||
@if ($canManageAccess)
|
||||
<a href="{{ route('investigations.access.index', $investigation) }}" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{{ __('Manage access') }}
|
||||
</a>
|
||||
<form method="POST" action="{{ route('investigations.destroy', $investigation) }}" onsubmit="return confirm('{{ __('Delete this investigation and all its targets/findings?') }}');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 dark:text-red-400 hover:underline">{{ __('Delete') }}</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
@if (session('status'))
|
||||
<div class="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-sm rounded-md p-4">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 text-sm rounded-md p-4">
|
||||
<ul>
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($investigation->description)
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-6 text-gray-700 dark:text-gray-300">
|
||||
{{ $investigation->description }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-gray-200 mb-4">{{ __('Targets') }}</h3>
|
||||
|
||||
@forelse ($investigation->targets as $target)
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 py-4 first:border-t-0 first:pt-0">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $target->type }}</span>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{{ $target->value }}</p>
|
||||
@if ($target->notes)
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $target->notes }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($canEdit)
|
||||
@php
|
||||
$applicableSources = $sources->filter(fn ($s) => in_array($target->type, $s->target_types, true));
|
||||
@endphp
|
||||
<div class="flex items-center gap-3">
|
||||
@if ($applicableSources->isNotEmpty())
|
||||
<form method="POST" action="{{ route('investigations.targets.run', [$investigation, $target]) }}" class="flex items-center gap-2">
|
||||
@csrf
|
||||
<select name="source_id" class="text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 rounded-md">
|
||||
@foreach ($applicableSources as $source)
|
||||
<option value="{{ $source->id }}" @disabled(!$runnableSourceKeys->contains($source->key))>
|
||||
{{ $source->name }} @if (!$runnableSourceKeys->contains($source->key)) ({{ __('not yet integrated') }}) @endif
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-secondary-button type="submit">{{ __('Run') }}</x-secondary-button>
|
||||
</form>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('investigations.targets.destroy', [$investigation, $target]) }}" onsubmit="return confirm('{{ __('Delete this target and its findings?') }}');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-600 dark:text-red-400 hover:underline">{{ __('Remove') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($target->findings->isNotEmpty())
|
||||
<ul class="mt-3 space-y-2">
|
||||
@foreach ($target->findings->sortByDesc('id') as $finding)
|
||||
<li class="text-sm bg-gray-50 dark:bg-gray-900 rounded-md p-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-medium">{{ $finding->source->name }}</span>
|
||||
<span class="text-xs uppercase tracking-wide
|
||||
{{ match($finding->status) {
|
||||
'completed' => 'text-green-600 dark:text-green-400',
|
||||
'failed' => 'text-red-600 dark:text-red-400',
|
||||
default => 'text-gray-500 dark:text-gray-400',
|
||||
} }}">
|
||||
{{ $finding->status }}
|
||||
</span>
|
||||
</div>
|
||||
@if ($finding->summary)
|
||||
<p class="text-gray-700 dark:text-gray-300 mt-1">{{ $finding->summary }}</p>
|
||||
@endif
|
||||
@if ($finding->error)
|
||||
<p class="text-red-600 dark:text-red-400 mt-1">{{ $finding->error }}</p>
|
||||
@endif
|
||||
@if ($finding->status === 'completed' && !empty($finding->raw_payload['matches']))
|
||||
<ul class="mt-2 space-y-1">
|
||||
@foreach ($finding->raw_payload['matches'] as $match)
|
||||
<li>
|
||||
<a href="{{ $match['url'] }}" target="_blank" rel="noopener" class="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{{ $match['name'] }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
@if ($finding->status === 'completed')
|
||||
@foreach (($finding->raw_payload ?? []) as $field => $items)
|
||||
@continue($field === 'matches' || !is_array($items) || empty($items) || !is_string($items[0] ?? null))
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-gray-600 dark:text-gray-400">
|
||||
{{ ucfirst($field) }} ({{ count($items) }})
|
||||
</summary>
|
||||
<ul class="mt-1 ml-4 list-disc space-y-0.5">
|
||||
@foreach (array_slice($items, 0, 50) as $item)
|
||||
<li class="text-gray-700 dark:text-gray-300">{{ $item }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@if (count($items) > 50)
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 ml-4">{{ __('+ :count more', ['count' => count($items) - 50]) }}</p>
|
||||
@endif
|
||||
</details>
|
||||
@endforeach
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ __('No targets yet.') }}</p>
|
||||
@endforelse
|
||||
|
||||
@if ($canEdit)
|
||||
<form method="POST" action="{{ route('investigations.targets.store', $investigation) }}" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700 flex flex-wrap items-end gap-3">
|
||||
@csrf
|
||||
<div>
|
||||
<x-input-label for="type" :value="__('Type')" />
|
||||
<select id="type" name="type" class="mt-1 text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 rounded-md">
|
||||
<option value="username">username</option>
|
||||
<option value="domain">domain</option>
|
||||
<option value="email">email</option>
|
||||
<option value="ip">ip</option>
|
||||
<option value="person">person</option>
|
||||
<option value="org">org</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[12rem]">
|
||||
<x-input-label for="value" :value="__('Value')" />
|
||||
<x-text-input id="value" name="value" type="text" class="mt-1 block w-full" required />
|
||||
</div>
|
||||
<x-primary-button type="submit">{{ __('Add target') }}</x-primary-button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
Reference in New Issue
Block a user