mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 15:39:59 +00:00
Add audit events for account actions, server and application settings, services, destinations, storage, backup schedules, and scheduled tasks. Track changed fields and skip unchanged saves without logging file content. Add regression coverage for audit events and changed-field detection.
536 lines
20 KiB
PHP
536 lines
20 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Security\CloudInitScriptForm;
|
|
use App\Livewire\Security\CloudInitScripts;
|
|
use App\Livewire\Security\CloudProviderTokenForm;
|
|
use App\Livewire\Security\CloudProviderTokens;
|
|
use App\Models\Application;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\CloudInitScript;
|
|
use App\Models\CloudProviderToken;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Once;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
function makeAuditTeamUser(): array
|
|
{
|
|
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
|
$settings = new InstanceSettings;
|
|
$settings->id = 0;
|
|
$settings->save();
|
|
}
|
|
Once::flush();
|
|
|
|
$team = Team::factory()->create();
|
|
$user = User::factory()->create();
|
|
$team->members()->attach($user->id, ['role' => 'owner']);
|
|
session(['currentTeam' => $team]);
|
|
test()->actingAs($user);
|
|
|
|
return [$team, $user];
|
|
}
|
|
|
|
function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string
|
|
{
|
|
$token = $user->createToken('audit-test', $abilities);
|
|
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
return $token->plainTextToken;
|
|
}
|
|
|
|
function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
|
|
{
|
|
$team = Team::factory()->create();
|
|
$project = Project::factory()->create(['team_id' => $team->id]);
|
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
$server = Server::factory()->create(['team_id' => $team->id]);
|
|
$destination = $server->standaloneDockers()->firstOrFail();
|
|
|
|
return Application::create([
|
|
'name' => 'audit-test-app',
|
|
'git_repository' => "https://github.com/{$repo}",
|
|
'git_branch' => 'main',
|
|
'build_pack' => 'nixpacks',
|
|
'ports_exposes' => '3000',
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
}
|
|
|
|
describe('audit channel helper', function () {
|
|
test('auditLog writes structured payload to audit channel', function () {
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('info')
|
|
->once()
|
|
->with('test.event', Mockery::on(function ($context) {
|
|
return $context['event'] === 'test.event'
|
|
&& $context['custom_field'] === 'value'
|
|
&& array_key_exists('ip', $context)
|
|
&& array_key_exists('user_id', $context);
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
|
|
auditLog('test.event', ['custom_field' => 'value']);
|
|
});
|
|
|
|
test('auditLog warning level routes correctly', function () {
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any());
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
|
|
auditLog('test.failed', [], 'warning');
|
|
});
|
|
|
|
test('auditLogWebhookFailure logs warning with provider tag', function () {
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->once()
|
|
->with('webhook.github.signature_failed', Mockery::on(function ($context) {
|
|
return $context['reason'] === 'invalid_signature'
|
|
&& $context['event'] === 'webhook.github.signature_failed'
|
|
&& array_key_exists('ip', $context);
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
|
|
auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']);
|
|
});
|
|
|
|
test('auditLog never includes raw secret keys in context', function () {
|
|
$captured = null;
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('info')
|
|
->once()
|
|
->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) {
|
|
$captured = $context;
|
|
|
|
return true;
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
|
|
auditLog('test.private_key.created', [
|
|
'team_id' => '1',
|
|
'private_key_uuid' => 'abc',
|
|
'fingerprint' => 'SHA256:xyz',
|
|
]);
|
|
|
|
expect($captured)->toBeArray();
|
|
// Helper itself never injects secret-bearing keys.
|
|
$disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret'];
|
|
foreach (array_keys($captured) as $key) {
|
|
expect(in_array(strtolower($key), $disallowed, true))->toBeFalse();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('security UI audit logging', function () {
|
|
test('creating a cloud provider token from Livewire writes an audit entry', function () {
|
|
[$team] = makeAuditTeamUser();
|
|
|
|
Http::fake([
|
|
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
|
|
]);
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('info')
|
|
->once()
|
|
->with('ui.cloud_token.created', Mockery::on(function (array $context) use ($team) {
|
|
return $context['team_id'] === $team->id
|
|
&& $context['provider'] === 'hetzner'
|
|
&& $context['cloud_token_name'] === 'UI Token';
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
|
|
Livewire::test(CloudProviderTokenForm::class)
|
|
->set('provider', 'hetzner')
|
|
->set('token', 'secret-token')
|
|
->set('name', 'UI Token')
|
|
->call('addToken');
|
|
});
|
|
|
|
test('deleting a cloud provider token from Livewire writes an audit entry', function () {
|
|
[$team] = makeAuditTeamUser();
|
|
$token = CloudProviderToken::factory()->create([
|
|
'team_id' => $team->id,
|
|
'provider' => 'hetzner',
|
|
'name' => 'Delete Me',
|
|
]);
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('info')
|
|
->once()
|
|
->with('ui.cloud_token.deleted', Mockery::on(function (array $context) use ($token) {
|
|
return $context['cloud_token_uuid'] === $token->uuid
|
|
&& $context['cloud_token_name'] === 'Delete Me';
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
|
|
Livewire::test(CloudProviderTokens::class)
|
|
->call('deleteToken', $token->id);
|
|
});
|
|
|
|
test('creating and deleting cloud-init scripts from Livewire write audit entries', function () {
|
|
[$team] = makeAuditTeamUser();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('info')
|
|
->once()
|
|
->with('ui.cloud_init_script.created', Mockery::on(function (array $context) use ($team) {
|
|
return $context['team_id'] === $team->id
|
|
&& $context['cloud_init_script_name'] === 'Bootstrap';
|
|
}));
|
|
$auditChannel->shouldReceive('info')
|
|
->once()
|
|
->with('ui.cloud_init_script.deleted', Mockery::on(function (array $context) {
|
|
return $context['cloud_init_script_name'] === 'Bootstrap';
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
|
|
Livewire::test(CloudInitScriptForm::class)
|
|
->set('name', 'Bootstrap')
|
|
->set('script', "#cloud-config\npackages: []")
|
|
->call('save');
|
|
|
|
$script = CloudInitScript::where('team_id', $team->id)->firstOrFail();
|
|
|
|
Livewire::test(CloudInitScripts::class)
|
|
->call('deleteScript', $script->id);
|
|
});
|
|
});
|
|
|
|
describe('webhook signature failure logging', function () {
|
|
test('GitHub manual webhook with bad signature logs to audit channel', function () {
|
|
$app = makeAuditApplication();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('webhook.github.signature_failed', Mockery::any());
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$payload = json_encode([
|
|
'ref' => 'refs/heads/main',
|
|
'repository' => ['full_name' => 'test-org/test-repo'],
|
|
'after' => 'abc1234',
|
|
'commits' => [],
|
|
]);
|
|
|
|
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
|
'HTTP_X-GitHub-Event' => 'push',
|
|
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], $payload);
|
|
|
|
$response->assertOk();
|
|
expect($response->getContent())->toContain('Invalid signature');
|
|
});
|
|
|
|
test('GitLab manual webhook with bad token logs to audit channel', function () {
|
|
$app = makeAuditApplication();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('webhook.gitlab.signature_failed', Mockery::any());
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
|
'object_kind' => 'push',
|
|
'ref' => 'refs/heads/main',
|
|
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
|
'after' => 'abc1234',
|
|
'commits' => [],
|
|
], [
|
|
'X-Gitlab-Token' => 'wrong-token',
|
|
]);
|
|
|
|
$response->assertOk();
|
|
expect($response->getContent())->toContain('Invalid signature');
|
|
});
|
|
|
|
test('Bitbucket manual webhook with malformed signature logs to audit channel', function () {
|
|
$app = makeAuditApplication();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('webhook.bitbucket.signature_failed', Mockery::any());
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$payload = json_encode([
|
|
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
|
|
'repository' => ['full_name' => 'test-org/test-repo'],
|
|
]);
|
|
|
|
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
|
'HTTP_X-Event-Key' => 'repo:push',
|
|
'HTTP_X-Hub-Signature' => 'sha1=anyvalue',
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], $payload);
|
|
|
|
$response->assertOk();
|
|
expect($response->getContent())->toContain('Invalid signature');
|
|
});
|
|
|
|
test('Gitea manual webhook with bad signature logs to audit channel', function () {
|
|
$app = makeAuditApplication();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('webhook.gitea.signature_failed', Mockery::any());
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$payload = json_encode([
|
|
'ref' => 'refs/heads/main',
|
|
'repository' => ['full_name' => 'test-org/test-repo'],
|
|
'after' => 'abc1234',
|
|
'commits' => [],
|
|
]);
|
|
|
|
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
|
'HTTP_X-Gitea-Event' => 'push',
|
|
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
|
'CONTENT_TYPE' => 'application/json',
|
|
], $payload);
|
|
|
|
$response->assertOk();
|
|
expect($response->getContent())->toContain('Invalid signature');
|
|
});
|
|
});
|
|
|
|
describe('API mutation audit logging', function () {
|
|
test('private key creation emits api.private_key.created audit event', function () {
|
|
[$team, $user] = makeAuditTeamUser();
|
|
$token = makeAuditApiToken($user, $team);
|
|
auth()->forgetGuards();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('info')
|
|
->zeroOrMoreTimes()
|
|
->with('api.private_key.created', Mockery::on(function ($context) {
|
|
return $context['event'] === 'api.private_key.created'
|
|
&& ! array_key_exists('private_key', $context);
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
// Generate a valid OpenSSH-format private key for the test.
|
|
$opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
|
|
base64_encode(str_repeat('a', 256)).
|
|
"\n-----END OPENSSH PRIVATE KEY-----";
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Content-Type' => 'application/json',
|
|
])->postJson('/api/v1/security/keys', [
|
|
'name' => 'test-key',
|
|
'description' => 'audit test',
|
|
'private_key' => $opensshKey,
|
|
]);
|
|
|
|
// Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201.
|
|
expect($response->status())->toBeIn([201, 422]);
|
|
});
|
|
|
|
test('enable_api denial for non-root team emits warning audit event', function () {
|
|
[$team, $user] = makeAuditTeamUser();
|
|
$token = makeAuditApiToken($user, $team);
|
|
auth()->forgetGuards();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('api.instance.enable_denied', Mockery::any());
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
])->postJson('/api/v1/enable');
|
|
|
|
$response->assertStatus(403);
|
|
});
|
|
|
|
test('project creation records api.project.created audit event', function () {
|
|
[$team, $user] = makeAuditTeamUser();
|
|
$token = makeAuditApiToken($user, $team);
|
|
auth()->forgetGuards();
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Content-Type' => 'application/json',
|
|
])->postJson('/api/v1/projects', [
|
|
'name' => 'audit-project',
|
|
'description' => 'audit',
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
|
|
$event = AuditEvent::query()->where('event', 'api.project.created')->sole();
|
|
expect($event->team_id)->toBe($team->id)
|
|
->and($event->resource_uuid)->toBe($response->json('uuid'))
|
|
->and($event->resource_name)->toBe('audit-project');
|
|
});
|
|
});
|
|
|
|
describe('threat-detection audit logging (Phase 2)', function () {
|
|
test('missing bearer token logs api.auth.unauthenticated', function () {
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('api.auth.unauthenticated', Mockery::any());
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$response = $this->getJson('/api/v1/projects');
|
|
|
|
$response->assertStatus(401);
|
|
});
|
|
|
|
test('expired bearer token logs api.auth.unauthenticated', function () {
|
|
[$team, $user] = makeAuditTeamUser();
|
|
$token = $user->createToken('expired-audit', ['read'], now()->subDay());
|
|
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
|
'team_id' => $team->id,
|
|
]);
|
|
auth()->forgetGuards();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('api.auth.unauthenticated', Mockery::any());
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token->plainTextToken,
|
|
])->getJson('/api/v1/projects');
|
|
|
|
$response->assertStatus(401);
|
|
});
|
|
|
|
test('read-only token hitting write endpoint logs api.auth.ability_denied', function () {
|
|
[$team, $user] = makeAuditTeamUser();
|
|
$readToken = makeAuditApiToken($user, $team, ['read']);
|
|
auth()->forgetGuards();
|
|
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('api.auth.ability_denied', Mockery::on(function ($ctx) {
|
|
return in_array('write', $ctx['required_abilities'] ?? [], true);
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$readToken,
|
|
'Content-Type' => 'application/json',
|
|
])->postJson('/api/v1/projects', [
|
|
'name' => 'should-fail',
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
});
|
|
|
|
test('sentinel push without Authorization logs token_missing', function () {
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
|
|
return $ctx['reason'] === 'token_missing';
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$response = $this->postJson('/api/v1/sentinel/push', []);
|
|
|
|
$response->assertStatus(401);
|
|
});
|
|
|
|
test('sentinel push with un-decryptable bearer logs decrypt_failed', function () {
|
|
$auditChannel = Mockery::mock();
|
|
$auditChannel->shouldReceive('warning')
|
|
->atLeast()
|
|
->once()
|
|
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
|
|
return $ctx['reason'] === 'decrypt_failed';
|
|
}));
|
|
|
|
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
|
Log::shouldReceive('warning')->andReturnNull();
|
|
Log::shouldReceive('info')->andReturnNull();
|
|
Log::shouldReceive('error')->andReturnNull();
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer not-a-valid-encrypted-payload',
|
|
])->postJson('/api/v1/sentinel/push', []);
|
|
|
|
$response->assertStatus(401);
|
|
});
|
|
});
|