@@ -1268,7 +1358,7 @@ function Write-Summary {
)
Write-Log "Storage Growth Analysis - $Hostname"
- Write-Log ([char]0x2550 * 63)
+ Write-Log ("$([char]0x2550)" * 63)
Write-Log ""
# OS Drive first
@@ -1296,7 +1386,7 @@ function Write-Summary {
Write-Log ""
Write-Log "DATA DRIVES"
- Write-Log ([char]0x2500 * 63)
+ Write-Log ("$([char]0x2500)" * 63)
$dataDrives = @($AllAnalyses | Where-Object { $_.DriveType -ne 'OS' })
if ($dataDrives.Count -eq 0) {
@@ -1337,7 +1427,7 @@ function Write-Summary {
}
}
- Write-Log ([char]0x2550 * 63)
+ Write-Log ("$([char]0x2550)" * 63)
Write-Log "SERVER STATUS: $($ServerStatus.ToUpper())"
Write-Log "Database: $($Script:DB_PATH)"
Write-Log ""
@@ -1351,7 +1441,7 @@ function Main {
$deviceId = $env:COMPUTERNAME
$runningInNinja = $null -ne (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue)
- # ── Step 1: Initialize ───────────────────────────────────────────────────
+ # -- Step 1: Initialize ---------------------------------------------------
if (-not $runningInNinja) {
Write-Log "*** TEST MODE - Not running in Ninja context ***"
@@ -1396,7 +1486,7 @@ function Main {
# Register device
Get-DeviceRecord -DeviceId $deviceId -Hostname $hostname
- # ── Step 2: Migrate Legacy Data ──────────────────────────────────────────
+ # -- Step 2: Migrate Legacy Data ------------------------------------------
# Check for existing JSON history and migrate (one-time)
$existingDrives = @(Get-AllDeviceDrives -DeviceId $deviceId)
@@ -1404,7 +1494,7 @@ function Main {
Import-LegacyJsonHistory -DeviceId $deviceId
}
- # ── Step 3: Discover & Collect ───────────────────────────────────────────
+ # -- Step 3: Discover & Collect -------------------------------------------
$currentDrives = $null
try {
@@ -1420,18 +1510,18 @@ function Main {
Write-Log "WARNING: No qualifying drives found."
}
- # ── Step 4: Update Database ──────────────────────────────────────────────
+ # -- Step 4: Update Database ----------------------------------------------
$now = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss")
$visibleLetters = @($currentDrives | ForEach-Object { $_.Letter })
- # Upsert drives and insert metrics
+ # Upsert drives and record daily metrics (idempotent per calendar day)
foreach ($drive in $currentDrives) {
$driveId = Save-DriveRecord -DeviceId $deviceId -DriveLetter $drive.Letter `
-VolumeLabel $drive.VolumeLabel -TotalSizeGB $drive.TotalSizeGB `
-DriveType $drive.DriveType -Status "Online"
- Add-MetricRecord -DriveId $driveId -Timestamp $now `
+ Set-DailyMetric -DriveId $driveId -Timestamp $now `
-UsedGB $drive.UsedGB -FreeGB $drive.FreeGB -UsagePercent $drive.UsagePercent
Write-VerboseLog "Drive $($drive.Letter): Metric recorded (Used: $($drive.UsedGB) GB, Free: $($drive.FreeGB) GB)"
@@ -1448,11 +1538,14 @@ function Main {
# Remove drives offline > 30 days
$removedCount = Remove-StaleDrives -DeviceId $deviceId
+ if ($removedCount -gt 0) {
+ Write-Log "Removed $removedCount stale drives (offline > $($Script:OFFLINE_REMOVAL_DAYS) days)"
+ }
# Prune old metrics
Remove-OldMetrics
- # ── Step 5: Analyze All Drives ───────────────────────────────────────────
+ # -- Step 5: Analyze All Drives -------------------------------------------
$allDrives = @(Get-AllDeviceDrives -DeviceId $deviceId)
$allAnalyses = [System.Collections.ArrayList]::new()
@@ -1472,7 +1565,7 @@ function Main {
foreach ($a in $osAnalyses) { [void]$sortedAll.Add($a) }
foreach ($a in $sortedData) { [void]$sortedAll.Add($a) }
- # ── Step 6: Server Status ────────────────────────────────────────────────
+ # -- Step 6: Server Status ------------------------------------------------
$worstSeverity = 0
$serverStatus = "Insufficient Data"
@@ -1486,7 +1579,7 @@ function Main {
}
}
- # ── Step 7: Critical Drive Alerts (fire-once) ────────────────────────────
+ # -- Step 7: Critical Drive Alerts (fire-once) ----------------------------
$newCriticalDrives = [System.Collections.ArrayList]::new()
@@ -1512,7 +1605,7 @@ function Main {
Write-CriticalAlert -CriticalDrives $newCriticalDrives -Hostname $hostname
}
- # ── Step 8: Generate Report & Output ─────────────────────────────────────
+ # -- Step 8: Generate Report & Output -------------------------------------
# Console summary
Write-Summary -Hostname $hostname -ServerStatus $serverStatus -AllAnalyses @($sortedAll) `
@@ -1533,7 +1626,7 @@ function Main {
Write-VerboseLog "HTML report generated ($($htmlReport.Length) chars)"
}
- # ── Step 9: Finalize ─────────────────────────────────────────────────────
+ # -- Step 9: Finalize -----------------------------------------------------
# Log database stats
$totalDrives = $allDrives.Count
From d1c0ae9d068139929393f9623f94920a90cf9a76 Mon Sep 17 00:00:00 2001
From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com>
Date: Wed, 25 Feb 2026 23:12:18 -0500
Subject: [PATCH 8/9] Refactor drive metric handling and improve security
Refactor drive metric insertion logic to use atomic DELETE and INSERT in a single batch, improving efficiency and preventing duplicate entries. Update HTML encoding for user-sourced values to enhance security.
---
rmm-ninja/ServerGrowthTracking | 69 ++++++++++++++++------------------
1 file changed, 33 insertions(+), 36 deletions(-)
diff --git a/rmm-ninja/ServerGrowthTracking b/rmm-ninja/ServerGrowthTracking
index e9907bd..13284cf 100644
--- a/rmm-ninja/ServerGrowthTracking
+++ b/rmm-ninja/ServerGrowthTracking
@@ -407,6 +407,9 @@ function Save-DriveRecord {
} | Out-Null
$newDrive = Get-DriveRecord -DeviceId $DeviceId -DriveLetter $DriveLetter
+ if (-not $newDrive) {
+ throw "Failed to retrieve newly inserted drive record for ${DeviceId}:${DriveLetter}"
+ }
return $newDrive.id
}
}
@@ -441,6 +444,7 @@ function Set-DailyMetric {
.SYNOPSIS
Inserts or updates the metric for a drive for the current calendar day.
Prevents duplicate rows when the script runs more than once per day.
+ Uses atomic DELETE+INSERT in a single batch to avoid TOCTOU races.
#>
param(
[int]$DriveId,
@@ -459,32 +463,22 @@ function Set-DailyMetric {
$dayStart = $parsed.ToString("yyyy-MM-dd") + "T00:00:00"
$dayEnd = $parsed.AddDays(1).ToString("yyyy-MM-dd") + "T00:00:00"
- $existing = Invoke-SqliteQuery -DataSource $Script:DB_PATH -Query @"
- SELECT id FROM metric
- WHERE drive_id = @driveId AND timestamp >= @dayStart AND timestamp < @dayEnd
- ORDER BY timestamp DESC LIMIT 1
+ # Atomic: delete any existing same-day row then insert the new one, in a single statement batch.
+ # This runs within a single PSSQLite connection so both statements share one implicit transaction.
+ Invoke-SqliteQuery -DataSource $Script:DB_PATH -Query @"
+ DELETE FROM metric
+ WHERE drive_id = @driveId AND timestamp >= @dayStart AND timestamp < @dayEnd;
+ INSERT INTO metric (drive_id, timestamp, used_gb, free_gb, usage_percent)
+ VALUES (@driveId, @ts, @used, @free, @pct);
"@ -SqlParameters @{
driveId = $DriveId
dayStart = $dayStart
dayEnd = $dayEnd
- }
-
- if ($existing) {
- $updateId = if ($existing -is [array]) { $existing[0].id } else { $existing.id }
- Invoke-SqliteQuery -DataSource $Script:DB_PATH -Query @"
- UPDATE metric SET timestamp = @ts, used_gb = @used, free_gb = @free, usage_percent = @pct
- WHERE id = @id
-"@ -SqlParameters @{
- id = $updateId
- ts = $Timestamp
- used = $UsedGB
- free = $FreeGB
- pct = $UsagePercent
- } | Out-Null
- }
- else {
- Add-MetricRecord -DriveId $DriveId -Timestamp $Timestamp -UsedGB $UsedGB -FreeGB $FreeGB -UsagePercent $UsagePercent
- }
+ ts = $Timestamp
+ used = $UsedGB
+ free = $FreeGB
+ pct = $UsagePercent
+ } | Out-Null
}
function Get-DriveMetrics {
@@ -906,7 +900,7 @@ function Get-DriveAnalysis {
Write-VerboseLog "Drive $($DriveRecord.drive_letter): slope=$([math]::Round($dailyGrowth, 4)) GB/day, R$([char]0x00B2)=$($regression.RSquared)"
- $result.GBPerMonth = $monthlyGrowth.ToString("F3")
+ $result.GBPerMonth = $monthlyGrowth.ToString("F3", [System.Globalization.CultureInfo]::InvariantCulture)
$currentFreeGB = $result.CurrentFreeGB
$currentUsagePercent = $result.CurrentPercent
@@ -919,7 +913,7 @@ function Get-DriveAnalysis {
$daysUntilFull = $currentFreeGB / $dailyGrowth
if ($daysUntilFull -gt $Script:DAYS_CAP) { $daysUntilFull = $Script:DAYS_CAP }
$daysUntilFull = [math]::Round($daysUntilFull, 2)
- $result.DaysUntilFull = $daysUntilFull.ToString("F2")
+ $result.DaysUntilFull = $daysUntilFull.ToString("F2", [System.Globalization.CultureInfo]::InvariantCulture)
$result.NumericDays = $daysUntilFull
}
@@ -1026,8 +1020,9 @@ function Build-SvgChart {
$legendItems = [System.Collections.ArrayList]::new()
foreach ($analysis in $DriveAnalyses) {
$color = $Script:CHART_COLORS[$legendItems.Count % $Script:CHART_COLORS.Count]
- $label = ($analysis.Letter -replace ':$', '')
- if ($analysis.DriveType -eq 'OS') { $label += " (OS)" }
+ $rawLabel = ($analysis.Letter -replace ':$', '')
+ if ($analysis.DriveType -eq 'OS') { $rawLabel += " (OS)" }
+ $label = [System.Net.WebUtility]::HtmlEncode($rawLabel)
[void]$legendItems.Add(@{ Label = $label; Color = $color })
}
@@ -1036,8 +1031,8 @@ function Build-SvgChart {
$legendRows = 1
$testX = $mLeft
foreach ($item in $legendItems) {
- $testX += 65 + ($item.Label.Length * 2)
- if ($testX -gt ($chartWidth - 80)) {
+ $testX += 28 + ($item.Label.Length * 6)
+ if ($testX -gt ($chartWidth - 20)) {
$testX = $mLeft
$legendRows++
}
@@ -1128,8 +1123,8 @@ function Build-SvgChart {
[void]$sb.Append("
")
[void]$sb.Append("$($item.Label)")
- $legendX += 65 + ($item.Label.Length * 2)
- if ($legendX -gt ($chartWidth - 80)) {
+ $legendX += 28 + ($item.Label.Length * 6)
+ if ($legendX -gt ($chartWidth - 20)) {
$legendX = $mLeft
$legendY += 16
}
@@ -1178,13 +1173,15 @@ function Build-StorageReport {
$now = (Get-Date).ToString("yyyy-MM-dd HH:mm")
$driveCount = $AllAnalyses.Count
- $totalPoints = ($AllAnalyses | ForEach-Object { $_.DataPoints } | Measure-Object -Sum).Sum
+ $measureResult = ($AllAnalyses | ForEach-Object { $_.DataPoints } | Measure-Object -Sum)
+ $totalPoints = if ($null -ne $measureResult.Sum) { [int]$measureResult.Sum } else { 0 }
# Build SVG chart
$svgChart = Build-SvgChart -DriveAnalyses $AllAnalyses
- # HTML-encode user-sourced values to prevent injection via volume labels or hostname
+ # HTML-encode interpolated values to prevent injection via volume labels, hostname, or status
$safeHostname = [System.Net.WebUtility]::HtmlEncode($Hostname)
+ $safeServerStatus = [System.Net.WebUtility]::HtmlEncode($ServerStatus)
# Build summary table rows
$tableRows = [System.Text.StringBuilder]::new()
@@ -1200,7 +1197,7 @@ function Build-StorageReport {
$baseStatus = $a.Status -replace '\s*\(Limited\)', ''
$badge = $statusBadgeColors[$baseStatus]
if (-not $badge) { $badge = $statusBadgeColors["Offline"] }
- $statusDisplay = $a.Status
+ $statusDisplay = [System.Net.WebUtility]::HtmlEncode($a.Status)
$statusHtml = "$statusDisplay"
$sizeStr = if ($a.TotalSizeGB -gt 0) { "$([math]::Round($a.TotalSizeGB, 0)) GB" } else { '-' }
@@ -1210,7 +1207,7 @@ function Build-StorageReport {
$growthStr = $a.GBPerMonth
$daysStr = $a.DaysUntilFull
- $capStr = ([double]$Script:DAYS_CAP).ToString("F2")
+ $capStr = ([double]$Script:DAYS_CAP).ToString("F2", [System.Globalization.CultureInfo]::InvariantCulture)
if ($daysStr -eq $capStr) {
$capYears = [math]::Round($Script:DAYS_CAP / 365, 0)
$daysStr = "${capYears}yr+"
@@ -1243,7 +1240,7 @@ function Build-StorageReport {
$safeHostname
-
Storage: $ServerStatus | $now
+
Storage: $safeServerStatus | $now
$svgChart
@@ -1279,7 +1276,7 @@ v$($Script:VERSION) | $driveCount drives | $totalPoints pts | OLS regression | S
function Initialize-EventSource {
try {
if (-not [System.Diagnostics.EventLog]::SourceExists($Script:EVENT_SOURCE)) {
- New-EventLog -LogName Application -Source $Script:EVENT_SOURCE -ErrorAction Stop
+ New-EventLog -LogName Application -Source $Script:EVENT_SOURCE -ErrorAction Stop | Out-Null
Write-VerboseLog "Event log source '$($Script:EVENT_SOURCE)' registered"
}
}
From cce946559c72f95ae9a3c0c908516aaa249406cd Mon Sep 17 00:00:00 2001
From: Zach Boogher <129975920+AlrightLad@users.noreply.github.com>
Date: Thu, 26 Mar 2026 01:52:54 -0400
Subject: [PATCH 9/9] Fix formatting in ServerGrowthTracking