TenantAtlas/app/Services/Intune/ScriptsPolicyNormalizer.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);
}
}