Add OpenCorporates collector for org targets, gated on an API token

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 <noreply@anthropic.com>
This commit is contained in:
cedry2k1992
2026-07-20 12:10:27 +03:00
co-authored by Claude Sonnet 5
parent 86f73c46b2
commit dc4388eabf
9 changed files with 261 additions and 3 deletions
+3
View File
@@ -63,3 +63,6 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# Free tier: register at https://opencorporates.com/api_accounts/new
OPENCORPORATES_API_TOKEN=
+39 -2
View File
@@ -52,8 +52,9 @@ commands).
username, IP, org), scoped to one investigation username, IP, org), scoped to one investigation
- **Sources** — catalog of OSINT tools/techniques (seeded: Sherlock, - **Sources** — catalog of OSINT tools/techniques (seeded: Sherlock,
theHarvester, crt.sh, WHOIS, ip-api.com, Holehe, Gravatar, DNS theHarvester, crt.sh, WHOIS, ip-api.com, Holehe, Gravatar, DNS
Records — all 8 now have a working runner). `sources.runner` is a Records, OpenCorporates — 9 total, 8 runnable out of the box;
display-only label; the real mapping is `App\Osint\RunnerRegistry` 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 - **Findings** — result of running a source against a target; raw JSON
payload preserved plus a parsed summary payload preserved plus a parsed summary
@@ -257,6 +258,42 @@ commands).
(22 records across all 5 present types, each rendering as its (22 records across all 5 present types, each rendering as its
own labeled section in the UI with correct counts and values). own labeled section in the UI with correct counts and values).
Test data cleaned up afterward. 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 ## Open questions
- Any legal/ToS constraints on the sources to be queried? - Any legal/ToS constraints on the sources to be queried?
+14 -1
View File
@@ -7,6 +7,7 @@ use App\Osint\Runners\DnsRunner;
use App\Osint\Runners\GravatarRunner; use App\Osint\Runners\GravatarRunner;
use App\Osint\Runners\HoleheRunner; use App\Osint\Runners\HoleheRunner;
use App\Osint\Runners\IpApiRunner; use App\Osint\Runners\IpApiRunner;
use App\Osint\Runners\OpenCorporatesRunner;
use App\Osint\Runners\SherlockRunner; use App\Osint\Runners\SherlockRunner;
use App\Osint\Runners\TheHarvesterRunner; use App\Osint\Runners\TheHarvesterRunner;
use App\Osint\Runners\WhoisRunner; use App\Osint\Runners\WhoisRunner;
@@ -29,11 +30,23 @@ class RunnerRegistry
'holehe' => HoleheRunner::class, 'holehe' => HoleheRunner::class,
'gravatar' => GravatarRunner::class, 'gravatar' => GravatarRunner::class,
'dns' => DnsRunner::class, 'dns' => DnsRunner::class,
'opencorporates' => OpenCorporatesRunner::class,
]; ];
public function has(string $sourceKey): bool 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 public function resolve(string $sourceKey): Runner
@@ -0,0 +1,56 @@
<?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 OpenCorporatesRunner implements Runner
{
public function run(Target $target): RunnerResult
{
if ($target->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);
}
}
+5
View File
@@ -32,5 +32,10 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('osint-runner:holehe', fn () => Limit::perMinute(15)); RateLimiter::for('osint-runner:holehe', fn () => Limit::perMinute(15));
RateLimiter::for('osint-runner:gravatar', fn () => Limit::perMinute(45)); RateLimiter::for('osint-runner:gravatar', fn () => Limit::perMinute(45));
RateLimiter::for('osint-runner:dns', fn () => Limit::perMinute(60)); 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));
} }
} }
+8
View File
@@ -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'),
],
]; ];
+7
View File
@@ -70,6 +70,13 @@ class SourceSeeder extends Seeder
'target_types' => ['domain'], 'target_types' => ['domain'],
'runner' => 'dns:records', '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) { foreach ($sources as $source) {
@@ -0,0 +1,95 @@
<?php
namespace Tests\Unit\Osint;
use App\Models\Target;
use App\Osint\Runners\OpenCorporatesRunner;
use App\Osint\RunnerException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class OpenCorporatesRunnerTest extends TestCase
{
public function test_throws_when_no_token_configured(): void
{
config(['services.opencorporates.token' => 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);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Tests\Unit\Osint;
use App\Osint\RunnerRegistry;
use Tests\TestCase;
class RunnerRegistryTest extends TestCase
{
public function test_opencorporates_is_hidden_without_a_configured_token(): void
{
config(['services.opencorporates.token' => 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'));
}
}