TenantAtlas/docs/security/REDACTION_AUDIT_REPORT.md
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

543 lines
23 KiB
Markdown

# Redaction / Masking / Sanitizing — Codebase Audit Report
**Auditor:** Security + Data-Integrity Codebase Auditor
**Date:** 2026-03-06
**Scope:** Entire TenantAtlas repo (excluding `vendor/`, `node_modules/`, compiled views)
**Severity Classification:** SEV-1 = data loss / secret exposure in production path; SEV-2 = data quality degradation; SEV-3 = cosmetic / defense-in-depth
---
## Executive Summary
### The Bug
**`PolicySnapshotRedactor`** — the central class that masks secrets before persisting `PolicyVersion` snapshots to the database — uses **substring-matching regex patterns** (`/password/i`, `/secret/i`, `/token/i`, `/certificate/i`) against JSON keys. This causes **false-positive redaction** of Intune configuration keys such as:
| Key | Actual Meaning | Wrongly Redacted? |
|-----|---------------|-------------------|
| `passwordMinimumLength` | Config: minimum password length (integer) | **YES** — matches `/password/i` |
| `passwordRequired` | Config: boolean toggle | **YES** — matches `/password/i` |
| `deviceCompliancePasswordRequired` | Config: boolean toggle | **YES** — matches `/password/i` |
| `localAdminPasswordRotationEnabled` | Config: boolean toggle | **YES** — matches `/password/i` |
| `passwordExpirationDays` | Config: integer | **YES** — matches `/password/i` |
| `passwordMinimumCharacterSetCount` | Config: integer | **YES** — matches `/password/i` |
| `passwordBlockSimple` | Config: boolean | **YES** — matches `/password/i` |
| `passwordPreviousPasswordBlockCount` | Config: integer | **YES** — matches `/password/i` |
| `certificateValidityPeriodScale` | Config: enum | **YES** — matches `/certificate/i` |
| `certificateRenewalThresholdPercentage` | Config: integer | **YES** — matches `/certificate/i` |
| `tokenType` | Config: enum | **YES** — matches `/token/i` |
### Impact
1. **SEV-1: Persistent data loss** — Redaction happens **before** storing `PolicyVersion.snapshot` (JSONB). The original values are **irrecoverably destroyed**. This breaks:
- Drift detection (diffs show `[REDACTED]` vs `[REDACTED]` — always "no change")
- Baseline compare (config values masked, comparison meaningless)
- Restore flows (restored policies could lose critical settings)
- Evidence packs / review pack exports
2. **SEV-2: False drift suppression** — Two snapshots with different `passwordMinimumLength` values (e.g., 8 vs 12) will both store `[REDACTED]`, producing identical hashes → drift goes undetected.
3. **Real secrets remain correctly redacted** — The existing patterns DO catch actual secrets (`password`, `wifi_password`, `clientSecret`, etc.) — the problem is OVER-matching config keys that *contain* the word "password".
### Affected Layers
- **3 distinct redaction/sanitization systems** with the same bug pattern
- **2 persistent storage paths** (PolicyVersion, AuditLog)
- **1 non-persistent rendering path** (Verification reports)
- **~8 additional downstream consumers** of already-redacted data
---
## Findings — Complete Redaction Map
### Finding F-1: `PolicySnapshotRedactor` (CRITICAL — SEV-1)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Services/Intune/PolicySnapshotRedactor.php` |
| **Lines** | 14-23 (pattern definitions), 64-70 (`isSensitiveKey`), 72-93 (`redactValue`) |
| **Layer** | Storage (persistent JSONB) |
| **What's redacted** | Any key whose name substring-matches 8 regex patterns |
| **Entry points** | `PolicyCaptureOrchestrator::capture()` (L148-150), `VersionService::captureVersion()` (L47-49) |
| **Persisted** | **YES** — redacted data stored as `PolicyVersion.snapshot`, `.assignments`, `.scope_tags` |
| **Reversible** | **NO** — original values lost permanently |
**Rules (exact patterns):**
```php
private const array SENSITIVE_KEY_PATTERNS = [
'/password/i', // ← FALSE POSITIVES: passwordMinimumLength, passwordRequired, etc.
'/secret/i', // ← FALSE POSITIVES: possible but less common in Intune
'/token/i', // ← FALSE POSITIVES: tokenType, tokenAccountType, etc.
'/client[_-]?secret/i', // ← OK (specific enough)
'/private[_-]?key/i', // ← OK (specific enough)
'/shared[_-]?secret/i', // ← OK (specific enough)
'/preshared/i', // ← OK (specific enough)
'/certificate/i', // ← FALSE POSITIVES: certificateValidityPeriodScale, etc.
];
```
**False positive examples:**
- `passwordMinimumLength: 12` → becomes `passwordMinimumLength: "[REDACTED]"`
- `passwordRequired: true` → becomes `passwordRequired: "[REDACTED]"`
- `certificateValidityPeriodScale: "years"` → becomes `certificateValidityPeriodScale: "[REDACTED]"`
- `tokenType: "user"` → becomes `tokenType: "[REDACTED]"`
**Risk Score:** 🔴 **CRITICAL** — Permanent data loss in production path. Destroys drift detection, baseline compare, and restore fidelity.
**Double-redaction issue:** `VersionService::captureVersion()` (L47-49) applies the redactor, AND `PolicyCaptureOrchestrator::capture()` (L148-150) also applies it before passing to `captureVersion()`. When called through the orchestrator, data is redacted twice (harmless but indicates confusion about ownership).
---
### Finding F-2: `AuditContextSanitizer` (HIGH — SEV-2)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Support/Audit/AuditContextSanitizer.php` |
| **Lines** | 36-44 (`shouldRedactKey`) |
| **Layer** | Audit logging (persistent) |
| **What's redacted** | Keys containing substrings: `token`, `secret`, `password`, `authorization`, `private_key`, `client_secret` |
| **Entry points** | `WorkspaceAuditLogger::log()` , `Intune\AuditLogger::log()` |
| **Persisted** | **YES** — stored in `AuditLog.metadata` (JSONB) |
**Rules (exact logic):**
```php
return str_contains($key, 'token')
|| str_contains($key, 'secret')
|| str_contains($key, 'password')
|| str_contains($key, 'authorization')
|| str_contains($key, 'private_key')
|| str_contains($key, 'client_secret');
```
**False positive examples:** Same as F-1 — any audit metadata key containing `password` or `token` as a substring is redacted. If audit context includes policy configuration metadata (e.g., `passwordMinimumLength`), it will be masked.
**Risk Score:** 🟠 **HIGH** — Audit log data corruption. Less severe than F-1 because audit logs typically contain operational metadata, not raw policy payloads. However, if policy config keys appear in audit context (e.g., during drift detection logging), they will be silently destroyed.
---
### Finding F-3: `VerificationReportSanitizer` (MEDIUM — SEV-3)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Support/Verification/VerificationReportSanitizer.php` |
| **Lines** | 10-18 (`FORBIDDEN_KEY_SUBSTRINGS`), 131, 276, 357, 415, 443-460 |
| **Layer** | UI rendering (non-persistent) |
| **What's redacted** | Values/keys containing: `access_token`, `refresh_token`, `client_secret`, `authorization`, `password`, `cookie`, `set-cookie` |
| **Entry points** | `VerificationReportViewer` (Filament UI), `OperationRunResource` |
| **Persisted** | **NO** — sanitization at rendering time only |
**Rules:**
```php
private const FORBIDDEN_KEY_SUBSTRINGS = [
'access_token', 'refresh_token', 'client_secret',
'authorization', 'password', 'cookie', 'set-cookie',
];
// Used via str_contains() on key names AND value strings
```
**False positive examples:** If a verification report evidence value contains the substring "password" (e.g., a check message like "passwordMinimumLength must be ≥ 8"), the entire value would be nulled out. The `containsForbiddenKeySubstring` is also applied to **values**, not just keys (L357, L415), which could null out diagnostic strings.
**Risk Score:** 🟡 **MEDIUM** — No data loss (rendering only), but could hide useful diagnostic information from operators.
---
### Finding F-4: `RunFailureSanitizer::sanitizeMessage()` (LOW — SEV-3)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Support/OpsUx/RunFailureSanitizer.php` |
| **Lines** | 119-140 |
| **Layer** | Error message sanitization (persistent in OperationRun context) |
| **What's redacted** | Bearer tokens, JWT-like strings, specific key=value patterns (`access_token`, `refresh_token`, `client_secret`, `password`) |
| **Entry points** | Various jobs, `OperationRunService` |
| **Persisted** | **YES** — stored in OperationRun error context |
**Rules:** Uses exact key names (`access_token`, `refresh_token`, `client_secret`, `password`) in regex patterns matching `key=value` or `"key":"value"` format. Also does a final `str_ireplace` of these exact substrings.
**False positive risk:** LOW. The regex targets `key=value` patterns specifically. However, the blanket `str_ireplace` on line 137 will replace the substring `password` in ANY position — a message like "Check passwordMinimumLength policy" becomes "Check [REDACTED]MinimumLength policy". But these are error messages, not structured data.
**Risk Score:** 🟢 **LOW** — Messages may be garbled but no structured data loss.
---
### Finding F-5: `GenerateReviewPackJob::redactArrayPii()` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Jobs/GenerateReviewPackJob.php` |
| **Lines** | 340-352 |
| **Layer** | Export (ZIP download) |
| **What's redacted** | PII fields: `displayName`, `display_name`, `userPrincipalName`, `user_principal_name`, `email`, `mail` |
| **Entry points** | Review pack generation |
| **Persisted** | No (exported ZIP) |
**Rules:** Exact key match via `in_array($key, $piiKeys, true)`.
**Risk Score:** 🟢 **SAFE** — Uses exact key matching. No false positive risk for policy config keys.
---
### Finding F-6: `VerificationReportSanitizer::sanitizeMessage()` (LOW — SEV-3)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Support/Verification/VerificationReportSanitizer.php` |
| **Lines** | 382-413 |
| **Layer** | UI rendering |
| **What's redacted** | Same pattern as `RunFailureSanitizer`: Bearer tokens, key=value secrets, long opaque blobs, plus blanket `str_ireplace` |
**Same issue as F-4:** The `str_ireplace` at L404-409 will corrupt any message containing "password" as a substring.
---
### Finding F-7: `DriftEvidence::sanitize()` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Services/Drift/DriftEvidence.php` |
| **Lines** | 12-28 |
| **Layer** | Drift evidence formatting |
| **What's redacted** | Nothing — this is an allowlist-based key filter, not a secret redactor |
**Risk Score:** 🟢 **SAFE** — Uses allowlist approach. No false positives.
---
### Finding F-8: `InventoryMetaSanitizer` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Services/Inventory/InventoryMetaSanitizer.php` |
| **Layer** | Inventory metadata normalization |
| **What's redacted** | Nothing secret-related — normalizes structural metadata (odata_type, etag, scope_tag_ids) |
**Risk Score:** 🟢 **SAFE** — Not a secret redactor at all.
---
### Finding F-9: `GraphContractRegistry::sanitizeArray()` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Services/Graph/GraphContractRegistry.php` |
| **Lines** | 637+ |
| **Layer** | Graph API payload preparation (restore path) |
| **What's redacted** | Read-only/metadata keys stripped for Graph API compliance |
**Risk Score:** 🟢 **SAFE** — Not secret redaction. Strips OData metadata keys using exact-match lists.
---
### Finding F-10: Model-level `$hidden` (SAFE)
| Models | Hidden attributes |
|--------|------------------|
| `User` | `password`, `remember_token` |
| `ProviderCredential` | `payload` (encrypted:array) |
| `AlertDestination` | (needs verification) |
| `PlatformUser` | (needs verification) |
**Risk Score:** 🟢 **SAFE** — Standard Laravel serialization protection. Correct usage.
---
### Finding F-11: `ProviderCredentialObserver` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Observers/ProviderCredentialObserver.php` |
| **Layer** | Audit logging for credential changes |
| **What's redacted** | Logs `'redacted_fields' => ['client_secret']` as metadata |
**Risk Score:** 🟢 **SAFE** — Explicitly documents redacted fields; doesn't perform substring matching.
---
## Root Cause Analysis
### Primary Root Cause: Substring Regex on Key Names
The `PolicySnapshotRedactor` (F-1) is the **single root cause** by severity. It uses:
```php
'/password/i' preg_match('/password/i', 'passwordMinimumLength') === 1 FALSE POSITIVE
```
This pattern has **no word boundaries**, **no exact-match semantics**, and **no allowlist of known safe keys**.
### Secondary Root Cause: Redaction at Persistence Time
The redactor runs **before** data is stored in `PolicyVersion.snapshot`. This makes the data loss **permanent and irrecoverable**. If redaction only happened at rendering/export time, the original data would still be available for drift detection, compare, and restore.
### Tertiary Root Cause: No Centralized Redaction Service
Three independent implementations (`PolicySnapshotRedactor`, `AuditContextSanitizer`, `VerificationReportSanitizer`) each define their own rules with inconsistent approaches. There is no single source of truth for "what is a secret key."
---
## Patch Plan
### Phase 1: Quick Fix — Allowlist + Word Boundaries (PRIORITY A — do first)
**Fix `PolicySnapshotRedactor`** to use an explicit allowlist of exact secret key names instead of substring regex:
```php
// BEFORE (broken):
private const array SENSITIVE_KEY_PATTERNS = [
'/password/i',
'/secret/i',
'/token/i',
// ...
];
// AFTER (fixed):
private const array EXACT_SECRET_KEYS = [
'password',
'wifi_password',
'wifiPassword',
'clientSecret',
'client_secret',
'sharedSecret',
'shared_secret',
'sharedKey',
'shared_key',
'preSharedKey',
'pre_shared_key',
'presharedKey',
'psk',
'privateKey',
'private_key',
'certificatePassword',
'certificate_password',
'pfxPassword',
'pfx_password',
'token', // bare "token" only — NOT tokenType
'accessToken',
'access_token',
'refreshToken',
'refresh_token',
'bearerToken',
'bearer_token',
'secret', // bare "secret" only — NOT secretReferenceValueId
'passphrase',
];
// Match exact key name (case-insensitive):
private function isSensitiveKey(string $key): bool
{
$normalized = strtolower(trim($key));
foreach (self::EXACT_SECRET_KEYS as $secretKey) {
if ($normalized === strtolower($secretKey)) {
return true;
}
}
return false;
}
```
**Apply the same fix to `AuditContextSanitizer::shouldRedactKey()`.**
### Phase 2: Move Redaction to Render Time (PRIORITY A — architectural fix)
Currently the redaction happens **before persistence**:
```
Graph API → PolicySnapshotRedactor → PolicyVersion.snapshot (REDACTED! 💀)
```
The correct architecture is:
```
Graph API → PolicyVersion.snapshot (ORIGINAL ✅)
[On read/render/export]
PolicySnapshotRedactor → UI / Export / Notification
```
This requires:
1. Remove `snapshotRedactor->redactPayload()` calls from `PolicyCaptureOrchestrator` and `VersionService::captureVersion()`
2. Apply redaction at rendering time in Filament resources, export jobs, and notification formatters
3. Ensure logs (`Log::info/warning`) never include raw snapshot data (they currently don't — they only log IDs)
**Migration consideration:** Existing `PolicyVersion` records with `[REDACTED]` values cannot be recovered. Document this as known data loss for historical versions.
### Phase 3: Centralized Redaction Service (PRIORITY B — proper fix)
Create a single `SecretKeyClassifier` service:
```php
final class SecretKeyClassifier
{
// Exact secret keys from Intune/Graph schemas
private const array SECRET_KEYS = [ /* ... */ ];
// Keys that LOOK like secrets but are config values (safety net)
private const array SAFE_CONFIG_KEYS = [
'passwordMinimumLength',
'passwordRequired',
'passwordExpirationDays',
'passwordMinimumCharacterSetCount',
'passwordBlockSimple',
'passwordPreviousPasswordBlockCount',
'deviceCompliancePasswordRequired',
'localAdminPasswordRotationEnabled',
'certificateValidityPeriodScale',
'certificateRenewalThresholdPercentage',
'tokenType',
'tokenAccountType',
// ... extend as new policy types are added
];
public function isSecretKey(string $key): bool { /* ... */ }
public function isKnownSafeConfigKey(string $key): bool { /* ... */ }
}
```
All three sanitizer systems (`PolicySnapshotRedactor`, `AuditContextSanitizer`, `VerificationReportSanitizer`) should delegate to this single classifier.
### Phase 4: Schema-Driven Secret Classification (PRIORITY C — long-term)
Extend `GraphContractRegistry` with per-policy-type secret field metadata:
```php
'deviceConfiguration' => [
// ...existing...
'secret_fields' => ['wifi_password', 'preSharedKey', 'certificatePassword'],
],
```
The `SecretKeyClassifier` would then consult the contract registry for type-specific secret lists, providing the most precise classification possible.
---
## Required Tests
### Unit Tests (new)
```
tests/Unit/PolicySnapshotRedactorFalsePositiveTest.php
```
**Sample test data:**
```json
{
"passwordMinimumLength": 12,
"passwordRequired": true,
"wifi_password": "SuperGeheimesPasswort123!",
"clientSecret": "abc",
"token": "xyz",
"deviceCompliancePasswordRequired": true,
"localAdminPasswordRotationEnabled": true,
"certificatePassword": "pfxpw",
"sharedKey": "psk",
"osVersionMinimum": "10.0.22631",
"certificateValidityPeriodScale": "years",
"tokenType": "user",
"passwordExpirationDays": 90,
"passwordBlockSimple": true
}
```
**Expected after redaction:**
| Key | Expected |
|-----|----------|
| `passwordMinimumLength` | `12` (VISIBLE) |
| `passwordRequired` | `true` (VISIBLE) |
| `wifi_password` | `[REDACTED]` |
| `clientSecret` | `[REDACTED]` |
| `token` | `[REDACTED]` |
| `deviceCompliancePasswordRequired` | `true` (VISIBLE) |
| `localAdminPasswordRotationEnabled` | `true` (VISIBLE) |
| `certificatePassword` | `[REDACTED]` |
| `sharedKey` | `[REDACTED]` |
| `osVersionMinimum` | `10.0.22631` (VISIBLE) |
| `certificateValidityPeriodScale` | `years` (VISIBLE) |
| `tokenType` | `user` (VISIBLE) |
| `passwordExpirationDays` | `90` (VISIBLE) |
| `passwordBlockSimple` | `true` (VISIBLE) |
### Unit Tests (update existing)
- `tests/Unit/AuditContextSanitizerTest.php` — add false-positive key tests
- `tests/Feature/Intune/PolicySnapshotRedactionTest.php` — add config key preservation tests
- `tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php` — add config key preservation
### Integration Tests
- Drift compare with `passwordMinimumLength` changes → must detect change
- Baseline compare with `passwordRequired` difference → must show diff
- Review pack export → config keys visible, actual secrets redacted
- Restore flow → original config values preserved through full cycle
---
## Guard Rails (Regression Prevention)
### 1. CI Regression Test
A dedicated test that asserts known Intune config keys are NEVER redacted:
```php
it('never redacts known Intune configuration keys', function (string $key, mixed $value) {
$redactor = new PolicySnapshotRedactor();
$result = $redactor->redactPayload([$key => $value]);
expect($result[$key])->toBe($value, "Config key '{$key}' was incorrectly redacted");
})->with([
['passwordMinimumLength', 12],
['passwordRequired', true],
['passwordExpirationDays', 90],
['passwordMinimumCharacterSetCount', 4],
['passwordBlockSimple', true],
['passwordPreviousPasswordBlockCount', 5],
['deviceCompliancePasswordRequired', true],
['localAdminPasswordRotationEnabled', true],
['certificateValidityPeriodScale', 'years'],
['certificateRenewalThresholdPercentage', 20],
['tokenType', 'user'],
]);
```
### 2. Static Analysis / CI Grep
Add a CI step that fails if any new code introduces substring-matching regex against "password"/"secret"/"token" without word boundaries:
```bash
# Fail if new redaction patterns use substring matching
grep -rn --include='*.php' "preg_match.*password\|str_contains.*password" app/ \
| grep -v "SAFE_CONFIG_KEYS\|EXACT_SECRET_KEYS\|SecretKeyClassifier" \
&& echo "FAIL: New substring-based password detection found" && exit 1
```
### 3. Central Redaction Service
All secret-classification logic must flow through `SecretKeyClassifier` (Phase 3). No component should independently define what constitutes a "secret key."
---
## Summary Table
| # | File | Layer | Persisted | False Positives | Severity | Fix Priority |
|---|------|-------|-----------|----------------|----------|-------------|
| F-1 | `PolicySnapshotRedactor.php` | Storage | **YES** | **HIGH** (password*, certificate*, token*) | **SEV-1** | **IMMEDIATE** |
| F-2 | `AuditContextSanitizer.php` | Audit | **YES** | **HIGH** (password, token substrings) | **SEV-2** | **HIGH** |
| F-3 | `VerificationReportSanitizer.php` | UI | No | **MEDIUM** (password in values) | **SEV-3** | MEDIUM |
| F-4 | `RunFailureSanitizer.php` | Errors | YES | **LOW** (str_ireplace on messages) | **SEV-3** | LOW |
| F-5 | `GenerateReviewPackJob.php` | Export | No | None (exact match) | SAFE | — |
| F-6 | `VerificationReportSanitizer::sanitizeMessage()` | UI | No | **LOW** (same as F-4) | **SEV-3** | LOW |
| F-7 | `DriftEvidence.php` | Drift | No | None (allowlist) | SAFE | — |
| F-8 | `InventoryMetaSanitizer.php` | Inventory | No | None (structural only) | SAFE | — |
| F-9 | `GraphContractRegistry.php` | API prep | No | None (exact match) | SAFE | — |
| F-10 | Model `$hidden` | Serialization | N/A | None | SAFE | — |
| F-11 | `ProviderCredentialObserver.php` | Audit | No | None (explicit docs) | SAFE | — |
---
## Assumptions & Caveats
1. **ASSUMPTION:** Intune Graph API responses contain keys like `passwordMinimumLength` at the top-level of policy JSON. Verified against Microsoft Graph documentation for deviceManagement policies — confirmed.
2. **ASSUMPTION:** No other secret redaction exists in Monolog processors or Laravel exception handlers. Verified: no custom Monolog processors found in `config/logging.php` or `app/Exceptions/`.
3. **CANNOT VERIFY:** Whether any existing `PolicyVersion` snapshots in production have already suffered data loss from this bug. **Recommendation:** Query production DB for `PolicyVersion` records where `snapshot::jsonb ? 'passwordMinimumLength'` and check if the value is `[REDACTED]`.
4. **ASSUMPTION:** Telescope / Debugbar are not deployed to production. If they are, their built-in redaction (which typically uses similar patterns) could also cause false positives in debug data.
5. **Double redaction in orchestrator path:** When `PolicyCaptureOrchestrator::capture()` calls `VersionService::captureVersion()`, the redactor runs twice (once in each). This is functionally harmless (idempotent) but indicates unclear ownership.