tools/Invoke-NinjaDiscovery.ps1
|
<# .SYNOPSIS NinjaRMM entry point: downloads this toolkit from its GitHub repo, runs a discovery scan with the options you pick in the Ninja parameter UI, and optionally emails or uploads the result - unattended, in one script deployment. .DESCRIPTION Paste this ONE file into a NinjaOne "Automation" script (PowerShell). Nothing else needs to be pre-staged on the endpoint - it downloads the rest of the toolkit itself. Run it as SYSTEM (Ninja lets you pick the run-as context); every collector already supports that. WHY A DOWNLOAD STEP: Discover-WindowsServer.ps1 is not one file - it loads ~30 module/config files relative to itself. NinjaOne script deployment is built around a single script body, so this wrapper's first job is fetching the rest of the toolkit (as a repo zip, since git is not reliably present on managed endpoints) before it can run it. The extracted copy is wiped and re-fetched on every run, so it's always the latest toolkit version - no separate update mechanism to maintain. NINJAONE PARAMETER CONSTRAINTS (confirmed against NinjaOne's own docs before writing this): script parameters are plain strings only - no booleans, no arrays, and these characters are rejected outright: & | ; $ > < \ ! That breaks two real things this wrapper may need to pass through: a GitHub token (only for a private fork), and an upload URL (pre-signed S3/Azure Blob URLs are full of '&'-separated query parameters). Both are base64-encoded before being put in the Ninja parameter value and decoded here - base64's alphabet (A-Za-z0-9+/=) contains none of Ninja's blocked characters, so this sidesteps the restriction entirely rather than hoping a given value happens not to collide with it. SECURITY NOTE, read before using this in production: whatever you pass through Ninja's parameter UI - the repo token, the delivery credential - is visible in plaintext in NinjaOne's own Activities/run-history log (base64 is encoding, not encryption; anyone who can see the run history can trivially decode it). This was an explicit tradeoff the user chose over the alternatives (a secure NinjaOne custom field, or hardcoding the secret into this script body instead). Mitigate it: - RepoTokenB64: not needed for the public repo - leave it empty. Only for a private fork: use a fine-grained, READ-ONLY GitHub PAT scoped to ONLY that one repo, so a leak via Ninja's logs means "read this one repo," not more. - DeliveryCredentialB64: use a relay-only / least-privilege API key for whichever provider you pick (SendGrid/SMTP2GO/Postal all support scoping a key to send-only), not an account-wide key. If your NinjaOne plan has a secure/masked custom field or credential store, prefer that over a plain parameter for both values - swap the two ConvertFrom-NinjaBase64 calls below for however you read that field instead. OUTPUT HANDLING: after a successful delivery, the local output folder is deleted - Ninja-managed endpoints are client infrastructure, and this toolkit's whole design philosophy is not leaving discovery output sitting around longer than it has to (see the repo README's "output is client data" section). If delivery is skipped (-DeliveryMethod None) or fails, the output is left in place so nothing is silently lost - check $env:ProgramData\Discover-WindowsServer\ninja\output on the endpoint. .PARAMETER Mode Fast, Deep, or Custom (matches Discover-WindowsServer.ps1 -Mode exactly). .PARAMETER ProjectType GeneralDiscovery, ServerRefresh, HyperVRefresh, Decommission, AzureMigration, AppMigration, or CMMCReadiness. .PARAMETER ComplianceLens None, CMMC, or GeneralSecurity. .PARAMETER DeliveryMethod None, Smtp2Go, SendGrid, Postal, or Upload. None just leaves the output on the endpoint (see OUTPUT HANDLING above). .PARAMETER DeliveryTargetB64 Base64-encoded. Meaning depends on -DeliveryMethod: Smtp2Go / SendGrid : comma-separated "To" address(es), e.g. "ops@msp.com" Postal : "<server-url>|<to-address>", e.g. "https://postal.msp.com|ops@msp.com" Upload : the destination URL (PUT by default - see tools\Send-DiscoveryOutput.ps1) .PARAMETER DeliveryCredentialB64 Base64-encoded API key (Smtp2Go/SendGrid/Postal) - ignored for Upload/None. .PARAMETER RepoTokenB64 Optional. Leave empty for the public repo. Only for a private fork: a base64-encoded GitHub PAT with read access to it - see the security note above (fine-grained, read-only, one repo). .PARAMETER FromAddress Sender address for the three email delivery methods. Rarely needs to change per run, so it defaults to a constant below rather than costing you another Ninja parameter - edit $DefaultFromAddress in this file, or override it here if you do want it per-run. .EXAMPLE # As entered in NinjaOne's automation parameter fields (already base64-encoded): -Mode Deep -ProjectType Decommission -ComplianceLens None ` -DeliveryMethod SendGrid -DeliveryTargetB64 b3BzQG1zcC5jb20= ` -DeliveryCredentialB64 U0cueHh4eHh4 -RepoTokenB64 Z2hwX3h4eHh4 #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'DeliveryCredentialB64', Justification = 'NinjaOne script parameters are string-only')] param( [ValidateSet('Fast', 'Deep', 'Custom')] [string]$Mode = 'Fast', [ValidateSet('GeneralDiscovery', 'ServerRefresh', 'HyperVRefresh', 'Decommission', 'AzureMigration', 'AppMigration', 'CMMCReadiness')] [string]$ProjectType = 'GeneralDiscovery', [ValidateSet('None', 'CMMC', 'GeneralSecurity')] [string]$ComplianceLens = 'None', [ValidateSet('None', 'Smtp2Go', 'SendGrid', 'Postal', 'Upload')] [string]$DeliveryMethod = 'None', [string]$DeliveryTargetB64 = '', [string]$DeliveryCredentialB64 = '', [string]$RepoTokenB64 = '', [string]$FromAddress = '' ) $ErrorActionPreference = 'Stop' # Edit this once for your MSP rather than passing -FromAddress on every deployment. $DefaultFromAddress = 'discoveries@yourmsp.example.com' # ---- Repo location: edit if you fork/rename this repo ------------------------- $RepoOwner = 'ghostinator' $RepoName = 'Discover-WindowsServer' $RepoBranch = 'main' function ConvertFrom-NinjaBase64 { <# Decodes a base64 Ninja parameter value; '' in, '' out (never errors on empty). #> param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return '' } try { return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value)) } catch { throw "Parameter value is not valid base64. Got: $Value" } } function Get-PostalTargetParts { <# Splits a decoded Postal DeliveryTargetB64 ("<server-url>|<to-address>") into its two parts. Pure function, separated out so this specific parsing - the one part of this wrapper with real logic worth getting wrong - is unit-testable without a network call. #> param([Parameter(Mandatory)][string]$DecodedTarget) $parts = $DecodedTarget -split '\|', 2 if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { throw "Postal delivery target must decode to '<server-url>|<to-address>'. Got: $DecodedTarget" } return [pscustomobject]@{ PostalServerUrl = $parts[0]; ToAddress = $parts[1] } } function Get-DeliveryInvocationArgs { <# Pure function: given the (already-decoded) method/target/credential/from, returns the hashtable to splat onto Invoke-DiscoveryDelivery. No network/file access - unit testable on its own, same reasoning as Discover-WindowsServer-GUI.ps1's Build-DiscoveryArgumentList. #> # NinjaOne script parameters are strings only, so the API key arrives as text; this is # where it becomes a PSCredential. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'NinjaOne script parameters are string-only')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'DecodedCredential', Justification = 'NinjaOne script parameters are string-only')] param( [Parameter(Mandatory)][string]$ZipPath, [Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)][string]$DecodedTarget, [string]$DecodedCredential, [Parameter(Mandatory)][string]$From, [string]$Subject = "Discover-WindowsServer output: $env:COMPUTERNAME" ) $deliveryArgs = @{ ZipPath = $ZipPath; Method = $Method } switch ($Method) { 'Upload' { $deliveryArgs['UploadUrl'] = $DecodedTarget } 'Postal' { $p = Get-PostalTargetParts -DecodedTarget $DecodedTarget $deliveryArgs['PostalServerUrl'] = $p.PostalServerUrl $deliveryArgs['To'] = @($p.ToAddress) $deliveryArgs['From'] = $From $deliveryArgs['Subject'] = $Subject $deliveryArgs['Credential'] = [pscredential]::new('ApiKey', (ConvertTo-SecureString $DecodedCredential -AsPlainText -Force)) } default { # Smtp2Go / SendGrid $deliveryArgs['To'] = @($DecodedTarget -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $deliveryArgs['From'] = $From $deliveryArgs['Subject'] = $Subject $deliveryArgs['Credential'] = [pscredential]::new('ApiKey', (ConvertTo-SecureString $DecodedCredential -AsPlainText -Force)) } } return $deliveryArgs } # Only runs when executed directly - not when dot-sourced, matching the same convention # tools\Send-DiscoveryOutput.ps1 and Discover-WindowsServer-GUI.ps1 use, so this file's pure # functions above are unit-testable without triggering a real download-and-scan. if ($MyInvocation.InvocationName -ne '.') { $effectiveFrom = if ($FromAddress) { $FromAddress } else { $DefaultFromAddress } $repoToken = ConvertFrom-NinjaBase64 $RepoTokenB64 $decodedTarget = ConvertFrom-NinjaBase64 $DeliveryTargetB64 $decodedCredential = ConvertFrom-NinjaBase64 $DeliveryCredentialB64 if ($DeliveryMethod -ne 'None' -and -not $decodedTarget) { Write-Host "FATAL: -DeliveryMethod $DeliveryMethod was given but -DeliveryTargetB64 is empty." -ForegroundColor Red exit 1 } # ---- 1. Fetch the toolkit ------------------------------------------------------ # Its own subfolder: it's wiped at the start of every run, and the sibling branding\ folder # (Core.psm1's Get-DiscoveryBrandingDirectory) must survive that. $workRoot = Join-Path $env:ProgramData 'Discover-WindowsServer\ninja' $toolkitDir = Join-Path $workRoot 'toolkit' $zipPath = Join-Path $workRoot 'toolkit.zip' Write-Host "Downloading $RepoOwner/$RepoName@$RepoBranch ..." if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force } New-Item -ItemType Directory -Path $workRoot -Force | Out-Null try { # GitHub requires TLS 1.2; Windows PowerShell on 2012 R2/2016 defaults to Ssl3|Tls. Same logic # as Send-DiscoveryOutput.ps1's Enable-DiscoveryTls12 (not dot-sourced yet - it's in the download). $tlsNow = [int][Net.ServicePointManager]::SecurityProtocol if ($tlsNow -ne 0 -and -not ($tlsNow -band 3072)) { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]($tlsNow -bor 3072) } # The token is only needed for a private repo or fork; the public repo downloads anonymously. $headers = @{} if ($repoToken) { $headers['Authorization'] = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("x-access-token:$repoToken")) } Invoke-WebRequest -Uri "https://github.com/$RepoOwner/$RepoName/archive/refs/heads/$RepoBranch.zip" ` -Headers $headers -OutFile $zipPath -UseBasicParsing Expand-Archive -LiteralPath $zipPath -DestinationPath $workRoot -Force # GitHub's archive convention: <RepoName>-<branch>\... $extracted = Join-Path $workRoot "$RepoName-$RepoBranch" Rename-Item -LiteralPath $extracted -NewName 'toolkit' } catch { Write-Host "FATAL: could not download/extract the toolkit: $($_.Exception.Message)" -ForegroundColor Red exit 1 } # ---- 2. Run the scan ------------------------------------------------------------ Write-Host "Running Discover-WindowsServer.ps1 -Mode $Mode -ProjectType $ProjectType ..." $outputRoot = Join-Path $workRoot 'output' $engineScript = Join-Path $toolkitDir 'Discover-WindowsServer.ps1' try { $outputPath = & $engineScript -Mode $Mode -ProjectType $ProjectType -ComplianceLens $ComplianceLens -OutputRoot $outputRoot -Quiet } catch { Write-Host "FATAL: discovery run failed: $($_.Exception.Message)" -ForegroundColor Red exit 1 } Write-Host "Scan complete: $outputPath" # ---- 3. Deliver, if configured --------------------------------------------------- $deliveryOk = $true if ($DeliveryMethod -ne 'None') { . (Join-Path $toolkitDir 'tools\Send-DiscoveryOutput.ps1') $zip = Find-DiscoveryRunZip -OutputPath $outputPath if (-not $zip) { Write-Host "FATAL: no .zip found under $outputPath\archive - nothing to deliver." -ForegroundColor Red $deliveryOk = $false } else { try { $deliveryArgs = Get-DeliveryInvocationArgs -ZipPath $zip -Method $DeliveryMethod ` -DecodedTarget $decodedTarget -DecodedCredential $decodedCredential -From $effectiveFrom Invoke-DiscoveryDelivery @deliveryArgs | Out-Null Write-Host "Delivered via $DeliveryMethod." } catch { Write-Host "FATAL: delivery via $DeliveryMethod failed: $($_.Exception.Message)" -ForegroundColor Red $deliveryOk = $false } } } # ---- 4. Clean up local output once it's safely delivered ------------------------ if ($DeliveryMethod -ne 'None' -and $deliveryOk) { try { Remove-Item -LiteralPath $outputPath -Recurse -Force } catch { } Write-Host 'Local output removed after successful delivery.' } else { Write-Host "Local output left in place: $outputPath" } if (-not $deliveryOk) { exit 1 } exit 0 } |