TenantAtlas/apps/platform/app/Support/Findings/FindingOutcomeSemantics.php
Ahmed Darrazi 807578dd9c
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m16s
feat: implement finding outcome taxonomy
2026-04-23 09:24:59 +02:00

204 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Findings;
use App\Models\Finding;
final class FindingOutcomeSemantics
{
public const string VERIFICATION_PENDING = 'pending_verification';
public const string VERIFICATION_VERIFIED = 'verified_cleared';
public const string VERIFICATION_NOT_APPLICABLE = 'not_applicable';
public const string OUTCOME_RESOLVED_PENDING_VERIFICATION = 'resolved_pending_verification';
public const string OUTCOME_VERIFIED_CLEARED = 'verified_cleared';
public const string OUTCOME_CLOSED_FALSE_POSITIVE = 'closed_false_positive';
public const string OUTCOME_CLOSED_DUPLICATE = 'closed_duplicate';
public const string OUTCOME_CLOSED_NO_LONGER_APPLICABLE = 'closed_no_longer_applicable';
public const string OUTCOME_RISK_ACCEPTED = 'risk_accepted';
/**
* @return array{
* terminal_outcome_key: ?string,
* label: ?string,
* verification_state: string,
* verification_label: ?string,
* report_bucket: ?string
* }
*/
public function describe(Finding $finding): array
{
$terminalOutcomeKey = $this->terminalOutcomeKey($finding);
$verificationState = $this->verificationState($finding);
return [
'terminal_outcome_key' => $terminalOutcomeKey,
'label' => $terminalOutcomeKey !== null ? $this->terminalOutcomeLabel($terminalOutcomeKey) : null,
'verification_state' => $verificationState,
'verification_label' => $verificationState !== self::VERIFICATION_NOT_APPLICABLE
? $this->verificationStateLabel($verificationState)
: null,
'report_bucket' => $terminalOutcomeKey !== null ? $this->reportBucket($terminalOutcomeKey) : null,
];
}
public function terminalOutcomeKey(Finding $finding): ?string
{
return match ((string) $finding->status) {
Finding::STATUS_RESOLVED => $this->resolvedTerminalOutcomeKey((string) ($finding->resolved_reason ?? '')),
Finding::STATUS_CLOSED => $this->closedTerminalOutcomeKey((string) ($finding->closed_reason ?? '')),
Finding::STATUS_RISK_ACCEPTED => self::OUTCOME_RISK_ACCEPTED,
default => null,
};
}
public function verificationState(Finding $finding): string
{
if ((string) $finding->status !== Finding::STATUS_RESOLVED) {
return self::VERIFICATION_NOT_APPLICABLE;
}
$reason = (string) ($finding->resolved_reason ?? '');
if (Finding::isSystemResolveReason($reason)) {
return self::VERIFICATION_VERIFIED;
}
if (Finding::isManualResolveReason($reason)) {
return self::VERIFICATION_PENDING;
}
return self::VERIFICATION_NOT_APPLICABLE;
}
public function systemReopenReasonFor(Finding $finding): string
{
return $this->verificationState($finding) === self::VERIFICATION_PENDING
? Finding::REOPEN_REASON_VERIFICATION_FAILED
: Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION;
}
/**
* @return array<string, string>
*/
public function terminalOutcomeOptions(): array
{
return [
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => $this->terminalOutcomeLabel(self::OUTCOME_RESOLVED_PENDING_VERIFICATION),
self::OUTCOME_VERIFIED_CLEARED => $this->terminalOutcomeLabel(self::OUTCOME_VERIFIED_CLEARED),
self::OUTCOME_CLOSED_FALSE_POSITIVE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_FALSE_POSITIVE),
self::OUTCOME_CLOSED_DUPLICATE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_DUPLICATE),
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE),
self::OUTCOME_RISK_ACCEPTED => $this->terminalOutcomeLabel(self::OUTCOME_RISK_ACCEPTED),
];
}
/**
* @return array<string, string>
*/
public function verificationStateOptions(): array
{
return [
self::VERIFICATION_PENDING => $this->verificationStateLabel(self::VERIFICATION_PENDING),
self::VERIFICATION_VERIFIED => $this->verificationStateLabel(self::VERIFICATION_VERIFIED),
self::VERIFICATION_NOT_APPLICABLE => $this->verificationStateLabel(self::VERIFICATION_NOT_APPLICABLE),
];
}
public function terminalOutcomeLabel(string $terminalOutcomeKey): string
{
return match ($terminalOutcomeKey) {
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification',
self::OUTCOME_VERIFIED_CLEARED => 'Verified cleared',
self::OUTCOME_CLOSED_FALSE_POSITIVE => 'Closed as false positive',
self::OUTCOME_CLOSED_DUPLICATE => 'Closed as duplicate',
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'Closed as no longer applicable',
self::OUTCOME_RISK_ACCEPTED => 'Risk accepted',
default => 'Unknown outcome',
};
}
public function verificationStateLabel(string $verificationState): string
{
return match ($verificationState) {
self::VERIFICATION_PENDING => 'Pending verification',
self::VERIFICATION_VERIFIED => 'Verified cleared',
default => 'Not applicable',
};
}
public function reportBucket(string $terminalOutcomeKey): string
{
return match ($terminalOutcomeKey) {
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'remediation_pending_verification',
self::OUTCOME_VERIFIED_CLEARED => 'remediation_verified',
self::OUTCOME_RISK_ACCEPTED => 'accepted_risk',
default => 'administrative_closure',
};
}
public function compactOutcomeSummary(array $counts): ?string
{
$parts = [];
foreach ($this->orderedOutcomeKeys() as $outcomeKey) {
$count = (int) ($counts[$outcomeKey] ?? 0);
if ($count < 1) {
continue;
}
$parts[] = sprintf('%d %s', $count, strtolower($this->terminalOutcomeLabel($outcomeKey)));
}
return $parts === [] ? null : implode(', ', $parts);
}
/**
* @return array<int, string>
*/
public function orderedOutcomeKeys(): array
{
return [
self::OUTCOME_RESOLVED_PENDING_VERIFICATION,
self::OUTCOME_VERIFIED_CLEARED,
self::OUTCOME_CLOSED_FALSE_POSITIVE,
self::OUTCOME_CLOSED_DUPLICATE,
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
self::OUTCOME_RISK_ACCEPTED,
];
}
private function resolvedTerminalOutcomeKey(string $reason): ?string
{
if (Finding::isSystemResolveReason($reason)) {
return self::OUTCOME_VERIFIED_CLEARED;
}
if (Finding::isManualResolveReason($reason)) {
return self::OUTCOME_RESOLVED_PENDING_VERIFICATION;
}
return null;
}
private function closedTerminalOutcomeKey(string $reason): ?string
{
return match ($reason) {
Finding::CLOSE_REASON_FALSE_POSITIVE => self::OUTCOME_CLOSED_FALSE_POSITIVE,
Finding::CLOSE_REASON_DUPLICATE => self::OUTCOME_CLOSED_DUPLICATE,
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
default => null,
};
}
}