Adds scripts normalizer + safe script content display (opt-in, decoded, capped) Improves script diff UX: side-by-side + Before/After, Torchlight highlighting, fullscreen with scroll-sync Fixes Torchlight dark mode in diff lines Tests updated/added; ScriptPoliciesNormalizedDisplayTest.php passes Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #19
249 lines
7.7 KiB
PHP
249 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use Illuminate\Support\Arr;
|
|
|
|
class ScriptsPolicyNormalizer implements PolicyTypeNormalizer
|
|
{
|
|
public function __construct(private readonly DefaultPolicyNormalizer $defaultNormalizer) {}
|
|
|
|
public function supports(string $policyType): bool
|
|
{
|
|
return in_array($policyType, [
|
|
'deviceManagementScript',
|
|
'deviceShellScript',
|
|
'deviceHealthScript',
|
|
], true);
|
|
}
|
|
|
|
/**
|
|
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
|
|
*/
|
|
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
|
{
|
|
$snapshot = is_array($snapshot) ? $snapshot : [];
|
|
|
|
$displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name');
|
|
$description = Arr::get($snapshot, 'description');
|
|
|
|
$entries = [];
|
|
|
|
$entries[] = ['key' => 'Type', 'value' => $policyType];
|
|
|
|
if (is_string($displayName) && $displayName !== '') {
|
|
$entries[] = ['key' => 'Display name', 'value' => $displayName];
|
|
}
|
|
|
|
if (is_string($description) && $description !== '') {
|
|
$entries[] = ['key' => 'Description', 'value' => $description];
|
|
}
|
|
|
|
$entries = array_merge($entries, $this->contentEntries($snapshot));
|
|
|
|
$schedule = Arr::get($snapshot, 'runSchedule');
|
|
if (is_array($schedule) && $schedule !== []) {
|
|
$entries[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])];
|
|
}
|
|
|
|
$frequency = Arr::get($snapshot, 'runFrequency');
|
|
if (is_string($frequency) && $frequency !== '') {
|
|
$entries[] = ['key' => 'Run frequency', 'value' => $frequency];
|
|
}
|
|
|
|
$roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds');
|
|
if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) {
|
|
$entries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)];
|
|
}
|
|
|
|
return [
|
|
'status' => 'ok',
|
|
'settings' => [
|
|
[
|
|
'type' => 'keyValue',
|
|
'title' => 'Script settings',
|
|
'entries' => $entries,
|
|
],
|
|
],
|
|
'warnings' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{key: string, value: mixed}>
|
|
*/
|
|
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<int, array{key: string, value: mixed}>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
|
{
|
|
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
|
|
|
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
|
}
|
|
}
|