467 lines
17 KiB
PHP
467 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\EntraAdminRoles;
|
|
|
|
use App\Models\AlertRule;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Services\Findings\FindingSlaPolicy;
|
|
use Carbon\CarbonImmutable;
|
|
|
|
final class EntraAdminRolesFindingGenerator
|
|
{
|
|
private const int GA_THRESHOLD = 5; // TODO: move to settings when configurable thresholds are implemented
|
|
|
|
/** @var array<int, array<string, mixed>> */
|
|
private array $alertEvents = [];
|
|
|
|
public function __construct(
|
|
private readonly HighPrivilegeRoleCatalog $catalog,
|
|
private readonly ?FindingSlaPolicy $slaPolicy = null,
|
|
) {}
|
|
|
|
/**
|
|
* Generate/upsert/auto-resolve findings based on report payload data.
|
|
*/
|
|
public function generate(
|
|
Tenant $tenant,
|
|
array $reportPayload,
|
|
?OperationRun $operationRun = null,
|
|
): EntraAdminRolesFindingResult {
|
|
$this->alertEvents = [];
|
|
|
|
$created = 0;
|
|
$resolved = 0;
|
|
$reopened = 0;
|
|
$unchanged = 0;
|
|
|
|
$roleAssignments = is_array($reportPayload['role_assignments'] ?? null) ? $reportPayload['role_assignments'] : [];
|
|
$roleDefinitions = is_array($reportPayload['role_definitions'] ?? null) ? $reportPayload['role_definitions'] : [];
|
|
$measuredAt = (string) ($reportPayload['measured_at'] ?? CarbonImmutable::now('UTC')->toIso8601String());
|
|
$observedAt = $this->resolveObservedAt($measuredAt);
|
|
|
|
$roleDefMap = $this->buildRoleDefMap($roleDefinitions);
|
|
|
|
$currentFingerprints = [];
|
|
$gaCount = 0;
|
|
$gaPrincipals = [];
|
|
|
|
foreach ($roleAssignments as $assignment) {
|
|
$roleDefId = (string) ($assignment['roleDefinitionId'] ?? '');
|
|
$roleDef = $roleDefMap[$roleDefId] ?? null;
|
|
$templateId = (string) ($roleDef['templateId'] ?? $roleDefId);
|
|
$displayName = $roleDef !== null ? (string) ($roleDef['displayName'] ?? '') : null;
|
|
|
|
$severity = $this->catalog->classify($templateId, $displayName);
|
|
|
|
if ($severity === null) {
|
|
continue;
|
|
}
|
|
|
|
$principalId = (string) ($assignment['principalId'] ?? '');
|
|
$scopeId = (string) ($assignment['directoryScopeId'] ?? '/');
|
|
$principal = $assignment['principal'] ?? [];
|
|
|
|
$fingerprint = $this->individualFingerprint($tenant, $templateId, $principalId, $scopeId);
|
|
$currentFingerprints[] = $fingerprint;
|
|
|
|
$evidence = $this->buildEvidence($assignment, $roleDef, $principal, $severity, $measuredAt);
|
|
|
|
$result = $this->upsertFinding(
|
|
tenant: $tenant,
|
|
fingerprint: $fingerprint,
|
|
severity: $severity,
|
|
evidence: $evidence,
|
|
principalId: $principalId,
|
|
roleDefId: $roleDefId,
|
|
observedAt: $observedAt,
|
|
operationRun: $operationRun,
|
|
);
|
|
|
|
match ($result) {
|
|
'created' => $created++,
|
|
'reopened' => $reopened++,
|
|
default => $unchanged++,
|
|
};
|
|
|
|
if (in_array($result, ['created', 'reopened'], true) && in_array($severity, [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL], true)) {
|
|
$this->produceAlertEvent($tenant, $fingerprint, $evidence);
|
|
}
|
|
|
|
if ($this->catalog->isGlobalAdministrator($templateId, $displayName)) {
|
|
$gaCount++;
|
|
$gaPrincipals[] = [
|
|
'display_name' => (string) ($principal['displayName'] ?? 'Unknown'),
|
|
'type' => $this->resolvePrincipalType($principal),
|
|
'id' => $principalId,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Aggregate "Too many Global Admins" finding
|
|
$resolved += $this->handleGaAggregate($tenant, $gaCount, $gaPrincipals, $currentFingerprints, $observedAt, $operationRun, $created, $reopened);
|
|
|
|
// Auto-resolve stale findings
|
|
$resolved += $this->resolveStaleFindings($tenant, $currentFingerprints, $observedAt);
|
|
|
|
return new EntraAdminRolesFindingResult(
|
|
created: $created,
|
|
resolved: $resolved,
|
|
reopened: $reopened,
|
|
unchanged: $unchanged,
|
|
alertEventsProduced: count($this->alertEvents),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getAlertEvents(): array
|
|
{
|
|
return $this->alertEvents;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
private function buildRoleDefMap(array $roleDefinitions): array
|
|
{
|
|
$map = [];
|
|
|
|
foreach ($roleDefinitions as $def) {
|
|
$id = (string) ($def['id'] ?? '');
|
|
|
|
if ($id !== '') {
|
|
$map[$id] = $def;
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
private function upsertFinding(
|
|
Tenant $tenant,
|
|
string $fingerprint,
|
|
string $severity,
|
|
array $evidence,
|
|
string $principalId,
|
|
string $roleDefId,
|
|
CarbonImmutable $observedAt,
|
|
?OperationRun $operationRun,
|
|
): string {
|
|
$slaPolicy = $this->resolveSlaPolicy();
|
|
|
|
$existing = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
|
->where('fingerprint', $fingerprint)
|
|
->first();
|
|
|
|
if ($existing instanceof Finding) {
|
|
$this->observeFinding($existing, $observedAt);
|
|
|
|
$existing->forceFill([
|
|
'severity' => $severity,
|
|
'evidence_jsonb' => $evidence,
|
|
'current_operation_run_id' => $operationRun?->getKey(),
|
|
]);
|
|
|
|
if ($existing->status === Finding::STATUS_RESOLVED) {
|
|
$resolvedAt = $existing->resolved_at;
|
|
|
|
if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
|
|
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
|
|
|
$existing->forceFill([
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => $observedAt,
|
|
'resolved_at' => null,
|
|
'resolved_reason' => null,
|
|
'closed_at' => null,
|
|
'closed_reason' => null,
|
|
'closed_by_user_id' => null,
|
|
'sla_days' => $slaDays,
|
|
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
|
])->save();
|
|
|
|
return 'reopened';
|
|
}
|
|
}
|
|
|
|
// Update evidence on existing open finding
|
|
$existing->save();
|
|
|
|
return 'unchanged';
|
|
}
|
|
|
|
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
|
|
|
Finding::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES,
|
|
'source' => 'entra.admin_roles',
|
|
'scope_key' => hash('sha256', 'entra_admin_roles:'.$tenant->getKey()),
|
|
'fingerprint' => $fingerprint,
|
|
'subject_type' => 'role_assignment',
|
|
'subject_external_id' => "{$principalId}:{$roleDefId}",
|
|
'severity' => $severity,
|
|
'status' => Finding::STATUS_NEW,
|
|
'evidence_jsonb' => $evidence,
|
|
'current_operation_run_id' => $operationRun?->getKey(),
|
|
'first_seen_at' => $observedAt,
|
|
'last_seen_at' => $observedAt,
|
|
'times_seen' => 1,
|
|
'sla_days' => $slaDays,
|
|
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
|
]);
|
|
|
|
return 'created';
|
|
}
|
|
|
|
/**
|
|
* Handle aggregate "Too many Global Admins" finding.
|
|
*
|
|
* @return int Number of resolved findings (0 or 1)
|
|
*/
|
|
private function handleGaAggregate(
|
|
Tenant $tenant,
|
|
int $gaCount,
|
|
array $gaPrincipals,
|
|
array &$currentFingerprints,
|
|
CarbonImmutable $observedAt,
|
|
?OperationRun $operationRun,
|
|
int &$created,
|
|
int &$reopened,
|
|
): int {
|
|
$slaPolicy = $this->resolveSlaPolicy();
|
|
$gaFingerprint = $this->gaAggregateFingerprint($tenant);
|
|
$currentFingerprints[] = $gaFingerprint;
|
|
|
|
$resolved = 0;
|
|
|
|
if ($gaCount > self::GA_THRESHOLD) {
|
|
$evidence = [
|
|
'count' => $gaCount,
|
|
'threshold' => self::GA_THRESHOLD,
|
|
'principals' => $gaPrincipals,
|
|
];
|
|
|
|
$existing = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
|
->where('fingerprint', $gaFingerprint)
|
|
->first();
|
|
|
|
if ($existing instanceof Finding) {
|
|
$this->observeFinding($existing, $observedAt);
|
|
|
|
$existing->forceFill([
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'evidence_jsonb' => $evidence,
|
|
'current_operation_run_id' => $operationRun?->getKey(),
|
|
]);
|
|
|
|
if ($existing->status === Finding::STATUS_RESOLVED) {
|
|
$resolvedAt = $existing->resolved_at;
|
|
|
|
if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
|
|
$slaDays = $slaPolicy->daysForSeverity(Finding::SEVERITY_HIGH, $tenant);
|
|
|
|
$existing->forceFill([
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => $observedAt,
|
|
'resolved_at' => null,
|
|
'resolved_reason' => null,
|
|
'closed_at' => null,
|
|
'closed_reason' => null,
|
|
'closed_by_user_id' => null,
|
|
'sla_days' => $slaDays,
|
|
'due_at' => $slaPolicy->dueAtForSeverity(Finding::SEVERITY_HIGH, $tenant, $observedAt),
|
|
]);
|
|
|
|
$reopened++;
|
|
$this->produceAlertEvent($tenant, $gaFingerprint, $evidence);
|
|
}
|
|
}
|
|
|
|
$existing->save();
|
|
} else {
|
|
$slaDays = $slaPolicy->daysForSeverity(Finding::SEVERITY_HIGH, $tenant);
|
|
|
|
Finding::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES,
|
|
'source' => 'entra.admin_roles',
|
|
'scope_key' => hash('sha256', 'entra_admin_roles_ga_count:'.$tenant->getKey()),
|
|
'fingerprint' => $gaFingerprint,
|
|
'subject_type' => 'role_assignment',
|
|
'subject_external_id' => 'ga_aggregate',
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_NEW,
|
|
'evidence_jsonb' => $evidence,
|
|
'current_operation_run_id' => $operationRun?->getKey(),
|
|
'first_seen_at' => $observedAt,
|
|
'last_seen_at' => $observedAt,
|
|
'times_seen' => 1,
|
|
'sla_days' => $slaDays,
|
|
'due_at' => $slaPolicy->dueAtForSeverity(Finding::SEVERITY_HIGH, $tenant, $observedAt),
|
|
]);
|
|
$created++;
|
|
$this->produceAlertEvent($tenant, $gaFingerprint, $evidence);
|
|
}
|
|
} else {
|
|
// Auto-resolve aggregate if threshold met
|
|
$existing = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
|
->where('fingerprint', $gaFingerprint)
|
|
->whereIn('status', Finding::openStatusesForQuery())
|
|
->first();
|
|
|
|
if ($existing instanceof Finding) {
|
|
$existing->forceFill([
|
|
'status' => Finding::STATUS_RESOLVED,
|
|
'resolved_at' => $observedAt,
|
|
'resolved_reason' => 'ga_count_within_threshold',
|
|
])->save();
|
|
$resolved++;
|
|
}
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
/**
|
|
* Resolve open findings whose fingerprint is not in the current scan.
|
|
*/
|
|
private function resolveStaleFindings(Tenant $tenant, array $currentFingerprints, CarbonImmutable $observedAt): int
|
|
{
|
|
$staleFindings = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
|
->whereIn('status', Finding::openStatusesForQuery())
|
|
->whereNotIn('fingerprint', $currentFingerprints)
|
|
->get();
|
|
|
|
$resolved = 0;
|
|
|
|
foreach ($staleFindings as $finding) {
|
|
if (! $finding instanceof Finding) {
|
|
continue;
|
|
}
|
|
|
|
$finding->forceFill([
|
|
'status' => Finding::STATUS_RESOLVED,
|
|
'resolved_at' => $observedAt,
|
|
'resolved_reason' => 'role_assignment_removed',
|
|
])->save();
|
|
$resolved++;
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
private function resolveObservedAt(string $measuredAt): CarbonImmutable
|
|
{
|
|
$measuredAt = trim($measuredAt);
|
|
|
|
if ($measuredAt !== '') {
|
|
try {
|
|
return CarbonImmutable::parse($measuredAt);
|
|
} catch (\Throwable) {
|
|
// Fall through.
|
|
}
|
|
}
|
|
|
|
return CarbonImmutable::now('UTC');
|
|
}
|
|
|
|
private function resolveSlaPolicy(): FindingSlaPolicy
|
|
{
|
|
return $this->slaPolicy ?? app(FindingSlaPolicy::class);
|
|
}
|
|
|
|
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
|
|
{
|
|
if ($finding->first_seen_at === null) {
|
|
$finding->first_seen_at = $observedAt;
|
|
}
|
|
|
|
$lastSeenAt = $finding->last_seen_at;
|
|
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
|
|
|
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
|
|
$finding->last_seen_at = $observedAt;
|
|
$finding->times_seen = max(0, $timesSeen) + 1;
|
|
|
|
return;
|
|
}
|
|
|
|
if ($timesSeen < 1) {
|
|
$finding->times_seen = 1;
|
|
}
|
|
}
|
|
|
|
private function produceAlertEvent(Tenant $tenant, string $fingerprint, array $evidence): void
|
|
{
|
|
$this->alertEvents[] = [
|
|
'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'severity' => $evidence['severity'] ?? Finding::SEVERITY_HIGH,
|
|
'fingerprint_key' => 'entra_admin_role:'.$fingerprint,
|
|
'title' => 'High-privilege Entra admin role detected',
|
|
'body' => sprintf(
|
|
'Role "%s" assigned to %s on tenant %s.',
|
|
$evidence['role_display_name'] ?? 'Unknown',
|
|
$evidence['principal_display_name'] ?? 'Unknown',
|
|
$tenant->name ?? (string) $tenant->getKey(),
|
|
),
|
|
'metadata' => $evidence,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildEvidence(array $assignment, ?array $roleDef, array $principal, string $severity, string $measuredAt): array
|
|
{
|
|
return [
|
|
'role_display_name' => (string) ($roleDef['displayName'] ?? 'Unknown'),
|
|
'principal_display_name' => (string) ($principal['displayName'] ?? 'Unknown'),
|
|
'principal_type' => $this->resolvePrincipalType($principal),
|
|
'principal_id' => (string) ($assignment['principalId'] ?? ''),
|
|
'role_definition_id' => (string) ($assignment['roleDefinitionId'] ?? ''),
|
|
'role_template_id' => (string) ($roleDef['templateId'] ?? ''),
|
|
'directory_scope_id' => (string) ($assignment['directoryScopeId'] ?? '/'),
|
|
'is_built_in' => (bool) ($roleDef['isBuiltIn'] ?? false),
|
|
'severity' => $severity,
|
|
'measured_at' => $measuredAt,
|
|
];
|
|
}
|
|
|
|
private function individualFingerprint(Tenant $tenant, string $templateId, string $principalId, string $scopeId): string
|
|
{
|
|
return substr(hash('sha256', "entra_admin_role:{$tenant->getKey()}:{$templateId}:{$principalId}:{$scopeId}"), 0, 64);
|
|
}
|
|
|
|
private function gaAggregateFingerprint(Tenant $tenant): string
|
|
{
|
|
return substr(hash('sha256', "entra_admin_role_ga_count:{$tenant->getKey()}"), 0, 64);
|
|
}
|
|
|
|
private function resolvePrincipalType(array $principal): string
|
|
{
|
|
$odataType = (string) ($principal['@odata.type'] ?? '');
|
|
|
|
return match (true) {
|
|
str_contains($odataType, 'user') => 'user',
|
|
str_contains($odataType, 'group') => 'group',
|
|
str_contains($odataType, 'servicePrincipal') => 'servicePrincipal',
|
|
default => 'unknown',
|
|
};
|
|
}
|
|
}
|