## 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
543 lines
23 KiB
Markdown
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.
|