# Redaction / Masking / Sanitizing — Codebase Audit Report

> **Status:** Needs Review  
> **Last reviewed:** 2026-04-30  
> **Use for:** Security and data-integrity audit findings around redaction behavior and masking risks  
> **Do not use for:** Assuming every finding is still open without verifying the current codebase

**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.
