TenantAtlas/app/Services/Intune/SecretClassificationService.php
ahmido cd811cff4f Spec 120: harden secret redaction integrity (#146)
## Summary
- replace broad substring-based masking with a shared exact/path-based secret classifier and workspace-scoped fingerprint hashing
- persist protected snapshot metadata on `policy_versions` and keep secret-only changes visible in compare, drift, restore, review, verification, and ops surfaces
- add Spec 120 artifacts, audit documentation, and focused Pest regression coverage for snapshot, audit, verification, review-pack, and notification behavior

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Intune/PolicySnapshotRedactionTest.php tests/Feature/Intune/PolicySnapshotFingerprintIsolationTest.php tests/Feature/ReviewPack/ReviewPackRedactionIntegrityTest.php tests/Feature/OpsUx/OperationRunNotificationRedactionTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Spec / checklist status
| Checklist | Total | Completed | Incomplete | Status |
|-----------|-------|-----------|------------|--------|
| requirements.md | 16 | 16 | 0 | ✓ PASS |

- `tasks.md`: T001-T032 complete
- `tasks.md`: T033 manual quickstart validation is still open and noted for follow-up

## Filament / platform notes
- Livewire v4 compliance is unchanged
- no panel provider changes; `bootstrap/providers.php` remains the registration location
- no new globally searchable resources were introduced, so global search requirements are unchanged
- no new destructive Filament actions were added
- no new Filament assets were added; no `filament:assets` deployment change is required

## Testing coverage touched
- snapshot persistence and fingerprint isolation
- compare/drift protected-change evidence
- audit, verification, review-pack, ops-failure, and notification sanitization
- viewer/read-only Filament presentation updates

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #146
2026-03-07 16:43:01 +00:00

118 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Intune;
use Illuminate\Support\Str;
final class SecretClassificationService
{
public const string REDACTED = '[REDACTED]';
public const int REDACTION_VERSION = 1;
/**
* @var array<int, string>
*/
private const array PROTECTED_FIELD_NAMES = [
'access_token',
'apikey',
'api_key',
'authorization',
'bearer',
'bearer_token',
'client_secret',
'cookie',
'password',
'presharedkey',
'pre_shared_key',
'private_key',
'refresh_token',
'sas_token',
'secret',
'set-cookie',
'shared_secret',
'token',
];
/**
* @var array<string, array<int, string>>
*/
private const array PROTECTED_JSON_POINTERS = [
'snapshot' => [
'/wifi/password',
'/authentication/clientSecret',
],
'assignments' => [],
'scope_tags' => [],
'audit' => [],
'verification' => [],
'ops_failure' => [],
];
public function protectsField(string $sourceBucket, string $fieldName, ?string $jsonPointer = null): bool
{
$fieldName = $this->normalizeFieldName($fieldName);
if ($fieldName === '') {
return false;
}
$protectedPointers = self::PROTECTED_JSON_POINTERS[$sourceBucket] ?? [];
if (is_string($jsonPointer) && in_array($jsonPointer, $protectedPointers, true)) {
return true;
}
return in_array($fieldName, self::PROTECTED_FIELD_NAMES, true);
}
public function sanitizeAuditString(string $value): string
{
return $this->sanitizeMessageLikeString($value, '[REDACTED]');
}
public function sanitizeOpsFailureString(string $value): string
{
$sanitized = $this->sanitizeMessageLikeString($value, '[REDACTED_SECRET]');
$sanitized = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $sanitized) ?? $sanitized;
return $sanitized;
}
private function sanitizeMessageLikeString(string $value, string $replacement): string
{
$patterns = [
'/\bAuthorization\s*:\s*Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/i',
'/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*/i',
'/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/',
];
foreach ($patterns as $pattern) {
$value = preg_replace($pattern, $replacement, $value) ?? $value;
}
foreach (self::PROTECTED_FIELD_NAMES as $fieldName) {
$quotedPattern = sprintf('/"%s"\s*:\s*"[^"]*"/i', preg_quote($fieldName, '/'));
$pairPattern = sprintf('/\b%s\b\s*[:=]\s*[^\s,;]+/i', preg_quote($fieldName, '/'));
$value = preg_replace($quotedPattern, sprintf('"%s":"%s"', $fieldName, $replacement), $value) ?? $value;
$value = preg_replace($pairPattern, sprintf('%s=%s', $fieldName, $replacement), $value) ?? $value;
}
return $value;
}
private function normalizeFieldName(string $fieldName): string
{
$fieldName = Str::of($fieldName)
->replace(['-', ' '], '_')
->snake()
->lower()
->toString();
return trim($fieldName);
}
}