Public/Invoke-IntuneAIAssist.ps1
|
# Auto-generated by module/Build-Module.ps1 from en/scripts/Invoke-IntuneAIAssist.ps1 # Edit the source script, then re-run Build-Module.ps1. Do not edit this file directly. function Invoke-IntuneAIAssist { <# .SYNOPSIS EXPERIMENTAL, OPT-IN AI recreation ASSISTANT for the items the kit cannot auto-import. .DESCRIPTION For each MANUAL / SKIPPED item (encrypted-secret profiles, Admin Templates, Endpoint Security intents, or any SKIP_* row from an import log), this tool asks a CONFIGURABLE AI endpoint to draft: 1. a short recreation guide for the target-tenant admin (portal steps), and 2. a PowerShell + Microsoft Graph scaffold (with -WhatIf and <PLACEHOLDER> for secrets). Output is written to .\ai-output (a runbook + scaffold .ps1 files) for HUMAN REVIEW. IT NEVER WRITES TO ANY TENANT and never auto-executes anything. Goal: reduce manual portal work, not replace human judgement. PRIVACY: object METADATA is sent to the AI endpoint you configure. Secret values are redacted before sending. This is OPT-IN. Prefer Azure OpenAI (data stays in your tenant) over a public API. .PARAMETER ExportPath Export folder produced by the kit (contains manifest.json + NN_* families). .PARAMETER ImportLog (Optional) CSV log from the import engine; SKIP_* / ERROR rows drive the item list. .PARAMETER OutputPath Where to write the runbook + scaffolds. Default: .\ai-output .PARAMETER Language Language of the generated runbook: en | fr. Default en. .PARAMETER MaxItems Safety cap on the number of items sent to the AI. Default 25. .PARAMETER ExcludeFamilies Families to skip (e.g. 01_DeviceConfigurations). .PARAMETER AssumeYes Skip the privacy confirmation prompt (for automation). .PARAMETER SendToProvider Explicit opt-in to actually contact the AI provider over the network. WITHOUT this switch the tool runs DRY-RUN: it redacts and writes metadata locally and makes ZERO network calls. .NOTES AI settings are read from config.ps1 (git-ignored) or environment variables. The API key is NEVER bundled with the kit: $AiProvider = 'AzureOpenAI' | 'OpenAI' | 'Custom' (env: INTUNE_AI_PROVIDER) $AiEndpoint = '<full chat/completions URL>' (env: INTUNE_AI_ENDPOINT) $AiApiKey = '<your key>' (env: INTUNE_AI_API_KEY) $AiModel = '<model / deployment name>' (env: INTUNE_AI_MODEL) #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$ExportPath, [string]$ImportLog, [string]$OutputPath = (Join-Path (Get-Location) 'ai-output'), [ValidateSet('en','fr')][string]$Language = 'en', [int]$MaxItems = 25, [string[]]$ExcludeFamilies, [switch]$AssumeYes, [switch]$SendToProvider ) $ErrorActionPreference = 'Stop' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # --- AI configuration (config.ps1 at the bundle root, else environment variables). Key never bundled. --- $cfg = Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ps1' if (Test-Path -LiteralPath $cfg) { . $cfg } $AiProvider = if ($AiProvider) { $AiProvider } elseif ($env:INTUNE_AI_PROVIDER) { $env:INTUNE_AI_PROVIDER } else { 'OpenAI' } $AiEndpoint = if ($AiEndpoint) { $AiEndpoint } else { $env:INTUNE_AI_ENDPOINT } $AiApiKey = if ($AiApiKey) { $AiApiKey } else { $env:INTUNE_AI_API_KEY } $AiModel = if ($AiModel) { $AiModel } else { $env:INTUNE_AI_MODEL } if ($SendToProvider -and -not $AiApiKey) { throw "No AI API key. Set `$AiApiKey (and `$AiEndpoint / `$AiModel / `$AiProvider) in config.ps1, or the INTUNE_AI_* environment variables. The key is provided by YOU and never shipped with the kit. (A key is only required with -SendToProvider; dry-run needs none.)" } # --- Privacy gate (opt-in) --- Write-Host "" Write-Host "AI RECREATION ASSISTANT (experimental)" -ForegroundColor Magenta if ($SendToProvider) { Write-Host "Object METADATA (secrets redacted) WILL be sent to: $AiProvider" -ForegroundColor Yellow } else { Write-Host "DRY-RUN: -SendToProvider not set. NOTHING is sent; redacted metadata is written locally only." -ForegroundColor Yellow } Write-Host "This tool NEVER writes to a tenant. Output is for review only." -ForegroundColor Yellow if ($SendToProvider -and -not $AssumeYes) { $r = Read-Host "Proceed? [y/N]" if ($r -notmatch '^[yYoO]') { Write-Host "Cancelled."; return } } # --- Redaction: strip secret values before anything leaves the machine --- # Property / key names whose value is ALWAYS redacted (defense in depth). $secretKeys = @( 'secretReferenceValueId','value','password','omaSettingBase64', 'scriptContent','detectionScriptContent','remediationScriptContent', 'privateKey','certificate','token','connectionString','clientSecret' ) function Remove-Secrets { param($Obj) if ($null -eq $Obj) { return $null } if ($Obj -is [System.Collections.IEnumerable] -and -not ($Obj -is [string]) -and -not ($Obj -is [System.Collections.IDictionary])) { return @($Obj | ForEach-Object { Remove-Secrets $_ }) } if ($Obj -is [System.Collections.IDictionary]) { $o = [ordered]@{} foreach ($k in $Obj.Keys) { if ($k -in $secretKeys) { $o[$k] = '<REDACTED>' } else { $o[$k] = Remove-Secrets $Obj[$k] } } return $o } if ($Obj -is [System.Management.Automation.PSCustomObject]) { $o = [ordered]@{} foreach ($p in $Obj.PSObject.Properties) { if ($p.Name -in $secretKeys) { $o[$p.Name] = '<REDACTED>' } else { $o[$p.Name] = Remove-Secrets $p.Value } } return [pscustomobject]$o } return $Obj } # --- Hard pre-scan: refuse to transmit anything that still looks like a secret --- function Assert-NoSecret { param([string]$Payload) foreach ($pat in @('-----BEGIN','MII[A-Za-z0-9+/]{200,}','\b[0-9A-Fa-f]{40}\b')) { if ($Payload -match $pat) { throw "Secret pre-scan tripped ('$pat'): aborting BEFORE any network call. Nothing was sent." } } } # --- Provider-agnostic chat call (OpenAI-compatible) --- function Invoke-AiChat { param([string]$System,[string]$User) $body = @{ messages = @(@{ role='system'; content=$System }, @{ role='user'; content=$User }); temperature = 0.2 } switch ($AiProvider) { 'AzureOpenAI' { if (-not $AiEndpoint) { throw 'AzureOpenAI requires $AiEndpoint (full chat/completions URL with api-version).' } $uri = $AiEndpoint; $headers = @{ 'api-key' = $AiApiKey } } 'OpenAI' { $body.model = if ($AiModel) { $AiModel } else { 'gpt-4.1-mini' } $uri = if ($AiEndpoint) { $AiEndpoint } else { 'https://api.openai.com/v1/chat/completions' } $headers = @{ Authorization = "Bearer $AiApiKey" } } default { # Custom OpenAI-compatible endpoint if (-not $AiEndpoint) { throw 'Custom provider requires $AiEndpoint.' } if ($AiModel) { $body.model = $AiModel } $uri = $AiEndpoint; $headers = @{ Authorization = "Bearer $AiApiKey" } } } # Defense in depth: redact the WHOLE payload recursively before it can leave the machine. $body = Remove-Secrets $body $payloadJson = ($body | ConvertTo-Json -Depth 12) # Hard pre-scan on the serialized payload: a witness secret aborts here, BEFORE any Invoke-RestMethod. Assert-NoSecret -Payload $payloadJson # Opt-in network gate: without -SendToProvider there is ZERO network egress (dry-run). if (-not $SendToProvider) { return "[DRY-RUN] -SendToProvider not set: no external AI call was made. Redacted metadata only." } $resp = Invoke-RestMethod -Method POST -Uri $uri -Headers $headers -ContentType 'application/json' -Body $payloadJson return [string]$resp.choices[0].message.content } # --- Build the list of MANUAL / skipped items --- function Get-ManualItems { $items = @() if ($ImportLog -and (Test-Path -LiteralPath $ImportLog)) { foreach ($r in (Import-Csv -LiteralPath $ImportLog)) { if ($r.Status -and ($r.Status -like 'SKIP*' -or $r.Status -eq 'ERROR')) { $items += [pscustomobject]@{ Family=$r.Family; Name=$r.Name; Reason=($r.Reason + ' ' + $r.Error + ' ' + $r.ErrorMessage).Trim() } } } } # Fallback / complement: scan the export for known manual families. foreach ($fam in '01_DeviceConfigurations','14_AdminTemplates','15_EndpointSecurity') { if ($ExcludeFamilies -contains $fam) { continue } $dir = Join-Path $ExportPath $fam if (-not (Test-Path -LiteralPath $dir)) { continue } foreach ($f in (Get-ChildItem $dir -Filter *.json -File)) { $o = Get-Content $f.FullName -Raw | ConvertFrom-Json $isSecret = $fam -eq '01_DeviceConfigurations' -and @($o.omaSettings | Where-Object { $_.secretReferenceValueId }).Count -gt 0 if ($fam -eq '01_DeviceConfigurations' -and -not $isSecret) { continue } # non-secret device configs are auto-imported $name = if ($o.displayName) { $o.displayName } else { $o.name } if ($items.Name -notcontains $name) { $items += [pscustomobject]@{ Family=$fam; Name=$name; Reason='Manual family / not auto-importable'; _file=$f.FullName } } } } return $items | Select-Object -First $MaxItems } # --- Main --- New-Item -ItemType Directory -Force -Path $OutputPath, (Join-Path $OutputPath 'scaffolds') | Out-Null $manual = @(Get-ManualItems) if ($manual.Count -eq 0) { Write-Host "No manual/skipped items found." -ForegroundColor Green; return } Write-Host ("Drafting recreation help for {0} item(s)..." -f $manual.Count) -ForegroundColor Cyan $system = @" You are a senior Microsoft Intune and Microsoft Graph engineer. For an exported Intune object that could NOT be auto-imported into a target tenant, write the answer in $Language and produce: 1. A concise, numbered recreation guide for the target-tenant admin (Intune portal steps). 2. A PowerShell + Microsoft Graph (Invoke-MgGraphRequest, beta) scaffold that recreates it, using -WhatIf where relevant and a clearly marked <PLACEHOLDER> for any secret the export does not contain. Never invent secret values. Keep it practical and short. Return Markdown and put the script inside a single fenced 'powershell' code block. "@ $runbook = New-Object System.Text.StringBuilder [void]$runbook.AppendLine("# AI recreation runbook (review before use)`n") [void]$runbook.AppendLine("> Generated by Invoke-IntuneAIAssist.ps1. **Review every step and script before running.** Nothing here was executed against a tenant.`n") $i = 0 foreach ($it in $manual) { $i++ Write-Host (" [{0}/{1}] {2}" -f $i, $manual.Count, $it.Name) -ForegroundColor DarkCyan $meta = @{ family=$it.Family; name=$it.Name; reason=$it.Reason } if ($it._file) { $meta.object = (Remove-Secrets (Get-Content $it._file -Raw | ConvertFrom-Json)) } $user = "Family: $($it.Family)`nName: $($it.Name)`nReason it was skipped: $($it.Reason)`nRedacted object metadata (JSON):`n" + ($meta | ConvertTo-Json -Depth 20) try { $answer = Invoke-AiChat -System $system -User $user } catch { [void]$runbook.AppendLine("## $($it.Name) ($($it.Family))`n_AI call failed: $($_.Exception.Message)_`n") continue } [void]$runbook.AppendLine("## $($it.Name) · _$($it.Family)_`n") [void]$runbook.AppendLine($answer + "`n") if (-not $SendToProvider) { [void]$runbook.AppendLine("`n> DRY-RUN - nothing was sent. Redacted metadata that WOULD be sent:`n") [void]$runbook.AppendLine('```json') [void]$runbook.AppendLine(($meta | ConvertTo-Json -Depth 20)) [void]$runbook.AppendLine('```') } [void]$runbook.AppendLine("`n---`n") $m = [regex]::Match($answer, '(?s)```powershell(.*?)```') if ($m.Success) { $safe = ($it.Name -replace '[^\w\.\- ]','_').Trim(); if ($safe.Length -gt 60) { $safe = $safe.Substring(0,60) } $sf = Join-Path $OutputPath ("scaffolds\{0}.ps1" -f $safe) ("# REVIEW BEFORE RUNNING - AI-generated scaffold. Never run blindly against a tenant.`n" + $m.Groups[1].Value.Trim()) | Set-Content -LiteralPath $sf -Encoding UTF8 } } $rbPath = Join-Path $OutputPath 'RUNBOOK.md' Set-Content -LiteralPath $rbPath -Value $runbook.ToString() -Encoding UTF8 Write-Host "" Write-Host ("Runbook : {0}" -f $rbPath) -ForegroundColor Green Write-Host ("Scaffolds: {0}" -f (Join-Path $OutputPath 'scaffolds')) -ForegroundColor Green Write-Host "REVIEW everything before running any generated script." -ForegroundColor Yellow } |