## 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
148 lines
5.2 KiB
PHP
148 lines
5.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
final class PolicySnapshotRedactor
|
|
{
|
|
public function __construct(
|
|
private readonly ?SecretClassificationService $classificationService = null,
|
|
private readonly ?SecretFingerprintHasher $fingerprintHasher = null,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function redactPayload(array $payload): array
|
|
{
|
|
$redacted = $this->protectBucket('snapshot', $payload, null);
|
|
|
|
return is_array($redacted['value']) ? $redacted['value'] : $payload;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>>|null $assignments
|
|
* @return array<int, array<string, mixed>>|null
|
|
*/
|
|
public function redactAssignments(?array $assignments): ?array
|
|
{
|
|
if ($assignments === null) {
|
|
return null;
|
|
}
|
|
|
|
$redacted = $this->protectBucket('assignments', $assignments, null);
|
|
|
|
return is_array($redacted['value']) ? $redacted['value'] : $assignments;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>>|null $scopeTags
|
|
* @return array<int, array<string, mixed>>|null
|
|
*/
|
|
public function redactScopeTags(?array $scopeTags): ?array
|
|
{
|
|
if ($scopeTags === null) {
|
|
return null;
|
|
}
|
|
|
|
$redacted = $this->protectBucket('scope_tags', $scopeTags, null);
|
|
|
|
return is_array($redacted['value']) ? $redacted['value'] : $scopeTags;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @param array<int, array<string, mixed>>|null $assignments
|
|
* @param array<int, array<string, mixed>>|array<string, mixed>|null $scopeTags
|
|
*/
|
|
public function protect(int $workspaceId, array $payload, ?array $assignments = null, ?array $scopeTags = null): ProtectedSnapshotResult
|
|
{
|
|
$snapshot = $this->protectBucket('snapshot', $payload, $workspaceId);
|
|
$protectedAssignments = $assignments !== null ? $this->protectBucket('assignments', $assignments, $workspaceId) : null;
|
|
$protectedScopeTags = $scopeTags !== null ? $this->protectBucket('scope_tags', $scopeTags, $workspaceId) : null;
|
|
|
|
$secretFingerprints = ProtectedSnapshotResult::emptyFingerprints();
|
|
$secretFingerprints['snapshot'] = $snapshot['fingerprints'];
|
|
$secretFingerprints['assignments'] = $protectedAssignments['fingerprints'] ?? [];
|
|
$secretFingerprints['scope_tags'] = $protectedScopeTags['fingerprints'] ?? [];
|
|
|
|
return new ProtectedSnapshotResult(
|
|
snapshot: is_array($snapshot['value']) ? $snapshot['value'] : $payload,
|
|
assignments: $protectedAssignments !== null && is_array($protectedAssignments['value']) ? $protectedAssignments['value'] : $assignments,
|
|
scopeTags: $protectedScopeTags !== null && is_array($protectedScopeTags['value']) ? $protectedScopeTags['value'] : $scopeTags,
|
|
secretFingerprints: $secretFingerprints,
|
|
redactionVersion: SecretClassificationService::REDACTION_VERSION,
|
|
protectedPathsCount: count($secretFingerprints['snapshot']) + count($secretFingerprints['assignments']) + count($secretFingerprints['scope_tags']),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $segments
|
|
* @return array{value: mixed, fingerprints: array<string, string>}
|
|
*/
|
|
private function protectBucket(string $sourceBucket, mixed $value, ?int $workspaceId, array $segments = []): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [
|
|
'value' => $value,
|
|
'fingerprints' => [],
|
|
];
|
|
}
|
|
|
|
$redacted = [];
|
|
$fingerprints = [];
|
|
|
|
foreach ($value as $key => $item) {
|
|
$nextSegments = [...$segments, (string) $key];
|
|
$jsonPointer = $this->jsonPointer($nextSegments);
|
|
|
|
if (is_string($key) && $this->classifier()->protectsField($sourceBucket, $key, $jsonPointer)) {
|
|
$redacted[$key] = SecretClassificationService::REDACTED;
|
|
|
|
if ($workspaceId !== null) {
|
|
$fingerprints[$jsonPointer] = $this->hasher()->fingerprint($workspaceId, $sourceBucket, $jsonPointer, $item);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$protected = $this->protectBucket($sourceBucket, $item, $workspaceId, $nextSegments);
|
|
|
|
$redacted[$key] = $protected['value'];
|
|
$fingerprints = [...$fingerprints, ...$protected['fingerprints']];
|
|
}
|
|
|
|
return [
|
|
'value' => $redacted,
|
|
'fingerprints' => $fingerprints,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $segments
|
|
*/
|
|
private function jsonPointer(array $segments): string
|
|
{
|
|
if ($segments === []) {
|
|
return '/';
|
|
}
|
|
|
|
return '/'.implode('/', array_map(
|
|
static fn (string $segment): string => str_replace(['~', '/'], ['~0', '~1'], $segment),
|
|
$segments,
|
|
));
|
|
}
|
|
|
|
private function classifier(): SecretClassificationService
|
|
{
|
|
return $this->classificationService ?? app(SecretClassificationService::class);
|
|
}
|
|
|
|
private function hasher(): SecretFingerprintHasher
|
|
{
|
|
return $this->fingerprintHasher ?? app(SecretFingerprintHasher::class);
|
|
}
|
|
}
|