Closes the email target-type gap (previously had zero collectors).
Checks whether an email is registered on 120+ sites via the
password-reset-oracle technique. Shares the Sherlock/whois venv.
HoleheRunner gates success on the CSV file existing rather than the
process exit code: holehe's CLI calls Python's exit("message") right
after writing results on its normal success path, which always yields
exit code 1 regardless of outcome — discovered while verifying this
end-to-end against the real dev server and queue worker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 KiB
OSINT Project Plan
Overview
Laravel-based OSINT (Open Source Intelligence) platform. Laravel is the
core app (UI, auth, data storage, orchestration); heavier or specialized
collection/enrichment work is delegated to standalone Node and Python
scripts invoked by Laravel (via Process, queued jobs, or scheduled
commands).
Stack
- Backend: Laravel 12 (PHP 8.4)
- Database: MySQL,
osintschema on192.168.0.155:3306(same server used byhuginn/muninn) - Frontend: Laravel default (Blade + Vite), upgrade to Inertia/Vue or Livewire if the UI grows past simple CRUD
- Queue:
databasedriver initially, move to Redis if job volume grows - Scripts:
scripts/node/andscripts/python/at the project root for standalone collectors/enrichers, each callable from Laravel as a subprocess or via a small internal HTTP/CLI contract
Directory conventions
scripts/python/setup.sh— recreates everything below in one step; run it on any new machine (seescripts/python/README.md)scripts/python/.venv— shared venv for lightweight Python collectors (Sherlock,python-whois); not committedscripts/python/.venv-theharvester— separate venv for theHarvester (heavy deps); not committedscripts/python/theharvester-src/— vendored theHarvester source pinned at tag4.6.0; not committedscripts/python/whois_lookup.py— thin wrapper normalizingpython-whoisoutput to JSON forWhoisRunnerto parsescripts/node/<tool>/— same pattern for Node-based collectors, ownpackage.json, not yet usedapp/Osint/—Runnerinterface,RunnerRegistry(source key → runner class),Runners/implementations (SherlockRunner,CrtShRunner,WhoisRunner,IpApiRunner,TheHarvesterRunner)app/Jobs/RunFinding.php— queued job that resolves a runner via the registry and updates theFindingrowstorage/app/osint/runs/<uuid>/— scratch dir a runner works in while a lookup is in progress; deleted when the run finishes
Core domain
- Investigations — a case/project; has an
owner_id(full control), plus per-investigation access grants to individual users or whole groups, each with aroleofvieweroreditor. Owner always wins; a direct user grant beats a group grant; among multiple group grantseditorbeatsviewer. SeeInvestigation::roleFor(). - Groups — simple teams (owner + members) that can be granted access to an investigation as a unit
- Targets — the entity being investigated (person, domain, email, username, IP, org), scoped to one investigation
- Sources — catalog of OSINT tools/techniques (seeded: Sherlock,
theHarvester, crt.sh, WHOIS, ip-api.com, Holehe — all 6 now have a
working runner).
sources.runneris a display-only label; the real mapping isApp\Osint\RunnerRegistry - Findings — result of running a source against a target; raw JSON payload preserved plus a parsed summary
Phases
-
Scaffolding (done) — Laravel app created, DB connected, plan committed
-
Auth + base UI (done) — Laravel Breeze (Blade stack) installed; register/login/logout/password reset/email verify/profile all wired and manually verified against the real DB; test suite green against a dedicated
osint_testingMySQL DB -
Domain models (done) — migrations + models for Groups, Investigations (+ user/group access grants), Targets, Sources, Findings;
InvestigationPolicyenforcing owner/editor/viewer;sourcestable seeded with the 5 starter tools; 8 new tests covering the permission matrix (33/33 passing) -
First script integration (done) — Sherlock wired end-to-end:
App\Osint\Runnerinterface +RunnerRegistry(maps a source'skeyto its runner class — this is how future collectors plug in),SherlockRunnershells out to a venv'dsherlockbinary and parses its--csvoutput,App\Jobs\RunFindingruns on thedatabasequeue and updates theFindingrow (pending → running → completed/failed). Minimal UI built alongside it to actually exercise the pipeline: investigations list/create/show, add-target form, per-target "run source" form (only offers sources whosetarget_typesmatch and that have a registered runner). Manually verified end-to-end against the real dev DB: registered a user, created an investigation, added ausernametarget, ran Sherlock, processed the queue, confirmed 35 matches rendered as links in the UI, then cleaned up the test data. -
Investigation UI polish (done) —
/groups(create, view members, owner adds/removes members by email —GroupPolicy);/investigations/ {id}/access(owner grants/revokes per-user and per-group roles —InvestigationAccessController+InvestigationPolicy::manageAccess, only groups the owner belongs to are selectable); investigation and target delete (owner-only / editor-or-owner respectively, DB-level cascade cleans up targets/findings/grants); targetvaluevalidation is now type-specific (Target::valueRulesFor()— email format, IP format, domain regex, username charset matching whatSherlockRunneraccepts). 19 new feature tests (52/52 passing) plus a full manual pass against the real dev DB: registered two users, confirmed the grantee got 403 before any grant, granted directeditoraccess and confirmed 200 + edit UI, revoked it and confirmed 403 again, then repeated the same check through a group grant (viewerrole correctly hides the edit form), and confirmed a path-traversal-style username target is rejected by validation before it ever reachesSherlockRunner. -
Expand collectors (done) — 4 new runners registered in
RunnerRegistry:CrtShRunner(domain) — plain HTTP call to crt.sh's JSON API, no script needed; dedupes subdomains from certificate SAN entriesIpApiRunner(ip) — plain HTTP call to ip-api.com; also pure PHPWhoisRunner(domain only —python-whoisdoesn't do IP RDAP, sosources.target_typesforwhoiswas narrowed from[domain, ip]to[domain]) — shells out toscripts/python/whois_lookup.py, a small helper that normalizespython-whois's output (datetimes → ISO strings) to JSON on stdoutTheHarvesterRunner(domain) — shells out to a vendoredtheHarvester(pinned to tag4.6.0; 4.7+ requires Python 3.12, which isn't available here) in its own venv (.venv-theharvester) since it pulls in much heavier deps (playwright, censys, shodan, fastapi) than Sherlock; queries only free, no-API-key sources (crt.sh, OTX, HackerTarget, CertSpotter, ThreatMiner, RapidDNS, Anubis)
scripts/python/setup.shnow sets up both venvs + vendors theHarvester in one step (documented inscripts/python/README.md). Show page updated to render any list-shapedraw_payloadfield (subdomains, hosts, emails, ...) as a collapsible list, not just Sherlock'smatches— capped at 50 items with a "+N more" note. 7 new unit tests for the two HTTP-only runners usingHttp::fake()(59/59 passing); Whois/theHarvester aren't unit-tested (real process + network, same as Sherlock) but were verified end-to-end against the real dev DB:example.com→ crt.sh found 11 subdomains, WHOIS returned registrar/dates/nameservers, theHarvester found 502 hosts;8.8.8.8→ ip-api geolocated to Ashburn, VA. All test data cleaned up afterward. -
Search/reporting (done) —
/search(SearchController) finds investigations by name and targets by value, both scoped throughInvestigation::accessibleBy()so results never leak data the user can't see. Per-investigation/report(printable HTML, browser print-to-PDF, no PDF library dependency),/export.csv(findings, one row per target × source),/export.json(full structured dump). CSV export escapes values starting with=+-@/tab/CR to prevent Excel/Sheets formula injection from a user-controlled target value or a finding summary sourced from external tool output. 10 new tests (67/67 passing) plus a manual pass against the real dev server: searched by investigation name and by target value, opened the printable report, downloaded and inspected both CSV and JSON exports. All test data cleaned up afterward. -
Queue worker process management (done) —
deploy/systemd/ osint-queue-worker.service, asystemd --userunit (no sudo needed; this box already has lingering enabled so it survives logout) runningqueue:work --timeout=180 --max-time=3600 --tries=1withRestart=always.--timeoutis set aboveconfig('osint.run_timeout')(120s) — the worker's timeout must exceed a runner's own timeout or the worker kills the job first.--max-timerestarts the worker hourly to pick up code/.envchanges and avoid long-lived-process memory growth. Installed and verified live: dispatched a real job via the web app without manually runningqueue:work, confirmed the persistent service picked it up and completed it (ip-api lookup, ~200ms); killed the worker process directly and confirmed systemd auto-restarted it within seconds. Seedeploy/systemd/README.mdfor install/operate commands. -
Bulk actions + rate limiting (done) — the original 8 phases were all complete; this closed the biggest remaining workflow friction (running each source against each target one at a time).
App\Osint\FindingDispatcher::dispatchAllApplicable()— queues every enabled, registered source that applies to a target's type and doesn't already have apending/runningfinding for it (acompleted/failedone is re-queued, so bulk-running naturally retries prior failures). Shared by a "Run all" button per target and a "Run all targets" button per investigation.- Per-source rate limiting via Laravel's
RateLimitedqueue middleware (RunFinding::middleware()), limits defined inAppServiceProvider::boot(): ip-api.com 45/min (their published limit), crt.sh 30/min, WHOIS 20/min, Sherlock 10/min, theHarvester 5/min (all courtesy limits of ours, protecting the free infra these runners depend on from a bulk run hammering it). RunFinding::$triesraised from 1 to 30: a rate-limiter release counts as a queue attempt, and a bulk run can easily queue dozens of same-source lookups behind one rate-limit window — needs enough attempts to ride that out. AddedRunFinding::failed()so a job that does exhaust retries still marks itsFindingasfailedwith a clear error instead of leaving it stuck atpendingforever. (Business-logic failures — a runner throwing — are unaffected;handle()already catches those internally and never lets them bubble up to Laravel's retry mechanism.)- Bug found and fixed via this work:
CrtShRunnertreated any non-2xx as fatal, but crt.sh (a free, frequently overloaded public service) intermittently returns 404/502/503/timeouts for queries that succeed moments later — confirmed by hand, same query, 404 then 200 with no code changes in between. Added->retry(3, 2000, throw: false). This doesn't fully solve it — hit a sustained bad patch during testing where even 3 retries over ~6s didn't survive it — but it's real instability on crt.sh's end, not something worth over-engineering client-side retries around; the failure path (clear error, re-runnable, doesn't block other queued jobs) already handles it reasonably. - Also reconciled
deploy/systemd/osint-queue-worker.service's--tries=1→--tries=3: a job's own$triesproperty overrides this CLI default, soRunFinding's$tries=30was never at risk, but leaving the generic default at 1 was a footgun for any future job type that doesn't set its own$tries(Laravel's real default if--trieswere omitted entirely is unlimited retries). - 8 new tests (75/75 passing):
FindingDispatcherskip/re-queue logic,RunFinding::middleware()/failed(), HTTP-level bulk-run authorization. Plus a full manual pass against the real dev server + the live systemd worker (notqueue:work --once): bulk-ran 3 sources against one target, bulk-ran across two targets in one investigation, watched theHarvester/crt.sh/whois resolve sequentially through the single worker, hit and diagnosed the crt.sh flakiness live, confirmed a retried lookup completing cleanly. All test data cleaned up afterward.
-
Holehe collector (done) — 6th source, closes the
emailtarget-type gap (previously zero collectors for it). Checks whether an email is registered on 120+ sites via the password-reset-oracle technique; same venv as Sherlock/whois (scripts/python/setup.shupdated).HoleheRunnerfollows the Sherlock/CSV pattern but with one wrinkle discovered while verifying it end-to-end: holehe's CLI calls Python'sexit("message")on its normal success path right after writing the CSV (core.py: export_csv), which always yields exit code 1 — a real successful run and a genuine crash are indistinguishable by exit code alone. Fixed by gating on whether the CSV file actually exists instead ofsuccessful(). Courtesy rate limit: 15/min (osint-runner:holehe). Not unit-tested (real process + network, same as Sherlock/whois/theHarvester); verified by hand instead: registered a user, added anemailtarget, ran Holehe through the live systemd worker, watched it fail with the exit-code-1 quirk before the fix and complete cleanly after, confirmed the summary rendered correctly in the UI. Test data cleaned up afterward.
Open questions
- Any legal/ToS constraints on the sources to be queried?
Environment notes
.envpoints at the shared MySQL server (192.168.0.155), dbosint, credentials match thehuginn/muninnprojects (admin/admin)phpunit.xmlpoints at a separateosint_testingDB on the same server sophp artisan testnever touches real data- There's no
.env.testingfile, soartisan migrate --env=testingdoes not targetosint_testing— it silently runs against the realosintDB. Always usephp artisan test(which honorsphpunit.xml's DB overrides) for anything testing-related, never--env=testingdirectly against migrate/seed - No sudo access in the dev sandbox that created this project; SQLite extension isn't installed for PHP 8.4 here, so MySQL was used instead
QUEUE_CONNECTION=database; the queue worker is supervised bysystemd --user(seedeploy/systemd/) and running continuously — no more need to manually runphp artisan queue:workin dev- The two Python venvs are ~390MB combined (
.venv166MB,.venv-theharvester223MB, mostly playwright/censys/shodan/fastapi pulled in by theHarvester) — worth knowing before assuming this is a lightweight checkout