Add model-level hidden fields for secrets, tokens, keys, notification credentials, deployment logs, and environment values. Allow explicit read:sensitive API access to reveal gated private keys and deployment logs, and cover the behavior with feature and unit tests.
379 lines
14 KiB
PHP
379 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
function makeApiToken(User $user, Team $team, array $abilities): string
|
|
{
|
|
session(['currentTeam' => $team]);
|
|
$token = $user->createToken('sensitive-test', $abilities);
|
|
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
return $token->plainTextToken;
|
|
}
|
|
|
|
function makeTeamUser(): array
|
|
{
|
|
$team = Team::factory()->create();
|
|
$user = User::factory()->create();
|
|
$team->members()->attach($user->id, ['role' => 'owner']);
|
|
session(['currentTeam' => $team]);
|
|
|
|
return [$team, $user];
|
|
}
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::query()->delete();
|
|
$settings = new InstanceSettings;
|
|
$settings->id = 0;
|
|
$settings->save();
|
|
|
|
[$this->team, $this->user] = makeTeamUser();
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$this->server->settings->forceFill([
|
|
'sentinel_token' => encrypt('super-secret-sentinel-token'),
|
|
'sentinel_custom_url' => 'https://sentinel.internal',
|
|
'logdrain_axiom_api_key' => encrypt('axiom-key-secret'),
|
|
'logdrain_newrelic_license_key' => encrypt('newrelic-key-secret'),
|
|
'logdrain_custom_config' => 'custom-config-data',
|
|
'logdrain_custom_config_parser' => 'custom-parser-data',
|
|
])->saveQuietly();
|
|
});
|
|
|
|
describe('GET /api/v1/servers sensitive field gating', function () {
|
|
test('read token does not leak sentinel or logdrain fields', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/servers');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->not->toContain('sentinel_token');
|
|
expect($body)->not->toContain('sentinel_custom_url');
|
|
expect($body)->not->toContain('logdrain_axiom_api_key');
|
|
expect($body)->not->toContain('logdrain_newrelic_license_key');
|
|
expect($body)->not->toContain('logdrain_custom_config');
|
|
});
|
|
|
|
test('read sensitive token sees sentinel and logdrain fields', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/servers');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->toContain('sentinel_token');
|
|
expect($body)->toContain('sentinel_custom_url');
|
|
expect($body)->toContain('logdrain_axiom_api_key');
|
|
});
|
|
|
|
test('root token sees sentinel and logdrain fields', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['root']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/servers');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->toContain('sentinel_token');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/security/keys sensitive field gating', function () {
|
|
beforeEach(function () {
|
|
PrivateKey::withoutEvents(function () {
|
|
PrivateKey::forceCreate([
|
|
'uuid' => 'private-key-sensitive-test',
|
|
'name' => 'Sensitive key',
|
|
'description' => 'test',
|
|
'private_key' => 'super-secret-private-key',
|
|
'is_git_related' => false,
|
|
'team_id' => $this->team->id,
|
|
]);
|
|
});
|
|
});
|
|
|
|
test('read token does not leak private key material', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/security/keys/private-key-sensitive-test');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
expect($response->getContent())->not->toContain('"private_key":');
|
|
});
|
|
|
|
test('read sensitive token sees private key material', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/security/keys/private-key-sensitive-test');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
expect($response->getContent())->toContain('"private_key":');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/deployments sensitive field gating', function () {
|
|
beforeEach(function () {
|
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
$destination = $this->server->standaloneDockers()->firstOrFail();
|
|
|
|
$this->application = Application::create([
|
|
'name' => 'deployment-sensitive-test-app',
|
|
'git_repository' => 'https://github.com/test/test',
|
|
'git_branch' => 'main',
|
|
'build_pack' => 'nixpacks',
|
|
'ports_exposes' => '3000',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
|
|
ApplicationDeploymentQueue::create([
|
|
'application_id' => $this->application->id,
|
|
'deployment_uuid' => 'deployment-sensitive-test',
|
|
'pull_request_id' => 0,
|
|
'status' => 'in_progress',
|
|
'logs' => '[{"output":"super-secret-deployment-log"}]',
|
|
'server_id' => $this->server->id,
|
|
'application_name' => $this->application->name,
|
|
'server_name' => $this->server->name,
|
|
]);
|
|
});
|
|
|
|
test('read token does not leak deployment logs', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/deployments/deployment-sensitive-test');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
expect($response->getContent())->not->toContain('"logs":');
|
|
});
|
|
|
|
test('read sensitive token sees deployment logs', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/deployments/deployment-sensitive-test');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
expect($response->getContent())->toContain('"logs":');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/applications nested-relation scrubbing', function () {
|
|
beforeEach(function () {
|
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
$destination = $this->server->standaloneDockers()->firstOrFail();
|
|
|
|
$this->application = Application::create([
|
|
'name' => 'sensitive-test-app',
|
|
'git_repository' => 'https://github.com/test/test',
|
|
'git_branch' => 'main',
|
|
'build_pack' => 'nixpacks',
|
|
'ports_exposes' => '3000',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
});
|
|
|
|
test('read token does not leak sentinel_token via destination.server.settings', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/applications');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->not->toContain('"sentinel_token":');
|
|
expect($body)->not->toContain('"sentinel_custom_url":');
|
|
expect($body)->not->toContain('"logdrain_axiom_api_key":');
|
|
});
|
|
|
|
test('read-sensitive token sees nested sentinel_token via destination.server.settings', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/applications');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->toContain('"sentinel_token":');
|
|
expect($body)->toContain('"sentinel_custom_url":');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/databases sensitive field gating', function () {
|
|
beforeEach(function () {
|
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
$destination = $this->server->standaloneDockers()->firstOrFail();
|
|
|
|
$this->database = StandalonePostgresql::create([
|
|
'name' => 'sensitive-db',
|
|
'description' => 'test',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => encrypt('super-secret-db-password'),
|
|
'postgres_db' => 'app',
|
|
'image' => 'postgres:16-alpine',
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
});
|
|
|
|
test('read token does not leak postgres_password or db urls', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/databases');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->not->toContain('postgres_password');
|
|
expect($body)->not->toContain('internal_db_url');
|
|
expect($body)->not->toContain('external_db_url');
|
|
});
|
|
|
|
test('read sensitive token sees postgres_password and nested sentinel_token', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/databases');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->toContain('"postgres_password":');
|
|
expect($body)->toContain('"internal_db_url":');
|
|
expect($body)->toContain('"sentinel_token":');
|
|
});
|
|
|
|
test('project database list can eager load nested destination server settings', function () {
|
|
$databases = $this->project->databases(['destination.server.settings']);
|
|
$database = $databases->firstWhere('id', $this->database->id);
|
|
|
|
expect($database)->not->toBeNull()
|
|
->and($database->relationLoaded('destination'))->toBeTrue()
|
|
->and($database->destination->relationLoaded('server'))->toBeTrue()
|
|
->and($database->destination->server->relationLoaded('settings'))->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/services sensitive field gating', function () {
|
|
beforeEach(function () {
|
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
|
|
$destination = $this->server->standaloneDockers()->firstOrFail();
|
|
$this->service = Service::factory()->create([
|
|
'server_id' => $this->server->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'environment_id' => $this->environment->id,
|
|
]);
|
|
});
|
|
|
|
test('read token does not leak service or nested server sensitive fields', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/services');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->not->toContain('"docker_compose_raw":')
|
|
->and($body)->not->toContain('"sentinel_token":')
|
|
->and($body)->not->toContain('"sentinel_custom_url":');
|
|
});
|
|
|
|
test('read sensitive token sees service and nested server sensitive fields', function () {
|
|
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/services');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
expect($body)->toContain('"docker_compose_raw":')
|
|
->and($body)->toContain('"sentinel_token":')
|
|
->and($body)->toContain('"sentinel_custom_url":');
|
|
});
|
|
|
|
test('read sensitive service list eager loads nested server settings once', function () {
|
|
$secondServer = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$secondDestination = $secondServer->standaloneDockers()->firstOrFail();
|
|
|
|
Service::factory()->create([
|
|
'server_id' => $secondServer->id,
|
|
'destination_id' => $secondDestination->id,
|
|
'destination_type' => $secondDestination->getMorphClass(),
|
|
'environment_id' => $this->environment->id,
|
|
]);
|
|
|
|
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
|
$serverSettingsQueries = collect();
|
|
|
|
DB::listen(function ($query) use ($serverSettingsQueries) {
|
|
if (str_contains($query->sql, 'from "server_settings"')) {
|
|
$serverSettingsQueries->push($query->sql);
|
|
}
|
|
});
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->getJson('/api/v1/services');
|
|
|
|
$response->assertStatus(200);
|
|
|
|
expect($serverSettingsQueries->contains(fn (string $sql) => str_contains($sql, '"server_settings"."server_id" in')))->toBeTrue();
|
|
});
|
|
});
|