diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index bea7788..bbfac49 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -623,6 +623,7 @@ private static function normalizedPolicyState(Policy $record): array $normalized['context'] = 'policy'; $normalized['record_id'] = (string) $record->getKey(); + $normalized['policy_type'] = $record->policy_type; $request->attributes->set($cacheKey, $normalized); diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 4bab649..91347ef 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -87,6 +87,7 @@ public static function infolist(Schema $schema): Schema $normalized['context'] = 'version'; $normalized['record_id'] = (string) $record->getKey(); + $normalized['policy_type'] = $record->policy_type; return $normalized; }) diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php index 945e520..68bf7e5 100644 --- a/app/Services/Intune/ScriptsPolicyNormalizer.php +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -39,22 +39,7 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $entries[] = ['key' => 'Description', 'value' => $description]; } - // Script content and large blobs should not dominate normalized output. - // Keep only safe summary fields if present. - $contentKeys = [ - 'scriptContent', - 'scriptContentBase64', - 'detectionScriptContent', - 'remediationScriptContent', - ]; - - foreach ($contentKeys as $key) { - $value = Arr::get($snapshot, $key); - - if (is_string($value) && $value !== '') { - $entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; - } - } + $entries = array_merge($entries, $this->contentEntries($snapshot)); $schedule = Arr::get($snapshot, 'runSchedule'); if (is_array($schedule) && $schedule !== []) { @@ -84,6 +69,173 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor ]; } + /** + * @return array + */ + private function contentEntries(array $snapshot): array + { + $showContent = (bool) config('tenantpilot.display.show_script_content', false); + $maxChars = (int) config('tenantpilot.display.max_script_content_chars', 5000); + if ($maxChars <= 0) { + $maxChars = 5000; + } + + if (! $showContent) { + return $this->contentSummaryEntries($snapshot); + } + + $entries = []; + + $scriptContent = Arr::get($snapshot, 'scriptContent'); + if (is_string($scriptContent) && $scriptContent !== '') { + $decoded = $this->decodeIfBase64Text($scriptContent); + if (is_string($decoded) && $decoded !== '') { + $scriptContent = $decoded; + } + } + + if (! is_string($scriptContent) || $scriptContent === '') { + $scriptContentBase64 = Arr::get($snapshot, 'scriptContentBase64'); + if (is_string($scriptContentBase64) && $scriptContentBase64 !== '') { + $decoded = base64_decode($this->stripWhitespace($scriptContentBase64), true); + if (is_string($decoded) && $decoded !== '') { + $scriptContent = $this->normalizeDecodedText($decoded); + } + } + } + + if (is_string($scriptContent) && $scriptContent !== '') { + $entries[] = ['key' => 'scriptContent', 'value' => $this->limitContent($scriptContent, $maxChars)]; + } + + foreach (['detectionScriptContent', 'remediationScriptContent'] as $key) { + $value = Arr::get($snapshot, $key); + + if (! is_string($value) || $value === '') { + continue; + } + + $decoded = $this->decodeIfBase64Text($value); + if (is_string($decoded) && $decoded !== '') { + $value = $decoded; + } + + $entries[] = ['key' => $key, 'value' => $this->limitContent($value, $maxChars)]; + } + + return $entries; + } + + private function decodeIfBase64Text(string $candidate): ?string + { + $trimmed = $this->stripWhitespace($candidate); + if ($trimmed === '' || strlen($trimmed) < 16) { + return null; + } + + if (strlen($trimmed) % 4 !== 0) { + return null; + } + + if (! preg_match('/^[A-Za-z0-9+\/=]+$/', $trimmed)) { + return null; + } + + $decoded = base64_decode($trimmed, true); + if (! is_string($decoded) || $decoded === '') { + return null; + } + + $decoded = $this->normalizeDecodedText($decoded); + if ($decoded === '') { + return null; + } + + if (! $this->looksLikeText($decoded)) { + return null; + } + + return $decoded; + } + + private function stripWhitespace(string $value): string + { + return preg_replace('/\s+/', '', $value) ?? ''; + } + + private function normalizeDecodedText(string $decoded): string + { + if (str_starts_with($decoded, "\xFF\xFE")) { + $decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16LE'); + } elseif (str_starts_with($decoded, "\xFE\xFF")) { + $decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16BE'); + } elseif (str_contains($decoded, "\x00")) { + $decoded = mb_convert_encoding($decoded, 'UTF-8', 'UTF-16LE'); + } + + if (str_starts_with($decoded, "\xEF\xBB\xBF")) { + $decoded = substr($decoded, 3); + } + + return $decoded; + } + + private function looksLikeText(string $decoded): bool + { + $length = strlen($decoded); + if ($length === 0) { + return false; + } + + $nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $decoded) ?: 0; + if ($nonPrintable > (int) max(1, $length * 0.05)) { + return false; + } + + // Scripts should typically contain some whitespace or line breaks. + if ($length >= 24 && ! preg_match('/\s/', $decoded)) { + return false; + } + + return true; + } + + /** + * @return array + */ + private function contentSummaryEntries(array $snapshot): array + { + // Script content and large blobs should not dominate normalized output. + // Keep only safe summary fields if present. + $contentKeys = [ + 'scriptContent', + 'scriptContentBase64', + 'detectionScriptContent', + 'remediationScriptContent', + ]; + + $entries = []; + + foreach ($contentKeys as $key) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; + } + } + + return $entries; + } + + private function limitContent(string $content, int $maxChars): string + { + if (mb_strlen($content) <= $maxChars) { + return $content; + } + + return mb_substr($content, 0, $maxChars).'…'; + } + /** * @return array */ diff --git a/composer.json b/composer.json index ccdbb83..b681b4b 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "filament/filament": "^4.0", + "lara-zeus/torch-filament": "^2.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "pepperfm/filament-json": "^4" diff --git a/composer.lock b/composer.lock index 33f7c64..4a56c47 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4f08fd9fc4b86cc13b75332dd6e1b7a", + "content-hash": "20819254265bddd0aa70006919cb735f", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2082,6 +2082,87 @@ }, "time": "2025-11-13T14:57:49+00:00" }, + { + "name": "lara-zeus/torch-filament", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/lara-zeus/torch-filament.git", + "reference": "71dbe8df4a558a80308781ba20c5922943b33009" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lara-zeus/torch-filament/zipball/71dbe8df4a558a80308781ba20c5922943b33009", + "reference": "71dbe8df4a558a80308781ba20c5922943b33009", + "shasum": "" + }, + "require": { + "filament/filament": "^4.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.16", + "torchlight/engine": "^0.1.0" + }, + "require-dev": { + "larastan/larastan": "^2.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.0", + "nunomaduro/phpinsights": "^2.8", + "orchestra/testbench": "^8.0", + "phpstan/extension-installer": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaraZeus\\TorchFilament\\TorchFilamentServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaraZeus\\TorchFilament\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lara Zeus", + "email": "info@larazeus.com" + } + ], + "description": "Infolist component to highlight code using Torchlight Engine", + "homepage": "https://larazeus.com/torch-filament", + "keywords": [ + "code", + "design", + "engine", + "filamentphp", + "highlight", + "input", + "lara-zeus", + "laravel", + "torchlight", + "ui" + ], + "support": { + "issues": "https://github.com/lara-zeus/torch-filament/issues", + "source": "https://github.com/lara-zeus/torch-filament" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/larazeus", + "type": "custom" + }, + { + "url": "https://github.com/atmonshi", + "type": "github" + } + ], + "time": "2025-06-11T19:32:10+00:00" + }, { "name": "laravel/framework", "version": "v12.42.0", @@ -4265,6 +4346,60 @@ }, "time": "2025-02-26T00:08:40+00:00" }, + { + "name": "phiki/phiki", + "version": "v1.1.6", + "source": { + "type": "git", + "url": "https://github.com/phikiphp/phiki.git", + "reference": "3174d8cb309bdccc32b7a33500379de76148256b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phikiphp/phiki/zipball/3174d8cb309bdccc32b7a33500379de76148256b", + "reference": "3174d8cb309bdccc32b7a33500379de76148256b", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.5.3", + "php": "^8.2" + }, + "require-dev": { + "illuminate/support": "^11.30", + "laravel/pint": "^1.18.1", + "pestphp/pest": "^3.5.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^7.1.6" + }, + "bin": [ + "bin/phiki" + ], + "type": "library", + "autoload": { + "psr-4": { + "Phiki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Chandler", + "email": "support@ryangjchandler.co.uk", + "homepage": "https://ryangjchandler.co.uk", + "role": "Developer" + } + ], + "description": "Syntax highlighting using TextMate grammars in PHP.", + "support": { + "issues": "https://github.com/phikiphp/phiki/issues", + "source": "https://github.com/phikiphp/phiki/tree/v1.1.6" + }, + "time": "2025-06-06T20:18:29+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -8110,6 +8245,62 @@ }, "time": "2024-12-21T16:25:41+00:00" }, + { + "name": "torchlight/engine", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/torchlight-api/engine.git", + "reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/torchlight-api/engine/zipball/8d12f611efb0b22406ec0744abb453ddd2f1fe9d", + "reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.5.3", + "phiki/phiki": "^1.1.4", + "php": "^8.2" + }, + "require-dev": { + "ext-dom": "*", + "ext-libxml": "*", + "laravel/pint": "^1.13", + "pestphp/pest": "^2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Torchlight\\Engine\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Francis", + "email": "aaron@hammerstone.dev" + }, + { + "name": "John Koster", + "email": "john@stillat.com" + } + ], + "description": "The PHP-based Torchlight code annotation and rendering engine.", + "keywords": [ + "Code highlighting", + "syntax highlighting" + ], + "support": { + "issues": "https://github.com/torchlight-api/engine/issues", + "source": "https://github.com/torchlight-api/engine/tree/v0.1.0" + }, + "time": "2025-04-02T01:47:48+00:00" + }, { "name": "ueberdosis/tiptap-php", "version": "2.0.0", diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 2e8c4b0..17efa20 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -218,4 +218,9 @@ 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), ], + + 'display' => [ + 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), + 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), + ], ]; diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index a534d59..a3c9c9d 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -7,6 +7,7 @@ $warnings = $state['warnings'] ?? []; $settings = $state['settings'] ?? []; $settingsTable = $state['settings_table'] ?? null; + $policyType = $state['policy_type'] ?? null; @endphp
@@ -137,13 +138,102 @@ @php $value = $entry['value'] ?? 'N/A'; + $isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true) + && (bool) config('tenantpilot.display.show_script_content', false); + if (is_array($value) || is_object($value)) { $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } @endphp - - {{ Str::limit((string) $value, 200) }} - + @if($isScriptContent) + @php + $code = (string) $value; + $firstLine = strtok($code, "\n") ?: ''; + + $grammar = 'powershell'; + + if ($policyType === 'deviceShellScript') { + $shebang = trim($firstLine); + + if (str_starts_with($shebang, '#!')) { + if (str_contains($shebang, 'zsh')) { + $grammar = 'zsh'; + } elseif (str_contains($shebang, 'bash')) { + $grammar = 'bash'; + } else { + $grammar = 'sh'; + } + } else { + $grammar = 'sh'; + } + } elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') { + $grammar = 'powershell'; + } + + $highlightedHtml = null; + + if (class_exists(\Torchlight\Engine\Engine::class)) { + try { + $highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $grammar, + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: true, + ); + } catch (\Throwable $e) { + $highlightedHtml = null; + } + } + @endphp + +
+
+ + Show + Hide + + + + {{ number_format(Str::length($code)) }} chars + +
+ +
+ @if (is_string($highlightedHtml) && $highlightedHtml !== '') + + +
{!! $highlightedHtml !!}
+ @else +
{{ $code }}
+ @endif +
+
+ @else + + {{ Str::limit((string) $value, 200) }} + + @endif
@endforeach diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md index 81d7bd2..4201d38 100644 --- a/specs/013-scripts-management/tasks.md +++ b/specs/013-scripts-management/tasks.md @@ -4,17 +4,22 @@ # Tasks: Scripts Management (013) **Input**: [spec.md](./spec.md), [plan.md](./plan.md) ## Phase 1: Contracts Review -- [ ] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). +- [x] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). ## Phase 2: UI Normalization -- [ ] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. -- [ ] T003 Register the normalizer in `AppServiceProvider`. +- [x] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. +- [x] T003 Register the normalizer in `AppServiceProvider`. ## Phase 3: Tests + Verification -- [ ] T004 Add tests for normalized output (shape + stability) for each script policy type. -- [ ] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. -- [ ] T006 Run targeted tests. -- [ ] T007 Run Pint (`./vendor/bin/pint --dirty`). +- [x] T004 Add tests for normalized output (shape + stability) for each script policy type. +- [x] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. +- [x] T006 Run targeted tests. +- [x] T007 Run Pint (`./vendor/bin/pint --dirty`). + +## Phase 4: Script Content Display (Safe) +- [x] T008 Add opt-in display + base64 decoding for `scriptContent` in normalized settings. +- [x] T009 Highlight script content with Torch (shebang-based shell + PowerShell default). +- [x] T010 Hide script content behind a Show/Hide button (collapsed by default). ## Open TODOs (Follow-up) - None yet. diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index 8f7e3cf..fdd05f7 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -26,7 +26,16 @@ 'external_id' => 'policy-1', ]); - PolicyVersion::factory()->create([ + config([ + 'tenantpilot.display.show_script_content' => true, + ]); + + $scriptContent = str_repeat('X', 20); + if ($policyType === 'deviceShellScript') { + $scriptContent = "#!/bin/zsh\n".str_repeat('X', 20); + } + + $version = PolicyVersion::factory()->create([ 'policy_id' => $policy->id, 'tenant_id' => $tenant->id, 'policy_type' => $policyType, @@ -34,13 +43,16 @@ '@odata.type' => $odataType, 'displayName' => 'Script policy', 'description' => 'desc', - 'scriptContent' => str_repeat('X', 20), + 'scriptContent' => $scriptContent, ], ]); $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index')) ->assertSuccessful(); + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings') + ->assertSuccessful(); + $originalEnv !== false ? putenv("INTUNE_TENANT_ID={$originalEnv}") : putenv('INTUNE_TENANT_ID'); diff --git a/tests/Unit/ScriptsPolicyNormalizerTest.php b/tests/Unit/ScriptsPolicyNormalizerTest.php index 8310d82..61a63a7 100644 --- a/tests/Unit/ScriptsPolicyNormalizerTest.php +++ b/tests/Unit/ScriptsPolicyNormalizerTest.php @@ -56,3 +56,113 @@ expect($result['settings'])->toBeArray()->not->toBeEmpty(); expect($result['settings'][0]['type'])->toBe('keyValue'); }); + +it('summarizes script content by default', function () { + config([ + 'tenantpilot.display.show_script_content' => false, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => 'ABC', + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('[content: 3 chars]'); +}); + +it('shows script content when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 100, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => "line1\nline2", + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe("line1\nline2"); +}); + +it('decodes scriptContentBase64 when enabled and scriptContent is missing', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 50, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceShellScript', + 'displayName' => 'My macOS shell script', + 'scriptContentBase64' => base64_encode('echo hello'), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('echo hello'); +}); + +it('decodes base64-looking scriptContent when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $plain = "# hello\nWrite-Host \"hi\""; + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => base64_encode($plain), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe($plain); +}); + +it('decodes base64-looking detection/remediation script content when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $detection = "# detection\nWrite-Host \"detect\""; + $remediation = "# remediation\nWrite-Host \"fix\""; + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceHealthScript', + 'displayName' => 'My remediation', + 'detectionScriptContent' => base64_encode($detection), + 'remediationScriptContent' => base64_encode($remediation), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'detectionScriptContent')['value'])->toBe($detection); + expect($entries->firstWhere('key', 'remediationScriptContent')['value'])->toBe($remediation); +});