Fix RDS logon report to read RMM vars from $env: namespace#79
Fix RDS logon report to read RMM vars from $env: namespace#79mnelsondtc wants to merge 1 commit into
Conversation
NinjaOne passes RMM custom variables as environment variables, not as PowerShell scope variables. Reading from $env: lets DaysBack and OutputFolder configured in the Ninja Variables tab actually reach the script - they were being silently ignored before. The -NonInteractive auto-detect was a useful band-aid that let the script run but did not make the configured variables visible. Brings RDS to the same hardening baseline as app-putty: - $env:RMM / $env:Description / $env:RMMScriptPath / $env:DaysBack / $env:OutputFolder used throughout - [string]::IsNullOrWhiteSpace() guards on env-var reads so empty strings (common from RMM) are treated as unset - Main body wrapped in try/catch/finally with $exitCode tracking; the finally guarantees Stop-Transcript regardless of failure path - Unhandled exceptions now log $_.ScriptStackTrace for breadcrumbs - Explicit "exit $exitCode" at end of script so the RMM job result reflects success / failure - [SUCCESS] / [FAILURE] markers in stdout for quick at-a-glance results - Hint about Event Log Readers / SYSTEM rolled into the thrown error message instead of a separate Write-Host
There was a problem hiding this comment.
Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.
Once credits are available, push a new commit or reopen this pull request to trigger a review.
Gumbees
left a comment
There was a problem hiding this comment.
Review: PR #79 .. RDS logon report $env: hardening
Author: mnelsondtc → main, +149/-136, one file in msft-windows/ (msft-windows-rds-logon-report.ps1). HEAD 75bf7022.
What it does: brings msft-windows-rds-logon-report.ps1 to the same RMM hardening baseline as the app-putty scripts in PR #78. Sweeps bare $RMM / $DaysBack / $OutputFolder to $env: namespace, adds [string]::IsNullOrWhiteSpace guards, wraps the main body in try { ... } catch { } finally { Stop-Transcript }, replaces inner exit 1 with throw, tracks $exitCode, and exits explicitly. Follow-up to merged PR #52, addresses the convention drift PR #50's CLAUDE.md/template change never swept through legacy scripts.
The hardening pattern lands clean on every axis the prior reviews care about. But the compliance posture of the script's output makes the existing default unmergeable as-is.
Blockers (must fix before merge)
-
PII written to a world-readable temp directory by default
msft-windows-rds-logon-report.ps1:58:$OutputFolder = if ([string]::IsNullOrWhiteSpace($env:OutputFolder)) { 'C:\temp' } else { $env:OutputFolder }
:222:"TimeCreated,Computer,EventId,EventType,User,SessionID,SourceIP" | Set-Content -Path $csvPath -Encoding UTF8
The CSV contains
User,SourceIP, andSessionIDfor every RDS logon event in the window. Those are PII for dental-practice tenants (HIPAA-adjacent) and CUI-adjacent on the DoD/CMMC client subset. The default path isC:\temp.. host-local, world-readable by inherited ACL, no DTC-canonical hardened root. Any local user on the box (RDP session user, helpdesk that connected, attacker that landed unprivileged) can read every prior report.%WINDIR%\Tempis the same shape and not an acceptable destination either. Both inherit ACLs that don't gate read access to the auditing material.Fix shape:
- Move the default to a hardened DTC-canonical root .. e.g.
%ProgramData%\DTC\rds-reports\(created withNew-Item+ an explicitSet-Aclthat restricts read toAdministratorsand the service account that consumes the CSV). The DTC RMM state-storage convention is%PROGRAMDATA%\dtc\per recent device-state-DB conversations .. align this script's report path with that pattern. - Validate
$env:OutputFolderwhen set .. reject paths that escape the hardened root (Resolve-Pathand confirm the resolved path starts with the canonical prefix; reject UNC paths\\share\...unless explicitly whitelisted). Right now..\..\..\Windows\System32or\\evil-share\lootgets created and written to silently.
- Move the default to a hardened DTC-canonical root .. e.g.
-
PII not redacted at the report layer
:222ships rawUser,SourceIP,SessionIDcolumns. No option to hash the user identifier (e.g. SHA256 ofdomain\userfor trend analysis without revealing the principal), no option to truncate or anonymize the source IP, no$env:RedactPIItoggle. The report's read-side is "audit who logged in where, when" .. it can deliver that with non-reversible identifiers in most analysis modes. Pick a default redaction posture and add an env-var override for the cases that need the raw values.Fix shape:
- Default: redact
Userto a hash ((Get-FileHash -Algorithm SHA256 ...)against a tenant-salted input) and zero the last octet ofSourceIP(192.0.2.123→192.0.2.0). $env:RedactPII = "0"opt-out for engineers who need raw values during an investigation, with the in-clear path explicitly noted in the operator's hand-off doc and reviewed by Mike Schepers before that mode ships.- Either way, the CSV header should reflect what's in the column (
UserHash,SourceIPSubnet) when redaction is on, so a downstream reader knows what they're looking at.
- Default: redact
Real gaps (please address)
-
Backwards-compat note missing from the PR body
Any NinjaOne deployment that hadDaysBackorOutputFolderset in the Variables tab was silently running with the script's defaults (90,C:\temp) because the original script read the bare-variable form. After this merges, those values take effect for the first time. An operator who setDaysBack=365expecting a year of data and got 90 days will suddenly get a much larger CSV, possibly at a different path. One sentence in the body before merge. -
Operational-scope gap in the PR body
Body doesn't say who consumes the CSV, where it lands, or what the read-path looks like. Once blocker 1 is addressed and the destination is canonical, the body should state the contract: who reads these reports, on what cadence, and how they're rotated/purged. A logging/audit control without a read-loop is shelfware. -
Unguarded
[int]$env:DaysBack(~line 60)
Throws on non-numeric, no bounds check. NegativeDaysBackqueries the future and returns an empty CSV silently.[int]::TryParse+ range guard (1..365) would be cleaner. Pre-existing pattern across scripts, not a new regression in this PR.
Nits
- Helper function
Get-FilteredEventsinside the maintryrather than at top of file. Works fine (PowerShell hoists function defs), only called from one site, but conventionally function definitions live at the top of the file above the main flow. - PascalCase on
$Description/$DaysBack/$OutputFolder/$LogPathis intentional template mirroring (RMM-facing variable names), not a casing violation. Flagged for clarity.
Style notes (informational)
- Full
$env:sweep, string compare-ne "1",IsNullOrWhiteSpaceguards,try { ... } catch { } finally { Stop-Transcript }with$transcriptStartedguard, innerexit 1→throw,$exitCodetracked, singleexit $exitCodeat end .. all the hardening axes from PR #78's post-review state land clean here. The shape of the code itself is correct. - Template adherence is right.
VARIABLESspelled correctly, three-section structure intact, header doc claims match code reality. - Folder placement (
msft-windows/) and filename (kebab-case) both correct. - SYSTEM-context call-out is correctly inverted .. this script must run as SYSTEM to read the Security event log; the throw message explicitly calls that out (different from
chrome-remote-desktop's SYSTEM-refuse pattern, correctly inverted for the use case). - XML parse loop is defensive ..
if ($d.Name)guard, machine-account skip,Get-FilteredEventsreturns empty array on no-events.
Recommendation
REQUEST CHANGES.
The code-quality hardening is excellent and lands the PR #78 pattern cleanly. But the default output destination (C:\temp) and the absence of any PII-redaction layer in the CSV make this script a HIPAA / CMMC compliance liability the moment it runs against a dental practice's RDS host or a DoD-client jump box. Pin the default to a hardened DTC-canonical root, validate operator-supplied paths against escape, and add a redaction default (with an opt-out env-var for investigations). Once the output posture is right, blockers 3 and 4 are body-only edits and the rest is mergeable.
🤖 Reviewed by apis + 3-agent team (diff/intent, security/correctness, style/conventions). Posted as REQUEST CHANGES at Nate's direction .. the security slice's PII / temp-directory concerns are blocking for this PR.
Summary
Follow-up fix to PR #52 (merged): bring
msft-windows-rds-logon-report.ps1to the same RMM hardening baseline as the app-putty scripts (#78).Root cause: NinjaOne passes RMM custom variables as environment variables, not as PowerShell scope variables. The merged version of the script read
$RMM,$DaysBack, and$OutputFolderfrom script scope, so anything configured in the Ninja Variables tab was silently ignored. The-NonInteractiveauto-detect added during the original PR let the script run, but the configured variables still did not reach it.Changes
$env:namespace:$env:RMM,$env:Description,$env:RMMScriptPath,$env:DaysBack,$env:OutputFolder.[string]::IsNullOrWhiteSpace()guards on each env-var read so empty strings (common from RMM passthrough) are treated as unset and the default is applied.try { ... } catch { } finally { ... }with explicit$exitCodetracking.Stop-Transcriptnow runs infinally, so an unhandled exception in the XML parse loop (or anywhere else) can no longer leak an open transcript.exit 1calls replaced withthrowso the catch block centralizes failure handling.$_.ScriptStackTracefor breadcrumbs when something fails mid-parse.exit $exitCodeat end of script so NinjaOne reflects success/failure accurately.[SUCCESS]/[FAILURE]markers in stdout for quick at-a-glance job results.Event Log Readers/ SYSTEM) rolled into the thrown error message instead of a separate Write-Host.This is structurally the same pattern that landed in PR #78 for
app-putty/putty-install.ps1andapp-putty/putty-configure-logging.ps1.Backwards compatibility
Anyone running interactively with
$RMM=1; .\script.ps1(scope-var style) now needs to use$env:RMM='1'; .\script.ps1instead. Same convention as the app-putty scripts. The Read-Host interactive path is unchanged for$env:RMMnot set.Test plan
DaysBackandOutputFolderset in the Variables tab; confirm the configured values are honored (filename uses requested span, CSV lands in configured folder)C:\temp) and the job completes with[SUCCESS]+exit 0$env:RMM='1'; .\script.ps1; confirm CSV is produced$env:OutputFolderat an unwritable path); confirm[FAILURE]+ stack trace +exit 1C:\Windows\logs\msft-windows-rds-logon-report.logis closed cleanly in all of the above