<# .SYNOPSIS G-Scan export for NinjaOne: the largest files, the file types and the findings as CSV. .DESCRIPTION Writes three CSV files per run - files, types and advice - to a folder on the machine, and optionally copies them to a share. The activity output lists the ten largest files. The folder is locked down to SYSTEM and Administrators before anything is written, and the script refuses a folder that is, or sits under, a link or junction. Running as SYSTEM, a folder that ordinary users can write to would otherwise let one of them redirect where SYSTEM writes. Each CSV is its own scan. On an NTFS drive with System rights that is a few seconds. .PARAMETER Drive What to scan. Default: the system drive. A folder or \\server\share works too. .PARAMETER Folder Where the CSV files go. Default: %ProgramData%\GariaNet\G-Scan\reports. .PARAMETER Share Optional \\server\share\folder to copy the files to. As System this is reached as the computer account, so that account needs write access there. .PARAMETER KeepDays Remove this script's own CSV files from Folder once they are older than this. Default 30. .PARAMETER GScan Full path to gscan.com. Default: the standard installation folder. .PARAMETER TimeoutSeconds Give up on a scan after this long. Default 1800. .NOTES G-Scan by Garia.Net - https://garia.net/ Run as System. Windows PowerShell 5.1 or later. Exit codes: 0 written, 1 a scan or the copy failed, 2 gscan.com not found or an unsafe folder. #> [CmdletBinding()] param( [string]$Drive = "$env:SystemDrive\", [string]$Folder = (Join-Path $env:ProgramData 'GariaNet\G-Scan\reports'), [string]$Share = '', [int]$KeepDays = 30, [string]$GScan = '', [int]$TimeoutSeconds = 1800 ) $ErrorActionPreference = 'Stop' # NinjaOne script variables arrive as environment variables with the same name. if ($env:drive) { $Drive = $env:drive } if ($env:folder) { $Folder = $env:folder } if ($env:share) { $Share = $env:share } if ($env:keepDays) { $KeepDays = [int]$env:keepDays } if ($env:gscan) { $GScan = $env:gscan } if ($env:timeoutSeconds) { $TimeoutSeconds = [int]$env:timeoutSeconds } $inv = [System.Globalization.CultureInfo]::InvariantCulture function Find-GScan([string]$Given) { if ($Given) { return $Given } $pf = $env:ProgramW6432 if (-not $pf) { $pf = $env:ProgramFiles } Join-Path $pf 'GariaNetTools\G-Scan\gscan.com' } # Backslashes right before a closing quote must be doubled, or "C:\" would swallow the # quote and everything after it. function Format-Argument([string]$Value) { '"' + ($Value -replace '(\\+)$', '$1$1') + '"' } function Invoke-GScan([string]$Exe, [string[]]$Arguments, [int]$Timeout) { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $Exe $psi.Arguments = ($Arguments | ForEach-Object { Format-Argument $_ }) -join ' ' $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 $p = [System.Diagnostics.Process]::Start($psi) $out = $p.StandardOutput.ReadToEndAsync() $err = $p.StandardError.ReadToEndAsync() if (-not $p.WaitForExit($Timeout * 1000)) { try { $p.Kill() } catch { } return [pscustomobject]@{ ExitCode = -1; Output = ''; Error = "no result within $Timeout seconds" } } $p.WaitForExit() [pscustomobject]@{ ExitCode = $p.ExitCode; Output = $out.Result; Error = $err.Result.Trim() } } function Format-Size([double]$Bytes) { $units = 'B', 'KB', 'MB', 'GB', 'TB', 'PB' $i = 0 while ($Bytes -ge 1024 -and $i -lt $units.Count - 1) { $Bytes /= 1024; $i++ } if ($i -eq 0) { return [string]::Format($inv, '{0:0} {1}', $Bytes, $units[$i]) } [string]::Format($inv, '{0:0.0} {1}', $Bytes, $units[$i]) } # True when the path, or any existing folder above it, is a link or junction. function Test-LinkInPath([string]$Path) { $p = [System.IO.Path]::GetFullPath($Path) while ($p) { if (Test-Path -LiteralPath $p) { $item = Get-Item -LiteralPath $p -Force if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $true } } $parent = [System.IO.Path]::GetDirectoryName($p) if (-not $parent -or $parent -eq $p) { break } $p = $parent } $false } # ------------------------------------------------------------------- the folder ------- $exe = Find-GScan $GScan if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { Write-Output "gscan.com not found at $exe. Install G-Scan 1.14 or later, or pass -GScan." exit 2 } $Folder = [System.IO.Path]::GetFullPath($Folder) if (Test-LinkInPath $Folder) { Write-Output "Not writing to ${Folder}: it, or a folder above it, is a link or junction." exit 2 } if (-not (Test-Path -LiteralPath $Folder)) { New-Item -ItemType Directory -Path $Folder -Force | Out-Null if (Test-LinkInPath $Folder) { Write-Output "Not writing to ${Folder}: it was created through a link or junction." exit 2 } } # Owned by SYSTEM, Administrators or whoever runs this - never by another account, which # could hand the rights back to itself after we lock the folder down. $trusted = @('S-1-5-18', 'S-1-5-32-544') $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $owner = (Get-Acl -LiteralPath $Folder).GetOwner([System.Security.Principal.SecurityIdentifier]).Value if (($trusted + $me) -notcontains $owner) { Write-Output "Not writing to ${Folder}: it is owned by $owner, not by SYSTEM, Administrators or this account." exit 2 } try { $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) foreach ($sid in $trusted) { $id = New-Object System.Security.Principal.SecurityIdentifier($sid) $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $id, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') $acl.AddAccessRule($rule) } Set-Acl -LiteralPath $Folder -AclObject $acl } catch { Write-Output "Could not restrict access to ${Folder}: $($_.Exception.Message)" exit 2 } # ------------------------------------------------------------------- the exports ------ $label = ($Drive.TrimEnd('\', '/') -replace '[^A-Za-z0-9]+', '_').Trim('_') if (-not $label) { $label = 'scan' } $stamp = Get-Date -Format 'yyyyMMdd-HHmm' $exitCode = 0 $written = @() foreach ($what in 'files', 'types', 'advice') { $file = Join-Path $Folder ('{0}-{1}-{2}-{3}.csv' -f $env:COMPUTERNAME, $label, $stamp, $what) $run = Invoke-GScan $exe @('--scan', $Drive, '--out', $file, '--what', $what) $TimeoutSeconds if ($run.ExitCode -ne 0) { Write-Output "G-Scan could not export $what for $Drive (exit code $($run.ExitCode)): $($run.Error)" exit 1 } $written += $file } Write-Output "Written to ${Folder}:" $written | ForEach-Object { Write-Output (' ' + (Split-Path $_ -Leaf)) } if ($Share) { # Only into a folder that is already there. Copy-Item to a path that does not exist # quietly makes a file of that name instead - a typo in the share would then look like # a successful copy. if (-not (Test-Path -LiteralPath $Share -PathType Container)) { Write-Output "Could not copy to ${Share}: that folder does not exist or cannot be reached." $exitCode = 1 } else { try { Copy-Item -LiteralPath $written -Destination $Share -Force Write-Output "Copied to $Share" } catch { Write-Output "Could not copy to ${Share}: $($_.Exception.Message)" $exitCode = 1 } } } # Only this script's own files, recognised by their name, and only once they are old. $limit = (Get-Date).AddDays(-$KeepDays) Get-ChildItem -LiteralPath $Folder -File | Where-Object { $_.Name -match '^.+-\d{8}-\d{4}-(files|types|advice)\.csv$' -and $_.LastWriteTime -lt $limit } | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force; Write-Output ('Removed after {0} days: {1}' -f $KeepDays, $_.Name) } $filesCsv = $written | Where-Object { $_ -like '*-files.csv' } | Select-Object -First 1 $rows = @(Get-Content -LiteralPath $filesCsv -Encoding UTF8 | Select-Object -Skip 1 | ConvertFrom-Csv) Write-Output '' Write-Output 'Largest files:' foreach ($row in ($rows | Select-Object -First 10)) { Write-Output (' {0,10} {1}' -f (Format-Size ([double]$row.bytes)), $row.path) } exit $exitCode