From dc4388eabf892300ac01276545b574fc93140fca Mon Sep 17 00:00:00 2001 From: cedry2k1992 Date: Mon, 20 Jul 2026 12:10:27 +0300 Subject: [PATCH] Add OpenCorporates collector for org targets, gated on an API token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last coverage gap for org (person remains intentionally unaddressed — no good free/non-scraping option exists). Unlike the other 8 sources, OpenCorporates now requires an API token even on its free tier (confirmed: keyless request gets 401). RunnerRegistry::has() checks services.opencorporates.token is configured before exposing the source as runnable, so it degrades to "not yet integrated" in the UI rather than a broken Run button, until OPENCORPORATES_API_TOKEN is set. Also fixes a real bug hit while building this: a RunnerRegistry::REQUIRES class constant mapping source key to a closure crashed on file load (PHP class constants can't hold closures) — inlined the one check instead of building an abstraction for a single case. Co-Authored-By: Claude Sonnet 5 --- .env.example | 3 + PLAN.md | 41 +++++++- app/Osint/RunnerRegistry.php | 15 ++- app/Osint/Runners/OpenCorporatesRunner.php | 56 +++++++++++ app/Providers/AppServiceProvider.php | 5 + config/services.php | 8 ++ database/seeders/SourceSeeder.php | 7 ++ tests/Unit/Osint/OpenCorporatesRunnerTest.php | 95 +++++++++++++++++++ tests/Unit/Osint/RunnerRegistryTest.php | 34 +++++++ 9 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 app/Osint/Runners/OpenCorporatesRunner.php create mode 100644 tests/Unit/Osint/OpenCorporatesRunnerTest.php create mode 100644 tests/Unit/Osint/RunnerRegistryTest.php diff --git a/.env.example b/.env.example index 474c0a2..f728973 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,6 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Free tier: register at https://opencorporates.com/api_accounts/new +OPENCORPORATES_API_TOKEN= diff --git a/PLAN.md b/PLAN.md index d4085d0..a8ed592 100644 --- a/PLAN.md +++ b/PLAN.md @@ -52,8 +52,9 @@ commands). username, IP, org), scoped to one investigation - **Sources** — catalog of OSINT tools/techniques (seeded: Sherlock, theHarvester, crt.sh, WHOIS, ip-api.com, Holehe, Gravatar, DNS - Records — all 8 now have a working runner). `sources.runner` is a - display-only label; the real mapping is `App\Osint\RunnerRegistry` + Records, OpenCorporates — 9 total, 8 runnable out of the box; + OpenCorporates needs a free API token, see phase 12). `sources.runner` + is a display-only label; the real mapping is `App\Osint\RunnerRegistry` - **Findings** — result of running a source against a target; raw JSON payload preserved plus a parsed summary @@ -257,6 +258,42 @@ commands). (22 records across all 5 present types, each rendering as its own labeled section in the UI with correct counts and values). Test data cleaned up afterward. +12. **OpenCorporates collector** (done) — 9th source, first attempt at + closing the `org` gap. Correction to what was assumed when this + was first suggested: OpenCorporates now requires an API token even + on the free tier — confirmed by hand (keyless request → `401 + Invalid Api Token`). Free tier still exists (50 req/day, 200/month, + register at opencorporates.com/api_accounts/new) but isn't fully + keyless like the others. + - `services.opencorporates.token` (`OPENCORPORATES_API_TOKEN` in + `.env`) holds the token; left blank by default. + - `RunnerRegistry::has('opencorporates')` additionally checks the + token is configured — without one, the source is hidden from + the "runnable" list (shows "not yet integrated", same as any + other unbuilt runner) instead of exposing a "Run" button that + always fails. Verified both layers by hand: the UI correctly + grays it out, and a direct POST bypassing the UI still gets + rejected server-side with a clear error and creates no `Finding` + row. + - Rate limit is their real quota (50/day) via `Limit::perDay(50)`, + not a courtesy limit like the other sources — protects the + configured token from getting exhausted or suspended. + - Hit one real bug while wiring the token-gating: PHP class + constants can't hold closures (`Constant expression contains + invalid operations`) — an earlier `REQUIRES` map of source key → + closure crashed every test process on file load. Fixed by + inlining the one special case directly in `has()` instead of + building a small closure-map abstraction for what's currently a + single entry. + - 8 new tests (93/93 passing) — `OpenCorporatesRunner` via + `Http::fake()` (token missing, results found/not-found, wrong + target type, HTTP failure), `RunnerRegistry` gating logic + directly. Not runnable end-to-end without a real token (none + configured in this environment) — verified what *can* be + verified without one: the gating behavior in both the UI and + the controller. **Whoever wants this source live needs to + register for a free OpenCorporates account and set + `OPENCORPORATES_API_TOKEN` in `.env`.** ## Open questions - Any legal/ToS constraints on the sources to be queried? diff --git a/app/Osint/RunnerRegistry.php b/app/Osint/RunnerRegistry.php index b498b60..aaddcc9 100644 --- a/app/Osint/RunnerRegistry.php +++ b/app/Osint/RunnerRegistry.php @@ -7,6 +7,7 @@ use App\Osint\Runners\DnsRunner; use App\Osint\Runners\GravatarRunner; use App\Osint\Runners\HoleheRunner; use App\Osint\Runners\IpApiRunner; +use App\Osint\Runners\OpenCorporatesRunner; use App\Osint\Runners\SherlockRunner; use App\Osint\Runners\TheHarvesterRunner; use App\Osint\Runners\WhoisRunner; @@ -29,11 +30,23 @@ class RunnerRegistry 'holehe' => HoleheRunner::class, 'gravatar' => GravatarRunner::class, 'dns' => DnsRunner::class, + 'opencorporates' => OpenCorporatesRunner::class, ]; public function has(string $sourceKey): bool { - return isset(self::RUNNERS[$sourceKey]); + if (! isset(self::RUNNERS[$sourceKey])) { + return false; + } + + // OpenCorporates additionally needs an API token configured before + // it's actually runnable — otherwise it's hidden rather than shown + // as a broken "Run" button that always fails. + if ($sourceKey === 'opencorporates') { + return filled(config('services.opencorporates.token')); + } + + return true; } public function resolve(string $sourceKey): Runner diff --git a/app/Osint/Runners/OpenCorporatesRunner.php b/app/Osint/Runners/OpenCorporatesRunner.php new file mode 100644 index 0000000..82e5c58 --- /dev/null +++ b/app/Osint/Runners/OpenCorporatesRunner.php @@ -0,0 +1,56 @@ +type !== 'org') { + throw new RunnerException('OpenCorporates only supports "org" targets.'); + } + + $token = config('services.opencorporates.token'); + if (blank($token)) { + throw new RunnerException('OpenCorporates API token not configured (OPENCORPORATES_API_TOKEN).'); + } + + $response = Http::timeout((int) config('osint.run_timeout')) + ->get('https://api.opencorporates.com/v0.4/companies/search', [ + 'q' => $target->value, + 'api_token' => $token, + 'per_page' => 10, + ]); + + if (! $response->successful()) { + throw new RunnerException('OpenCorporates request failed with status '.$response->status().'.'); + } + + $companies = $response->json('results.companies') ?? []; + + $matches = collect($companies)->map(function (array $entry) { + $company = $entry['company']; + + $label = sprintf( + '%s (%s%s)', + $company['name'], + $company['jurisdiction_code'] ?? 'unknown jurisdiction', + $company['current_status'] ? ', '.$company['current_status'] : '' + ); + + return ['name' => $label, 'url' => $company['opencorporates_url']]; + })->all(); + + $summary = count($matches) > 0 + ? sprintf('Found %d compan%s matching "%s".', count($matches), count($matches) === 1 ? 'y' : 'ies', $target->value) + : sprintf('No companies found matching "%s".', $target->value); + + return new RunnerResult(raw: ['matches' => $matches], summary: $summary); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 367061d..4fadc70 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -32,5 +32,10 @@ class AppServiceProvider extends ServiceProvider RateLimiter::for('osint-runner:holehe', fn () => Limit::perMinute(15)); RateLimiter::for('osint-runner:gravatar', fn () => Limit::perMinute(45)); RateLimiter::for('osint-runner:dns', fn () => Limit::perMinute(60)); + + // OpenCorporates' free tier is 50 requests/day, 200/month — this + // matches their real quota (not a courtesy limit) to protect the + // configured token from getting exhausted or suspended. + RateLimiter::for('osint-runner:opencorporates', fn () => Limit::perDay(50)); } } diff --git a/config/services.php b/config/services.php index 6a90eb8..78d4768 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,12 @@ return [ ], ], + 'opencorporates' => [ + // Free tier requires registering for an account at + // opencorporates.com/api_accounts/new — 50 requests/day, 200/month. + // Left blank, the source is hidden from "runnable" (see + // RunnerRegistry::has()) rather than shown broken. + 'token' => env('OPENCORPORATES_API_TOKEN'), + ], + ]; diff --git a/database/seeders/SourceSeeder.php b/database/seeders/SourceSeeder.php index 63382fe..237718b 100644 --- a/database/seeders/SourceSeeder.php +++ b/database/seeders/SourceSeeder.php @@ -70,6 +70,13 @@ class SourceSeeder extends Seeder 'target_types' => ['domain'], 'runner' => 'dns:records', ], + [ + 'key' => 'opencorporates', + 'name' => 'OpenCorporates', + 'description' => 'Company registration search by name. Requires a free OpenCorporates API token (OPENCORPORATES_API_TOKEN) — hidden from "runnable" sources until one is configured.', + 'target_types' => ['org'], + 'runner' => 'http:opencorporates', + ], ]; foreach ($sources as $source) { diff --git a/tests/Unit/Osint/OpenCorporatesRunnerTest.php b/tests/Unit/Osint/OpenCorporatesRunnerTest.php new file mode 100644 index 0000000..4c09f33 --- /dev/null +++ b/tests/Unit/Osint/OpenCorporatesRunnerTest.php @@ -0,0 +1,95 @@ + null]); + + $target = new Target(['type' => 'org', 'value' => 'Acme Inc']); + + $this->expectException(RunnerException::class); + $this->expectExceptionMessage('API token not configured'); + + (new OpenCorporatesRunner)->run($target); + } + + public function test_parses_search_results_into_matches(): void + { + config(['services.opencorporates.token' => 'test-token']); + + Http::fake([ + 'api.opencorporates.com/*' => Http::response([ + 'results' => [ + 'companies' => [ + [ + 'company' => [ + 'name' => 'Acme Inc', + 'jurisdiction_code' => 'us_de', + 'current_status' => 'Active', + 'opencorporates_url' => 'https://opencorporates.com/companies/us_de/123', + ], + ], + ], + ], + ]), + ]); + + $target = new Target(['type' => 'org', 'value' => 'Acme Inc']); + + $result = (new OpenCorporatesRunner)->run($target); + + $this->assertCount(1, $result->raw['matches']); + $this->assertSame('Acme Inc (us_de, Active)', $result->raw['matches'][0]['name']); + $this->assertSame('https://opencorporates.com/companies/us_de/123', $result->raw['matches'][0]['url']); + $this->assertStringContainsString('Found 1 company', $result->summary); + } + + public function test_reports_no_companies_found(): void + { + config(['services.opencorporates.token' => 'test-token']); + + Http::fake([ + 'api.opencorporates.com/*' => Http::response(['results' => ['companies' => []]]), + ]); + + $target = new Target(['type' => 'org', 'value' => 'Nonexistent Org XYZ']); + + $result = (new OpenCorporatesRunner)->run($target); + + $this->assertSame([], $result->raw['matches']); + $this->assertStringContainsString('No companies found', $result->summary); + } + + public function test_rejects_non_org_targets(): void + { + config(['services.opencorporates.token' => 'test-token']); + + $target = new Target(['type' => 'domain', 'value' => 'example.com']); + + $this->expectException(RunnerException::class); + + (new OpenCorporatesRunner)->run($target); + } + + public function test_throws_on_http_failure(): void + { + config(['services.opencorporates.token' => 'test-token']); + + Http::fake(['api.opencorporates.com/*' => Http::response('', 401)]); + + $target = new Target(['type' => 'org', 'value' => 'Acme Inc']); + + $this->expectException(RunnerException::class); + + (new OpenCorporatesRunner)->run($target); + } +} diff --git a/tests/Unit/Osint/RunnerRegistryTest.php b/tests/Unit/Osint/RunnerRegistryTest.php new file mode 100644 index 0000000..9c337b3 --- /dev/null +++ b/tests/Unit/Osint/RunnerRegistryTest.php @@ -0,0 +1,34 @@ + null]); + + $this->assertFalse((new RunnerRegistry)->has('opencorporates')); + } + + public function test_opencorporates_is_available_once_a_token_is_configured(): void + { + config(['services.opencorporates.token' => 'test-token']); + + $this->assertTrue((new RunnerRegistry)->has('opencorporates')); + } + + public function test_unconditional_runners_are_always_available(): void + { + $this->assertTrue((new RunnerRegistry)->has('sherlock')); + $this->assertTrue((new RunnerRegistry)->has('dns')); + } + + public function test_unknown_source_key_is_never_available(): void + { + $this->assertFalse((new RunnerRegistry)->has('not-a-real-source')); + } +}