Initial OfficeCom Sentinel client and deployment assets
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>false</UseWindowsForms>
|
||||
<Version>1.2.2</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="payload.zip" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
71
installer/AttackTracerNinjaBootstrapper/Program.cs
Normal file
71
installer/AttackTracerNinjaBootstrapper/Program.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
|
||||
namespace AttackTracerNinjaBootstrapper;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main()
|
||||
{
|
||||
string tempRoot = Path.Combine(Path.GetTempPath(), "AttackTracerNinjaSetup", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
try
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
string resourceName = assembly.GetManifestResourceNames()
|
||||
.First(name => name.EndsWith("payload.zip", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
string zipPath = Path.Combine(tempRoot, "payload.zip");
|
||||
using (Stream resourceStream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException("Embedded payload.zip was not found."))
|
||||
using (FileStream output = File.Create(zipPath))
|
||||
{
|
||||
resourceStream.CopyTo(output);
|
||||
}
|
||||
|
||||
string extractRoot = Path.Combine(tempRoot, "payload");
|
||||
ZipFile.ExtractToDirectory(zipPath, extractRoot);
|
||||
|
||||
string installScript = Path.Combine(extractRoot, "install-attacktracer-ninja.ps1");
|
||||
if (!File.Exists(installScript))
|
||||
{
|
||||
throw new FileNotFoundException("Installer script missing from payload.", installScript);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-ExecutionPolicy Bypass -File \"{installScript}\"",
|
||||
WorkingDirectory = extractRoot,
|
||||
UseShellExecute = true
|
||||
};
|
||||
|
||||
using Process process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Failed to launch installer process.");
|
||||
|
||||
process.WaitForExit();
|
||||
return process.ExitCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"AttackTracerNinja installer failed: {ex}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup; a locked temp folder should not hide the installer result.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>false</UseWindowsForms>
|
||||
<AssemblyName>AttackTracerNinjaServerBootstrapper</AssemblyName>
|
||||
<RootNamespace>AttackTracerNinjaServerBootstrapper</RootNamespace>
|
||||
<Version>1.0.9</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="payload.zip" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
70
installer/AttackTracerNinjaServerBootstrapper/Program.cs
Normal file
70
installer/AttackTracerNinjaServerBootstrapper/Program.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
|
||||
namespace AttackTracerNinjaServerBootstrapper;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main()
|
||||
{
|
||||
string tempRoot = Path.Combine(Path.GetTempPath(), "AttackTracerNinjaServerSetup", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
try
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
string resourceName = assembly.GetManifestResourceNames()
|
||||
.First(name => name.EndsWith("payload.zip", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
string zipPath = Path.Combine(tempRoot, "payload.zip");
|
||||
using (Stream resourceStream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException("Embedded payload.zip was not found."))
|
||||
using (FileStream output = File.Create(zipPath))
|
||||
{
|
||||
resourceStream.CopyTo(output);
|
||||
}
|
||||
|
||||
string extractRoot = Path.Combine(tempRoot, "payload");
|
||||
ZipFile.ExtractToDirectory(zipPath, extractRoot);
|
||||
|
||||
string installScript = Path.Combine(extractRoot, "install-attacktracer-ninja-server.ps1");
|
||||
if (!File.Exists(installScript))
|
||||
{
|
||||
throw new FileNotFoundException("Installer script missing from payload.", installScript);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-ExecutionPolicy Bypass -File \"{installScript}\"",
|
||||
WorkingDirectory = extractRoot,
|
||||
UseShellExecute = true
|
||||
};
|
||||
|
||||
using Process process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Failed to launch installer process.");
|
||||
|
||||
process.WaitForExit();
|
||||
return process.ExitCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"AttackTracerNinjaServer installer failed: {ex}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
installer/README.txt
Normal file
14
installer/README.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
OfficeCom Sentinel Installer Payload
|
||||
|
||||
This package installs OfficeCom Sentinel into:
|
||||
%ProgramFiles%\OCSentinel
|
||||
|
||||
Installed runner scripts:
|
||||
scripts\run-ocsentinel.ps1
|
||||
scripts\run-ocsentinel-monitor.ps1
|
||||
|
||||
Default config:
|
||||
config\ocsentinel-settings.json
|
||||
|
||||
Reports:
|
||||
reports\
|
||||
77
installer/install-attacktracer-ninja.ps1
Normal file
77
installer/install-attacktracer-ninja.ps1
Normal file
@@ -0,0 +1,77 @@
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptPath = $MyInvocation.MyCommand.Path
|
||||
$scriptDirectory = Split-Path -Parent $scriptPath
|
||||
$packageRoot = if ((Split-Path -Leaf $scriptDirectory) -ieq "scripts") { Split-Path -Parent $scriptDirectory } else { $scriptDirectory }
|
||||
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
||||
$appRoot = Join-Path $installRoot "app"
|
||||
$configRoot = Join-Path $installRoot "config"
|
||||
$reportsRoot = Join-Path $installRoot "reports"
|
||||
$samplesRoot = Join-Path $installRoot "samples"
|
||||
$scriptRoot = Join-Path $installRoot "scripts"
|
||||
$versionFile = Join-Path $packageRoot "VERSION.txt"
|
||||
$version = if (Test-Path $versionFile) { (Get-Content $versionFile -Raw).Trim() } else { "1.0.0" }
|
||||
|
||||
Write-Host "Installing OfficeCom Sentinel $version to $installRoot"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $appRoot, $configRoot, $reportsRoot, $samplesRoot, $scriptRoot | Out-Null
|
||||
|
||||
Copy-Item -Path (Join-Path $packageRoot "app\OCSentinelCli.exe") -Destination $appRoot -Force
|
||||
if (Test-Path (Join-Path $packageRoot "app\OCSentinelCli.pdb")) {
|
||||
Copy-Item -Path (Join-Path $packageRoot "app\OCSentinelCli.pdb") -Destination $appRoot -Force
|
||||
}
|
||||
Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-settings.example.json") -Destination (Join-Path $configRoot "ocsentinel-settings.example.json") -Force
|
||||
if (Test-Path (Join-Path $packageRoot "config\ocsentinel-client.example.json")) {
|
||||
Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-client.example.json") -Destination (Join-Path $configRoot "ocsentinel-client.example.json") -Force
|
||||
}
|
||||
Copy-Item -Path (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $samplesRoot "ninja-vulnerability-export.example.csv") -Force
|
||||
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Destination $scriptRoot -Force
|
||||
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Destination $scriptRoot -Force
|
||||
if (Test-Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1")) {
|
||||
Copy-Item -Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Destination $scriptRoot -Force
|
||||
}
|
||||
if (Test-Path (Join-Path $packageRoot "scripts\update-ocsentinel.ps1")) {
|
||||
Copy-Item -Path (Join-Path $packageRoot "scripts\update-ocsentinel.ps1") -Destination $scriptRoot -Force
|
||||
}
|
||||
if (Test-Path (Join-Path $packageRoot "scripts\build-attacktracer-org-report.ps1")) {
|
||||
Copy-Item -Path (Join-Path $packageRoot "scripts\build-attacktracer-org-report.ps1") -Destination $scriptRoot -Force
|
||||
}
|
||||
Copy-Item -Path (Join-Path $packageRoot "scripts\uninstall-ocsentinel.ps1") -Destination $scriptRoot -Force
|
||||
|
||||
$mainConfig = Join-Path $configRoot "ocsentinel-settings.json"
|
||||
$exampleConfig = Join-Path $configRoot "ocsentinel-settings.example.json"
|
||||
if (-not (Test-Path $mainConfig) -and (Test-Path $exampleConfig)) {
|
||||
Copy-Item $exampleConfig $mainConfig -Force
|
||||
}
|
||||
|
||||
$clientMainConfig = Join-Path $configRoot "ocsentinel-client.json"
|
||||
$clientExampleConfig = Join-Path $configRoot "ocsentinel-client.example.json"
|
||||
if (-not (Test-Path $clientMainConfig) -and (Test-Path $clientExampleConfig)) {
|
||||
Copy-Item $clientExampleConfig $clientMainConfig -Force
|
||||
}
|
||||
|
||||
$uninstallScript = Join-Path $scriptRoot "uninstall-ocsentinel.ps1"
|
||||
$uninstallCommand = "powershell.exe -ExecutionPolicy Bypass -File `"$uninstallScript`""
|
||||
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OCSentinel"
|
||||
|
||||
if (-not (Test-Path $uninstallKey)) {
|
||||
New-Item -Path $uninstallKey -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-ItemProperty -Path $uninstallKey -Name "DisplayName" -Value "OfficeCom Sentinel"
|
||||
Set-ItemProperty -Path $uninstallKey -Name "DisplayVersion" -Value $version
|
||||
Set-ItemProperty -Path $uninstallKey -Name "Publisher" -Value "OfficeCom"
|
||||
Set-ItemProperty -Path $uninstallKey -Name "InstallLocation" -Value $installRoot
|
||||
Set-ItemProperty -Path $uninstallKey -Name "UninstallString" -Value $uninstallCommand
|
||||
Set-ItemProperty -Path $uninstallKey -Name "QuietUninstallString" -Value $uninstallCommand
|
||||
Set-ItemProperty -Path $uninstallKey -Name "NoModify" -Value 1 -Type DWord
|
||||
Set-ItemProperty -Path $uninstallKey -Name "NoRepair" -Value 1 -Type DWord
|
||||
|
||||
Write-Host "Installation complete."
|
||||
Write-Host "Main path: $installRoot"
|
||||
Write-Host "Runner: $(Join-Path $scriptRoot 'run-ocsentinel.ps1')"
|
||||
Write-Host "Monitor: $(Join-Path $scriptRoot 'run-ocsentinel-monitor.ps1')"
|
||||
Write-Host "Updater: $(Join-Path $scriptRoot 'update-ocsentinel.ps1')"
|
||||
Write-Host "Org report:$(Join-Path $scriptRoot 'build-attacktracer-org-report.ps1')"
|
||||
3
installer/launch-install.cmd
Normal file
3
installer/launch-install.cmd
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -ExecutionPolicy Bypass -File "%~dp0install-attacktracer-ninja.ps1"
|
||||
exit /b %errorlevel%
|
||||
307
installer/runtime-build-attacktracer-org-report.ps1
Normal file
307
installer/runtime-build-attacktracer-org-report.ps1
Normal file
@@ -0,0 +1,307 @@
|
||||
param(
|
||||
[string]$ReportsRoot = "..\reports",
|
||||
[string]$OutputPath = "..\reports\attacktracer-org-report.html",
|
||||
[int]$MaxAlertRows = 25,
|
||||
[switch]$WriteNinjaOrgSummary,
|
||||
[switch]$EmitHtml
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Escape-Html {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return [System.Net.WebUtility]::HtmlEncode($Value)
|
||||
}
|
||||
|
||||
function Resolve-PathLike {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PathValue,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$BasePath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PathValue)) {
|
||||
return $PathValue
|
||||
}
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($PathValue) -or $PathValue.StartsWith("\\")) {
|
||||
return [System.IO.Path]::GetFullPath($PathValue)
|
||||
}
|
||||
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
|
||||
}
|
||||
|
||||
function Get-IncidentLabel {
|
||||
param([pscustomobject]$Source)
|
||||
|
||||
$target = @($Source.Targets)[0]
|
||||
$origin = @($Source.Sources)[0]
|
||||
|
||||
if ($target -match "Windows login") { return "Win Login-Fail" }
|
||||
if ($target -match "SQL Server") { return "SQL Login-Fail" }
|
||||
if ($target -match "Exchange") { return "Exchange Login-Fail" }
|
||||
if ($target -match "FTP") { return "FTP Login-Fail" }
|
||||
if ($origin) { return [string]$origin }
|
||||
if ($target) { return [string]$target }
|
||||
return "Auffaelligkeit"
|
||||
}
|
||||
|
||||
function Get-AlertRows {
|
||||
param([pscustomobject]$Report)
|
||||
|
||||
$rows = @()
|
||||
|
||||
foreach ($source in @($Report.TopSources)) {
|
||||
$rows += [pscustomobject]@{
|
||||
MachineName = [string]$Report.MachineName
|
||||
Incident = Get-IncidentLabel -Source $source
|
||||
Account = if (@($source.Usernames).Count -gt 0) { [string](@($source.Usernames)[0]) } else { "-" }
|
||||
Timestamp = [string]$source.LastSeenLocal
|
||||
Count = [int]$source.Count
|
||||
Ip = [string]$source.SourceIp
|
||||
Status = [string]$Report.AlertState
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows.Count -eq 0 -and ([string]$Report.AlertState -ne "ok" -or [int]$Report.TotalEvents -gt 0 -or [int]$Report.VulnerabilityCorrelation.CriticalCount -gt 0)) {
|
||||
$rows += [pscustomobject]@{
|
||||
MachineName = [string]$Report.MachineName
|
||||
Incident = if ([int]$Report.VulnerabilityCorrelation.CriticalCount -gt 0) { "CVE Korrelation" } else { "Auffaelligkeit" }
|
||||
Account = "-"
|
||||
Timestamp = [string]$Report.GeneratedAtLocal
|
||||
Count = [Math]::Max([int]$Report.TotalEvents, [int]$Report.VulnerabilityCorrelation.CriticalCount)
|
||||
Ip = "-"
|
||||
Status = [string]$Report.AlertState
|
||||
}
|
||||
}
|
||||
|
||||
return $rows
|
||||
}
|
||||
|
||||
function Build-OrgReportHtml {
|
||||
param(
|
||||
[pscustomobject[]]$Reports,
|
||||
[int]$MaxRows
|
||||
)
|
||||
|
||||
$sortedReports = @($Reports | Sort-Object MachineName)
|
||||
$alertingReports = @($sortedReports | Where-Object { $_.AlertState -ne "ok" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 })
|
||||
$criticalReports = @($sortedReports | Where-Object { $_.AlertState -eq "critical" })
|
||||
$totalEvents = (@($sortedReports | Measure-Object -Property TotalEvents -Sum).Sum)
|
||||
$totalEvents = if ($null -eq $totalEvents) { 0 } else { [int]$totalEvents }
|
||||
|
||||
$allIps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($report in $sortedReports) {
|
||||
foreach ($source in @($report.TopSources)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$source.SourceIp)) {
|
||||
$null = $allIps.Add([string]$source.SourceIp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$alertRows = foreach ($report in $alertingReports) {
|
||||
Get-AlertRows -Report $report
|
||||
}
|
||||
|
||||
$alertRows = @($alertRows | Sort-Object @{ Expression = { $_.Status -eq "critical" }; Descending = $true }, @{ Expression = { [DateTimeOffset]::Parse($_.Timestamp) }; Descending = $true })
|
||||
if ($alertRows.Count -gt $MaxRows) {
|
||||
$alertRows = @($alertRows | Select-Object -First $MaxRows)
|
||||
}
|
||||
|
||||
$cleanDevices = @($sortedReports | Where-Object { $_.AlertState -eq "ok" -and $_.TotalEvents -eq 0 -and $_.VulnerabilityCorrelation.CriticalCount -eq 0 } | Select-Object -ExpandProperty MachineName -Unique)
|
||||
$generatedAt = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss")
|
||||
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
[void]$sb.AppendLine('<div style="font-family: Segoe UI, Tahoma, sans-serif; font-size: 12px; color: #333;">')
|
||||
[void]$sb.AppendLine(" <h3 style=""color: #1e3a8a; margin-bottom: 8px; font-size: 14px;"">AttackTracer Report - $(Escape-Html ((Get-Date).ToString("dd.MM.yyyy")))</h3>")
|
||||
[void]$sb.AppendLine(" <div style=""margin-bottom: 10px; padding: 8px; background-color: #eff6ff; border: 1px solid #bfdbfe; color: #1e3a8a;""><strong>$(Escape-Html ([string]$sortedReports.Count)) Geraete</strong> gescannt | <strong>$(Escape-Html ([string]$alertingReports.Count)) auffaellig</strong> | <strong>$(Escape-Html ([string]$criticalReports.Count)) kritisch</strong> | <strong>$(Escape-Html ([string]$totalEvents)) Events</strong> | <strong>$(Escape-Html ([string]$allIps.Count)) eindeutige IPs</strong></div>")
|
||||
[void]$sb.AppendLine(' <table style="width: 100%; border-collapse: collapse; text-align: left;" border="1" cellpadding="4">')
|
||||
[void]$sb.AppendLine(' <tr style="background-color: #1e3a8a; color: white;">')
|
||||
[void]$sb.AppendLine(' <th>Server</th>')
|
||||
[void]$sb.AppendLine(' <th>Vorfall</th>')
|
||||
[void]$sb.AppendLine(' <th>Konto</th>')
|
||||
[void]$sb.AppendLine(' <th>Zeitpunkt</th>')
|
||||
[void]$sb.AppendLine(' <th>Anzahl</th>')
|
||||
[void]$sb.AppendLine(' <th>IP</th>')
|
||||
[void]$sb.AppendLine(' </tr>')
|
||||
|
||||
if ($alertRows.Count -eq 0) {
|
||||
[void]$sb.AppendLine(' <tr style="background-color: #f0fdf4; color: #166534;">')
|
||||
[void]$sb.AppendLine(' <td colspan="6">Keine Angriffe oder Korrelationen im ausgewerteten Bestand gefunden.</td>')
|
||||
[void]$sb.AppendLine(' </tr>')
|
||||
}
|
||||
else {
|
||||
foreach ($row in $alertRows) {
|
||||
$rowStyle = if ($row.Status -eq "critical") { "background-color: #fee2e2; color: #991b1b;" } else { "background-color: #fff7ed; color: #c2410c;" }
|
||||
$timestampText = $row.Timestamp
|
||||
try {
|
||||
$timestampText = ([DateTimeOffset]::Parse($row.Timestamp)).ToString("dd.MM.yy HH:mm")
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
[void]$sb.AppendLine(" <tr style=""$rowStyle"">")
|
||||
[void]$sb.AppendLine(" <td>$(Escape-Html $row.MachineName)</td>")
|
||||
[void]$sb.AppendLine(" <td>$(Escape-Html $row.Incident)</td>")
|
||||
[void]$sb.AppendLine(" <td>$(Escape-Html $row.Account)</td>")
|
||||
[void]$sb.AppendLine(" <td>$(Escape-Html $timestampText)</td>")
|
||||
[void]$sb.AppendLine(" <td>$(Escape-Html ([string]$row.Count))</td>")
|
||||
[void]$sb.AppendLine(" <td>$(Escape-Html $row.Ip)</td>")
|
||||
[void]$sb.AppendLine(' </tr>')
|
||||
}
|
||||
}
|
||||
|
||||
[void]$sb.AppendLine(' </table>')
|
||||
|
||||
if ($cleanDevices.Count -gt 0) {
|
||||
[void]$sb.AppendLine(' <div style="margin-top: 10px; font-size: 11px; color: #166534; background-color: #f0fdf4; padding: 6px; border: 1px solid #bbf7d0;">')
|
||||
[void]$sb.AppendLine(" <strong>Log sauber / Keine Angriffe:</strong> $(Escape-Html ($cleanDevices -join ', '))")
|
||||
[void]$sb.AppendLine(' </div>')
|
||||
}
|
||||
|
||||
[void]$sb.AppendLine(" <div style=""margin-top: 5px; font-size: 10px; color: #6b7280; text-align: right;"">Automatisch generiert am $(Escape-Html $generatedAt)</div>")
|
||||
[void]$sb.AppendLine('</div>')
|
||||
|
||||
return $sb.ToString()
|
||||
}
|
||||
|
||||
function Get-OrganizationStatus {
|
||||
param([pscustomobject[]]$Reports)
|
||||
|
||||
if (@($Reports | Where-Object { $_.AlertState -eq "critical" }).Count -gt 0) {
|
||||
return "critical"
|
||||
}
|
||||
|
||||
if (@($Reports | Where-Object { $_.AlertState -eq "warning" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 }).Count -gt 0) {
|
||||
return "warning"
|
||||
}
|
||||
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function Build-OrganizationSummaryText {
|
||||
param([pscustomobject[]]$Reports)
|
||||
|
||||
$deviceCount = @($Reports).Count
|
||||
$alertingCount = @($Reports | Where-Object { $_.AlertState -ne "ok" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 }).Count
|
||||
$criticalCount = @($Reports | Where-Object { $_.AlertState -eq "critical" }).Count
|
||||
$totalEvents = (@($Reports | Measure-Object -Property TotalEvents -Sum).Sum)
|
||||
$totalEvents = if ($null -eq $totalEvents) { 0 } else { [int]$totalEvents }
|
||||
|
||||
$topSystems = @(
|
||||
$Reports |
|
||||
Where-Object { $_.AlertState -ne "ok" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 } |
|
||||
Sort-Object @{ Expression = { $_.AlertState -eq "critical" }; Descending = $true }, @{ Expression = { [int]$_.TotalEvents }; Descending = $true } |
|
||||
Select-Object -ExpandProperty MachineName -First 5
|
||||
)
|
||||
|
||||
$summary = "$deviceCount Geraete gescannt, $alertingCount auffaellig, $criticalCount kritisch, $totalEvents Events."
|
||||
if ($topSystems.Count -gt 0) {
|
||||
$summary += " Top-Systeme: $($topSystems -join ', ')."
|
||||
}
|
||||
|
||||
return $summary
|
||||
}
|
||||
|
||||
function Set-NinjaOrganizationFieldValue {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
[AllowEmptyString()]
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
if (Get-Command -Name "Set-NinjaOrganizationProperty" -ErrorAction SilentlyContinue) {
|
||||
Set-NinjaOrganizationProperty -Name $Name -Value $Value | Out-Null
|
||||
return "Set-NinjaOrganizationProperty"
|
||||
}
|
||||
|
||||
if (Get-Command -Name "Ninja-Organization-Property-Set" -ErrorAction SilentlyContinue) {
|
||||
Ninja-Organization-Property-Set $Name $Value | Out-Null
|
||||
return "Ninja-Organization-Property-Set"
|
||||
}
|
||||
|
||||
throw "No supported NinjaOne organization custom field writer was available."
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$installRoot = Split-Path -Parent $scriptDir
|
||||
$reportsRootPath = Resolve-PathLike -PathValue $ReportsRoot -BasePath $installRoot
|
||||
$outputPathFull = Resolve-PathLike -PathValue $OutputPath -BasePath $installRoot
|
||||
|
||||
$reportFiles = @()
|
||||
if (Test-Path $reportsRootPath -PathType Leaf) {
|
||||
$reportFiles = @($reportsRootPath)
|
||||
}
|
||||
elseif (Test-Path $reportsRootPath -PathType Container) {
|
||||
$reportFiles = @(Get-ChildItem -Path $reportsRootPath -Recurse -Filter *.json | Select-Object -ExpandProperty FullName)
|
||||
}
|
||||
|
||||
if ($reportFiles.Count -eq 0) {
|
||||
throw "No report JSON files found under $reportsRootPath"
|
||||
}
|
||||
|
||||
$reports = foreach ($file in $reportFiles) {
|
||||
try {
|
||||
$report = Get-Content $file -Raw | ConvertFrom-Json
|
||||
if ($report.MachineName) {
|
||||
$report
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Skipping invalid report file ${file}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
if (@($reports).Count -eq 0) {
|
||||
throw "No valid AttackTracer report files were parsed."
|
||||
}
|
||||
|
||||
$latestReports = @(
|
||||
$reports |
|
||||
Group-Object MachineName |
|
||||
ForEach-Object {
|
||||
$_.Group |
|
||||
Sort-Object {
|
||||
try {
|
||||
[DateTimeOffset]::Parse([string]$_.GeneratedAtLocal)
|
||||
}
|
||||
catch {
|
||||
[DateTimeOffset]::MinValue
|
||||
}
|
||||
} -Descending |
|
||||
Select-Object -First 1
|
||||
}
|
||||
)
|
||||
|
||||
$html = Build-OrgReportHtml -Reports $latestReports -MaxRows $MaxAlertRows
|
||||
$orgStatus = Get-OrganizationStatus -Reports $latestReports
|
||||
$orgSummary = Build-OrganizationSummaryText -Reports $latestReports
|
||||
$orgLastUpdate = (Get-Date).ToString("o")
|
||||
|
||||
$outputDirectory = Split-Path -Parent $outputPathFull
|
||||
if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) {
|
||||
if (-not (Test-Path $outputDirectory)) {
|
||||
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
Set-Content -Path $outputPathFull -Value $html -Encoding UTF8
|
||||
Write-Host "Organization HTML report written to $outputPathFull"
|
||||
|
||||
if ($WriteNinjaOrgSummary) {
|
||||
$writer = Set-NinjaOrganizationFieldValue -Name "attacktracerorgstatus" -Value $orgStatus
|
||||
Set-NinjaOrganizationFieldValue -Name "attacktracerorgsummary" -Value $orgSummary | Out-Null
|
||||
Set-NinjaOrganizationFieldValue -Name "attacktracerorglastupdate" -Value $orgLastUpdate | Out-Null
|
||||
Write-Host "Organization summary fields updated via $writer"
|
||||
}
|
||||
|
||||
if ($EmitHtml) {
|
||||
Write-Output $html
|
||||
}
|
||||
213
installer/runtime-run-attacktracer-ninja-monitor.ps1
Normal file
213
installer/runtime-run-attacktracer-ninja-monitor.ps1
Normal file
@@ -0,0 +1,213 @@
|
||||
param(
|
||||
[int]$LookbackDays = 7,
|
||||
[int]$TopCount = 10,
|
||||
[string]$OutputPath = "..\reports\ocsentinel-summary.json",
|
||||
[string]$ConfigPath = "..\config\ocsentinel-settings.json",
|
||||
[string]$VulnerabilityCsvPath = "",
|
||||
[string]$MirrorRoot = "",
|
||||
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
||||
[string]$Mode = "status"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$runnerScript = Join-Path $scriptDir "run-ocsentinel.ps1"
|
||||
$outputFullPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $OutputPath))
|
||||
|
||||
$script:NinjaFieldBackend = $null
|
||||
$script:NinjaCliPath = "C:\ProgramData\NinjaRMMAgent\ninjarmm-cli.exe"
|
||||
|
||||
function Resolve-PathLike {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PathValue,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$BasePath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PathValue)) {
|
||||
return $PathValue
|
||||
}
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($PathValue) -or $PathValue.StartsWith("\\")) {
|
||||
return [System.IO.Path]::GetFullPath($PathValue)
|
||||
}
|
||||
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
|
||||
}
|
||||
|
||||
function Initialize-NinjaFieldWriter {
|
||||
if ($null -ne $script:NinjaFieldBackend) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||
$script:NinjaFieldBackend = "powershell"
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-Path $script:NinjaCliPath) {
|
||||
$script:NinjaFieldBackend = "cli"
|
||||
return
|
||||
}
|
||||
|
||||
$script:NinjaFieldBackend = "none"
|
||||
}
|
||||
|
||||
function Set-NinjaCustomFieldValue {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
[AllowEmptyString()]
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
Initialize-NinjaFieldWriter
|
||||
|
||||
switch ($script:NinjaFieldBackend) {
|
||||
"powershell" {
|
||||
Ninja-Property-Set $Name $Value | Out-Null
|
||||
return $true
|
||||
}
|
||||
"cli" {
|
||||
& $script:NinjaCliPath set $Name $Value | Out-Null
|
||||
return $LASTEXITCODE -eq 0
|
||||
}
|
||||
default {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-NinjaCustomFields {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[pscustomobject]$Report,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Mode,
|
||||
[Parameter(Mandatory)]
|
||||
[bool]$Triggered,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Reason
|
||||
)
|
||||
|
||||
Initialize-NinjaFieldWriter
|
||||
if ($script:NinjaFieldBackend -eq "none") {
|
||||
Write-Host "Ninja custom fields: skipped (Ninja field writer not available)."
|
||||
return
|
||||
}
|
||||
|
||||
$generatedAtUtc = ""
|
||||
if ($Report.GeneratedAtLocal) {
|
||||
try {
|
||||
$generatedAtUtc = ([DateTimeOffset]$Report.GeneratedAtLocal).ToUniversalTime().ToString("o")
|
||||
}
|
||||
catch {
|
||||
$generatedAtUtc = [string]$Report.GeneratedAtLocal
|
||||
}
|
||||
}
|
||||
|
||||
$fieldValues = [ordered]@{
|
||||
"ocsentinelstatus" = [string]$Report.AlertState
|
||||
"ocsentinelreason" = $Reason
|
||||
"ocsentinelbasestatus" = [string]$Report.BaseAlertState
|
||||
"ocsentinelevents" = [string]([int]$Report.TotalEvents)
|
||||
"ocsentineluniqueips" = [string]([int]$Report.UniqueIpCount)
|
||||
"ocsentinelcvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount)
|
||||
"ocsentinelcvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount)
|
||||
"ocsentinelmode" = $Mode
|
||||
"ocsentineltriggered" = $Triggered.ToString().ToLowerInvariant()
|
||||
"ocsentinellastscanutc" = $generatedAtUtc
|
||||
}
|
||||
|
||||
$updated = 0
|
||||
foreach ($entry in $fieldValues.GetEnumerator()) {
|
||||
try {
|
||||
if (Set-NinjaCustomFieldValue -Name $entry.Key -Value $entry.Value) {
|
||||
$updated++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to set Ninja custom field '$($entry.Key)': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Ninja custom fields: updated $updated field(s) via $script:NinjaFieldBackend."
|
||||
}
|
||||
|
||||
$runnerArgs = @(
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", $runnerScript,
|
||||
"-LookbackDays", $LookbackDays,
|
||||
"-TopCount", $TopCount,
|
||||
"-OutputPath", $OutputPath,
|
||||
"-ConfigPath", $ConfigPath
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) {
|
||||
$runnerArgs += @("-VulnerabilityCsvPath", $VulnerabilityCsvPath)
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($MirrorRoot)) {
|
||||
$runnerArgs += @("-MirrorRoot", (Resolve-PathLike -PathValue $MirrorRoot -BasePath $scriptDir))
|
||||
}
|
||||
|
||||
$null = & powershell @runnerArgs
|
||||
$runnerExitCode = $LASTEXITCODE
|
||||
|
||||
if (-not (Test-Path $outputFullPath)) {
|
||||
throw "Expected report file was not created: $outputFullPath"
|
||||
}
|
||||
|
||||
$report = Get-Content $outputFullPath -Raw | ConvertFrom-Json
|
||||
|
||||
$status = [string]$report.AlertState
|
||||
$baseStatus = [string]$report.BaseAlertState
|
||||
$events = [int]$report.TotalEvents
|
||||
$uniqueIps = [int]$report.UniqueIpCount
|
||||
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
||||
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
||||
|
||||
$monitorTriggered = $false
|
||||
$monitorReason = ""
|
||||
|
||||
switch ($Mode) {
|
||||
"status" {
|
||||
$monitorTriggered = $status -ne "ok"
|
||||
$monitorReason = "Final status is $status. $($report.AlertReason)"
|
||||
}
|
||||
"attack-only" {
|
||||
$monitorTriggered = $baseStatus -ne "ok"
|
||||
$monitorReason = "Base attack status is $baseStatus. $($report.BaseAlertReason)"
|
||||
}
|
||||
"cve-critical" {
|
||||
$monitorTriggered = $criticalCves -gt 0
|
||||
$monitorReason = "Critical/high CVE count is $criticalCves out of total CVEs $totalCves."
|
||||
}
|
||||
"attack-plus-cve" {
|
||||
$monitorTriggered = ($events -gt 0 -and $criticalCves -gt 0)
|
||||
$monitorReason = "Attack events=$events and critical/high CVEs=$criticalCves."
|
||||
}
|
||||
}
|
||||
|
||||
Publish-NinjaCustomFields -Report $report -Mode $Mode -Triggered $monitorTriggered -Reason $monitorReason
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "OfficeCom Sentinel monitor mode: $Mode"
|
||||
Write-Host "Triggered: $monitorTriggered"
|
||||
Write-Host "Reason: $monitorReason"
|
||||
Write-Host "Status: $status"
|
||||
Write-Host "Base status: $baseStatus"
|
||||
Write-Host "Events: $events"
|
||||
Write-Host "Unique IPs: $uniqueIps"
|
||||
Write-Host "Critical/High CVEs: $criticalCves"
|
||||
Write-Host "Total CVEs: $totalCves"
|
||||
Write-Host "Report: $outputFullPath"
|
||||
Write-Host "Runner exit code: $runnerExitCode"
|
||||
|
||||
if ($monitorTriggered) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
exit 0
|
||||
308
installer/runtime-run-attacktracer-ninja-server.ps1
Normal file
308
installer/runtime-run-attacktracer-ninja-server.ps1
Normal file
@@ -0,0 +1,308 @@
|
||||
param(
|
||||
[string]$ConfigPath = "..\config\attacktracer-server-settings.json"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-PathLike {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PathValue,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$BasePath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PathValue)) {
|
||||
return $PathValue
|
||||
}
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($PathValue) -or $PathValue.StartsWith("\\")) {
|
||||
return [System.IO.Path]::GetFullPath($PathValue)
|
||||
}
|
||||
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
|
||||
}
|
||||
|
||||
function Join-Url {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$BaseUrl,
|
||||
[Parameter(Mandatory)][string]$RelativePath
|
||||
)
|
||||
|
||||
$base = $BaseUrl.TrimEnd('/')
|
||||
$relative = $RelativePath.TrimStart('/')
|
||||
return "$base/$relative"
|
||||
}
|
||||
|
||||
function ConvertTo-PlainText {
|
||||
param([Parameter(Mandatory)][string]$EncryptedValue)
|
||||
|
||||
$secure = ConvertTo-SecureString $EncryptedValue
|
||||
$credential = New-Object System.Management.Automation.PSCredential("ignored", $secure)
|
||||
return $credential.GetNetworkCredential().Password
|
||||
}
|
||||
|
||||
function Normalize-NinjaScope {
|
||||
param([AllowEmptyString()][string]$Scope)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Scope)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$tokens = @(
|
||||
$Scope -split '[,\s;]+' |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||
ForEach-Object {
|
||||
switch ($_.Trim().ToLowerInvariant()) {
|
||||
"monitoring" { "monitoring"; break }
|
||||
"uberwachen" { "monitoring"; break }
|
||||
"ueberwachen" { "monitoring"; break }
|
||||
"management" { "management"; break }
|
||||
"verwalten" { "management"; break }
|
||||
"control" { "control"; break }
|
||||
"steuerung" { "control"; break }
|
||||
"offline_access" { "offline_access"; break }
|
||||
default { $_.Trim().ToLowerInvariant() }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return ($tokens | Select-Object -Unique) -join " "
|
||||
}
|
||||
|
||||
function Get-OrganizationStatus {
|
||||
param([pscustomobject[]]$Reports)
|
||||
|
||||
if (@($Reports | Where-Object { $_.AlertState -eq "critical" }).Count -gt 0) {
|
||||
return "critical"
|
||||
}
|
||||
|
||||
if (@($Reports | Where-Object { $_.AlertState -eq "warning" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 }).Count -gt 0) {
|
||||
return "warning"
|
||||
}
|
||||
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function Build-OrganizationSummaryText {
|
||||
param([pscustomobject[]]$Reports)
|
||||
|
||||
$deviceCount = @($Reports).Count
|
||||
$alertingCount = @($Reports | Where-Object { $_.AlertState -ne "ok" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 }).Count
|
||||
$criticalCount = @($Reports | Where-Object { $_.AlertState -eq "critical" }).Count
|
||||
$totalEvents = (@($Reports | Measure-Object -Property TotalEvents -Sum).Sum)
|
||||
$totalEvents = if ($null -eq $totalEvents) { 0 } else { [int]$totalEvents }
|
||||
|
||||
$topSystems = @(
|
||||
$Reports |
|
||||
Where-Object { $_.AlertState -ne "ok" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 } |
|
||||
Sort-Object @{ Expression = { $_.AlertState -eq "critical" }; Descending = $true }, @{ Expression = { [int]$_.TotalEvents }; Descending = $true } |
|
||||
Select-Object -ExpandProperty MachineName -First 5
|
||||
)
|
||||
|
||||
$summary = "$deviceCount Geraete gescannt, $alertingCount auffaellig, $criticalCount kritisch, $totalEvents Events."
|
||||
if ($topSystems.Count -gt 0) {
|
||||
$summary += " Top-Systeme: $($topSystems -join ', ')."
|
||||
}
|
||||
|
||||
return $summary
|
||||
}
|
||||
|
||||
function Get-LatestReports {
|
||||
param([Parameter(Mandatory)][string]$ReportsRootPath)
|
||||
|
||||
$reportFiles = @()
|
||||
if (Test-Path $ReportsRootPath -PathType Leaf) {
|
||||
$reportFiles = @($ReportsRootPath)
|
||||
}
|
||||
elseif (Test-Path $ReportsRootPath -PathType Container) {
|
||||
$reportFiles = @(Get-ChildItem -Path $ReportsRootPath -Recurse -Filter *.json | Select-Object -ExpandProperty FullName)
|
||||
}
|
||||
|
||||
if ($reportFiles.Count -eq 0) {
|
||||
throw "No report JSON files found under $ReportsRootPath"
|
||||
}
|
||||
|
||||
$reports = foreach ($file in $reportFiles) {
|
||||
try {
|
||||
$report = Get-Content $file -Raw | ConvertFrom-Json
|
||||
if ($report.MachineName) {
|
||||
$report
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Skipping invalid report file ${file}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
if (@($reports).Count -eq 0) {
|
||||
throw "No valid AttackTracer report files were parsed."
|
||||
}
|
||||
|
||||
return @(
|
||||
$reports |
|
||||
Group-Object MachineName |
|
||||
ForEach-Object {
|
||||
$_.Group |
|
||||
Sort-Object {
|
||||
try {
|
||||
[DateTimeOffset]::Parse([string]$_.GeneratedAtLocal)
|
||||
}
|
||||
catch {
|
||||
[DateTimeOffset]::MinValue
|
||||
}
|
||||
} -Descending |
|
||||
Select-Object -First 1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function Get-NinjaAccessToken {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$BaseUrl,
|
||||
[Parameter(Mandatory)][string]$ClientId,
|
||||
[Parameter(Mandatory)][string]$ClientSecret,
|
||||
[string]$Scope = ""
|
||||
)
|
||||
|
||||
$body = @{
|
||||
grant_type = "client_credentials"
|
||||
client_id = $ClientId
|
||||
client_secret = $ClientSecret
|
||||
}
|
||||
|
||||
$normalizedScope = Normalize-NinjaScope -Scope $Scope
|
||||
if (-not [string]::IsNullOrWhiteSpace($normalizedScope)) {
|
||||
$body.scope = $normalizedScope
|
||||
}
|
||||
|
||||
$tokenEndpoints = @(
|
||||
(Join-Url -BaseUrl $BaseUrl -RelativePath "ws/oauth/token"),
|
||||
(Join-Url -BaseUrl $BaseUrl -RelativePath "oauth/token")
|
||||
)
|
||||
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($tokenEndpoint in $tokenEndpoints) {
|
||||
try {
|
||||
Write-Host "Requesting NinjaOne OAuth token from $tokenEndpoint"
|
||||
$response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $body -ContentType "application/x-www-form-urlencoded" -TimeoutSec 60
|
||||
if (-not $response.access_token) {
|
||||
throw "OAuth token response did not contain an access_token."
|
||||
}
|
||||
|
||||
Write-Host "NinjaOne OAuth token acquired successfully"
|
||||
return [string]$response.access_token
|
||||
}
|
||||
catch {
|
||||
$message = "Token endpoint $tokenEndpoint failed: " + $_.Exception.Message
|
||||
if ($_.ErrorDetails.Message) {
|
||||
$message += " | " + $_.ErrorDetails.Message
|
||||
}
|
||||
|
||||
$failures.Add($message)
|
||||
}
|
||||
}
|
||||
|
||||
throw "Failed to obtain NinjaOne OAuth token. " + ($failures -join " || ")
|
||||
}
|
||||
|
||||
function Invoke-NinjaOrganizationFieldPatch {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$BaseUrl,
|
||||
[Parameter(Mandatory)][int]$OrganizationId,
|
||||
[Parameter(Mandatory)][string]$AccessToken,
|
||||
[Parameter(Mandatory)][hashtable]$FieldValues
|
||||
)
|
||||
|
||||
$endpoint = (Join-Url -BaseUrl $BaseUrl -RelativePath "v2/organization/$OrganizationId/custom-fields")
|
||||
$headers = @{
|
||||
Authorization = "Bearer $AccessToken"
|
||||
Accept = "application/json"
|
||||
}
|
||||
|
||||
$payloadCandidates = @(
|
||||
$FieldValues,
|
||||
@{ customFields = $FieldValues },
|
||||
@{ fields = @($FieldValues.GetEnumerator() | ForEach-Object { @{ name = $_.Key; value = $_.Value } }) }
|
||||
)
|
||||
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($payload in $payloadCandidates) {
|
||||
try {
|
||||
$json = $payload | ConvertTo-Json -Depth 8
|
||||
$fieldNames = @($FieldValues.Keys) -join ", "
|
||||
Write-Host "Organization custom fields endpoint: $endpoint"
|
||||
Write-Host "Updating NinjaOne organization custom fields: $fieldNames"
|
||||
Write-Host "PATCH payload size: $($json.Length) characters"
|
||||
Invoke-RestMethod -Method Patch -Uri $endpoint -Headers $headers -ContentType "application/json" -Body $json -TimeoutSec 60 | Out-Null
|
||||
Write-Host "NinjaOne organization custom fields updated successfully"
|
||||
return
|
||||
}
|
||||
catch {
|
||||
$message = $_.Exception.Message
|
||||
if ($_.ErrorDetails.Message) {
|
||||
$message += " | " + $_.ErrorDetails.Message
|
||||
}
|
||||
|
||||
$failures.Add($message)
|
||||
}
|
||||
}
|
||||
|
||||
throw "Failed to update NinjaOne organization custom fields. " + ($failures -join " || ")
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$installRoot = Split-Path -Parent $scriptDir
|
||||
$configBasePath = $scriptDir
|
||||
$configFullPath = Resolve-PathLike -PathValue $ConfigPath -BasePath $configBasePath
|
||||
if (-not (Test-Path $configFullPath)) {
|
||||
throw "Server config file not found: $configFullPath"
|
||||
}
|
||||
|
||||
$config = Get-Content $configFullPath -Raw | ConvertFrom-Json
|
||||
$reportsRootPath = Resolve-PathLike -PathValue $config.reportsRoot -BasePath $installRoot
|
||||
$htmlOutputPath = Resolve-PathLike -PathValue $config.htmlOutputPath -BasePath $installRoot
|
||||
$orgReportsScript = Join-Path $scriptDir "build-attacktracer-org-report.ps1"
|
||||
|
||||
& powershell -ExecutionPolicy Bypass -File $orgReportsScript -ReportsRoot $reportsRootPath -OutputPath $htmlOutputPath -MaxAlertRows $config.maxAlertRows
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$latestReports = Get-LatestReports -ReportsRootPath $reportsRootPath
|
||||
$orgStatus = Get-OrganizationStatus -Reports $latestReports
|
||||
$orgSummary = Build-OrganizationSummaryText -Reports $latestReports
|
||||
$orgLastUpdate = (Get-Date).ToString("o")
|
||||
$htmlContent = Get-Content $htmlOutputPath -Raw
|
||||
Write-Host "Organization summary prepared"
|
||||
Write-Host "HTML report size: $($htmlContent.Length) characters"
|
||||
$clientSecret = ConvertTo-PlainText -EncryptedValue ([string]$config.clientSecretEncrypted)
|
||||
$accessToken = Get-NinjaAccessToken -BaseUrl ([string]$config.ninjaBaseUrl) -ClientId ([string]$config.clientId) -ClientSecret $clientSecret -Scope ([string]$config.oauthScope)
|
||||
|
||||
$fieldValues = @{
|
||||
([string]$config.statusFieldName) = $orgStatus
|
||||
([string]$config.summaryFieldName) = $orgSummary
|
||||
([string]$config.lastUpdateFieldName) = $orgLastUpdate
|
||||
}
|
||||
|
||||
Invoke-NinjaOrganizationFieldPatch -BaseUrl ([string]$config.ninjaBaseUrl) -OrganizationId ([int]$config.organizationId) -AccessToken $accessToken -FieldValues $fieldValues
|
||||
|
||||
if ($config.updateHtmlField -and -not [string]::IsNullOrWhiteSpace([string]$config.htmlFieldName)) {
|
||||
$htmlFieldName = [string]$config.htmlFieldName
|
||||
Write-Host "Attempting separate HTML organization field update for '$htmlFieldName'"
|
||||
|
||||
try {
|
||||
Invoke-NinjaOrganizationFieldPatch -BaseUrl ([string]$config.ninjaBaseUrl) -OrganizationId ([int]$config.organizationId) -AccessToken $accessToken -FieldValues @{
|
||||
$htmlFieldName = $htmlContent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "HTML organization field update failed for '$htmlFieldName'. Keeping local HTML report only. $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Organization HTML report written to $htmlOutputPath"
|
||||
Write-Host "Organization custom fields updated via NinjaOne API"
|
||||
Write-Host "Status field: $($config.statusFieldName)=$orgStatus"
|
||||
136
installer/runtime-run-attacktracer-ninja.ps1
Normal file
136
installer/runtime-run-attacktracer-ninja.ps1
Normal file
@@ -0,0 +1,136 @@
|
||||
param(
|
||||
[int]$LookbackDays = 7,
|
||||
[int]$TopCount = 10,
|
||||
[string]$OutputPath = "..\reports\ocsentinel-summary.json",
|
||||
[string]$ConfigPath = "..\config\ocsentinel-settings.json",
|
||||
[string]$ClientConfigPath = "..\config\ocsentinel-client.json",
|
||||
[string]$SecretPath = "",
|
||||
[string]$VulnerabilityCsvPath = "",
|
||||
[string]$MirrorRoot = "",
|
||||
[ValidateSet("disabled", "auto", "required")]
|
||||
[string]$UploadMode = "auto",
|
||||
[switch]$FailOnAttacks,
|
||||
[switch]$FailOnThreshold
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$installRoot = Split-Path -Parent $scriptDir
|
||||
$appExe = Join-Path $installRoot "app\OCSentinelCli.exe"
|
||||
$outputFullPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $OutputPath))
|
||||
|
||||
function Resolve-PathLike {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PathValue,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$BasePath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PathValue)) {
|
||||
return $PathValue
|
||||
}
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($PathValue) -or $PathValue.StartsWith("\\")) {
|
||||
return [System.IO.Path]::GetFullPath($PathValue)
|
||||
}
|
||||
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
|
||||
}
|
||||
|
||||
if (-not (Test-Path $appExe)) {
|
||||
throw "Application executable not found: $appExe"
|
||||
}
|
||||
|
||||
$arguments = @()
|
||||
|
||||
$configFullPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $ConfigPath))
|
||||
if ($UploadMode -eq "disabled") {
|
||||
$arguments += "scan"
|
||||
}
|
||||
else {
|
||||
$clientConfigFullPath = Resolve-PathLike -PathValue $ClientConfigPath -BasePath $scriptDir
|
||||
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $scriptDir }
|
||||
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
|
||||
|
||||
if ($UploadMode -eq "required" -and -not $canUpload) {
|
||||
throw "UploadMode 'required' was set, but client config or protected secret is missing."
|
||||
}
|
||||
|
||||
if ($canUpload) {
|
||||
$arguments += "scan-and-upload"
|
||||
$arguments += @("--client-config", $clientConfigFullPath, "--secret-path", $secretFullPath)
|
||||
}
|
||||
else {
|
||||
$arguments += "scan"
|
||||
}
|
||||
}
|
||||
|
||||
$arguments += @(
|
||||
"--lookback-days", $LookbackDays,
|
||||
"--top", $TopCount,
|
||||
"--output", $outputFullPath,
|
||||
"--ninja-output"
|
||||
)
|
||||
|
||||
if (Test-Path $configFullPath) {
|
||||
$arguments += @("--config", $configFullPath)
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) {
|
||||
$vulnerabilityCsvFullPath = Resolve-PathLike -PathValue $VulnerabilityCsvPath -BasePath $scriptDir
|
||||
if (Test-Path $vulnerabilityCsvFullPath) {
|
||||
$arguments += @("--vulnerability-csv", $vulnerabilityCsvFullPath)
|
||||
}
|
||||
}
|
||||
|
||||
if ($FailOnAttacks) {
|
||||
$arguments += "--fail-on-attacks"
|
||||
}
|
||||
|
||||
if ($FailOnThreshold) {
|
||||
$arguments += "--fail-on-threshold"
|
||||
}
|
||||
|
||||
& $appExe @arguments
|
||||
$exitCode = $LASTEXITCODE
|
||||
|
||||
if (-not (Test-Path $outputFullPath)) {
|
||||
throw "Expected report file was not created: $outputFullPath"
|
||||
}
|
||||
|
||||
$report = Get-Content $outputFullPath -Raw | ConvertFrom-Json
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($MirrorRoot)) {
|
||||
Write-Host "Legacy mirror mode enabled."
|
||||
$mirrorRootPath = Resolve-PathLike -PathValue $MirrorRoot -BasePath $scriptDir
|
||||
if (-not (Test-Path $mirrorRootPath)) {
|
||||
New-Item -ItemType Directory -Force -Path $mirrorRootPath | Out-Null
|
||||
}
|
||||
$mirrorPath = Join-Path $mirrorRootPath "$($report.MachineName).json"
|
||||
Copy-Item -Path $outputFullPath -Destination $mirrorPath -Force
|
||||
Write-Host "Mirrored report: $mirrorPath"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "OfficeCom Sentinel runner summary"
|
||||
Write-Host "Machine: $($report.MachineName)"
|
||||
Write-Host "Events: $($report.TotalEvents)"
|
||||
Write-Host "Unique IPs: $($report.UniqueIpCount)"
|
||||
Write-Host "Status: $($report.AlertState)"
|
||||
Write-Host "Reason: $($report.AlertReason)"
|
||||
Write-Host "Base status: $($report.BaseAlertState)"
|
||||
Write-Host "CVE findings: $($report.VulnerabilityCorrelation.TotalCount)"
|
||||
Write-Host "Critical/High CVEs: $($report.VulnerabilityCorrelation.CriticalCount)"
|
||||
Write-Host "Upload mode: $UploadMode"
|
||||
Write-Host "Report: $outputFullPath"
|
||||
|
||||
if ($report.Errors.Count -gt 0) {
|
||||
Write-Host "Warnings:"
|
||||
foreach ($warningEntry in $report.Errors) {
|
||||
Write-Host "- $warningEntry"
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
113
installer/server-install-attacktracer-ninja-server.ps1
Normal file
113
installer/server-install-attacktracer-ninja-server.ps1
Normal file
@@ -0,0 +1,113 @@
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Read-DefaultValue {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Prompt,
|
||||
[string]$DefaultValue = ""
|
||||
)
|
||||
|
||||
$suffix = if ([string]::IsNullOrWhiteSpace($DefaultValue)) { "" } else { " [$DefaultValue]" }
|
||||
$value = Read-Host "$Prompt$suffix"
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
return $DefaultValue
|
||||
}
|
||||
|
||||
return $value
|
||||
}
|
||||
|
||||
function Read-YesNo {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Prompt,
|
||||
[bool]$DefaultValue = $false
|
||||
)
|
||||
|
||||
$defaultText = if ($DefaultValue) { "Y/n" } else { "y/N" }
|
||||
$value = Read-Host "$Prompt [$defaultText]"
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
return $DefaultValue
|
||||
}
|
||||
|
||||
return $value.Trim().StartsWith("y", [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
$packageRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$installRoot = Join-Path ${env:ProgramFiles} "AttackTracerNinjaServer"
|
||||
$configRoot = Join-Path $installRoot "config"
|
||||
$scriptRoot = Join-Path $installRoot "scripts"
|
||||
$versionFile = Join-Path $packageRoot "VERSION.txt"
|
||||
$version = if (Test-Path $versionFile) { (Get-Content $versionFile -Raw).Trim() } else { "1.0.0" }
|
||||
|
||||
Write-Host "Installing AttackTracerNinjaServer $version to $installRoot"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $configRoot, $scriptRoot | Out-Null
|
||||
|
||||
Copy-Item -Path (Join-Path $packageRoot "build-attacktracer-org-report.ps1") -Destination $scriptRoot -Force
|
||||
Copy-Item -Path (Join-Path $packageRoot "run-attacktracer-ninja-server.ps1") -Destination $scriptRoot -Force
|
||||
Copy-Item -Path (Join-Path $packageRoot "uninstall-attacktracer-ninja-server.ps1") -Destination $scriptRoot -Force
|
||||
Copy-Item -Path (Join-Path $packageRoot "attacktracer-server-settings.example.json") -Destination (Join-Path $configRoot "attacktracer-server-settings.example.json") -Force
|
||||
|
||||
$configPath = Join-Path $configRoot "attacktracer-server-settings.json"
|
||||
$examplePath = Join-Path $configRoot "attacktracer-server-settings.example.json"
|
||||
$existing = if (Test-Path $configPath) { Get-Content $configPath -Raw | ConvertFrom-Json } else { Get-Content $examplePath -Raw | ConvertFrom-Json }
|
||||
|
||||
$reportsRoot = Read-DefaultValue -Prompt "ReportsRoot share path" -DefaultValue ([string]$existing.reportsRoot)
|
||||
$htmlOutputPath = Read-DefaultValue -Prompt "HTML output path" -DefaultValue ([string]$existing.htmlOutputPath)
|
||||
$ninjaBaseUrl = Read-DefaultValue -Prompt "Ninja base URL" -DefaultValue ([string]$existing.ninjaBaseUrl)
|
||||
$organizationId = Read-DefaultValue -Prompt "Ninja organization ID" -DefaultValue ([string]$existing.organizationId)
|
||||
$clientId = Read-DefaultValue -Prompt "Ninja OAuth Client ID" -DefaultValue ([string]$existing.clientId)
|
||||
$oauthScope = Read-DefaultValue -Prompt "Ninja OAuth scope" -DefaultValue ([string]$existing.oauthScope)
|
||||
$secretPrompt = Read-Host "Ninja OAuth Client Secret (leave empty to keep existing)" -AsSecureString
|
||||
|
||||
$clientSecretEncrypted = [string]$existing.clientSecretEncrypted
|
||||
if ($secretPrompt.Length -gt 0) {
|
||||
$clientSecretEncrypted = ConvertFrom-SecureString $secretPrompt
|
||||
}
|
||||
|
||||
$updateHtmlField = Read-YesNo -Prompt "Also update attacktracerorgreport via API" -DefaultValue ([bool]$existing.updateHtmlField)
|
||||
$htmlFieldName = Read-DefaultValue -Prompt "HTML field name" -DefaultValue ([string]$existing.htmlFieldName)
|
||||
$statusFieldName = Read-DefaultValue -Prompt "Status field name" -DefaultValue ([string]$existing.statusFieldName)
|
||||
$summaryFieldName = Read-DefaultValue -Prompt "Summary field name" -DefaultValue ([string]$existing.summaryFieldName)
|
||||
$lastUpdateFieldName = Read-DefaultValue -Prompt "Last update field name" -DefaultValue ([string]$existing.lastUpdateFieldName)
|
||||
$maxAlertRows = [int](Read-DefaultValue -Prompt "Max alert rows in HTML" -DefaultValue ([string]$existing.maxAlertRows))
|
||||
|
||||
$config = [ordered]@{
|
||||
ninjaBaseUrl = $ninjaBaseUrl
|
||||
organizationId = [int]$organizationId
|
||||
clientId = $clientId
|
||||
clientSecretEncrypted = $clientSecretEncrypted
|
||||
oauthScope = $oauthScope
|
||||
reportsRoot = $reportsRoot
|
||||
htmlOutputPath = $htmlOutputPath
|
||||
statusFieldName = $statusFieldName
|
||||
summaryFieldName = $summaryFieldName
|
||||
lastUpdateFieldName = $lastUpdateFieldName
|
||||
htmlFieldName = $htmlFieldName
|
||||
updateHtmlField = $updateHtmlField
|
||||
maxAlertRows = $maxAlertRows
|
||||
}
|
||||
|
||||
$config | ConvertTo-Json -Depth 6 | Set-Content -Path $configPath -Encoding UTF8
|
||||
|
||||
$uninstallScript = Join-Path $scriptRoot "uninstall-attacktracer-ninja-server.ps1"
|
||||
$uninstallCommand = "powershell.exe -ExecutionPolicy Bypass -File `"$uninstallScript`""
|
||||
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\AttackTracerNinjaServer"
|
||||
|
||||
if (-not (Test-Path $uninstallKey)) {
|
||||
New-Item -Path $uninstallKey -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-ItemProperty -Path $uninstallKey -Name "DisplayName" -Value "AttackTracerNinjaServer"
|
||||
Set-ItemProperty -Path $uninstallKey -Name "DisplayVersion" -Value $version
|
||||
Set-ItemProperty -Path $uninstallKey -Name "Publisher" -Value "AttackTracerNinja"
|
||||
Set-ItemProperty -Path $uninstallKey -Name "InstallLocation" -Value $installRoot
|
||||
Set-ItemProperty -Path $uninstallKey -Name "UninstallString" -Value $uninstallCommand
|
||||
Set-ItemProperty -Path $uninstallKey -Name "QuietUninstallString" -Value $uninstallCommand
|
||||
Set-ItemProperty -Path $uninstallKey -Name "NoModify" -Value 1 -Type DWord
|
||||
Set-ItemProperty -Path $uninstallKey -Name "NoRepair" -Value 1 -Type DWord
|
||||
|
||||
Write-Host "Installation complete."
|
||||
Write-Host "Main path: $installRoot"
|
||||
Write-Host "Server config:$configPath"
|
||||
Write-Host "Server runner:$(Join-Path $scriptRoot 'run-attacktracer-ninja-server.ps1')"
|
||||
16
installer/server-uninstall-attacktracer-ninja-server.ps1
Normal file
16
installer/server-uninstall-attacktracer-ninja-server.ps1
Normal file
@@ -0,0 +1,16 @@
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$installRoot = Join-Path ${env:ProgramFiles} "AttackTracerNinjaServer"
|
||||
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\AttackTracerNinjaServer"
|
||||
|
||||
if (Test-Path $uninstallKey) {
|
||||
Remove-Item -Path $uninstallKey -Force -Recurse
|
||||
}
|
||||
|
||||
if (Test-Path $installRoot) {
|
||||
Remove-Item -LiteralPath $installRoot -Force -Recurse
|
||||
}
|
||||
|
||||
Write-Host "AttackTracerNinjaServer removed from $installRoot"
|
||||
16
installer/uninstall-attacktracer-ninja.ps1
Normal file
16
installer/uninstall-attacktracer-ninja.ps1
Normal file
@@ -0,0 +1,16 @@
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
||||
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OCSentinel"
|
||||
|
||||
if (Test-Path $uninstallKey) {
|
||||
Remove-Item -Path $uninstallKey -Force -Recurse
|
||||
}
|
||||
|
||||
if (Test-Path $installRoot) {
|
||||
Remove-Item -LiteralPath $installRoot -Force -Recurse
|
||||
}
|
||||
|
||||
Write-Host "OfficeCom Sentinel removed from $installRoot"
|
||||
131
installer/update-attacktracer-ninja.ps1
Normal file
131
installer/update-attacktracer-ninja.ps1
Normal file
@@ -0,0 +1,131 @@
|
||||
param(
|
||||
[string]$ManifestUrl = "",
|
||||
[string]$Channel = "stable",
|
||||
[string]$TempRoot = "$env:TEMP\OCSentinelUpdate",
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
||||
$appExe = Join-Path $installRoot "app\OCSentinelCli.exe"
|
||||
$installScript = Join-Path $installRoot "scripts\install-ocsentinel.ps1"
|
||||
$versionFile = Join-Path $installRoot "VERSION.txt"
|
||||
|
||||
function Get-InstalledVersion {
|
||||
if (Test-Path $versionFile) {
|
||||
return (Get-Content $versionFile -Raw).Trim()
|
||||
}
|
||||
|
||||
if (Test-Path $appExe) {
|
||||
return (Get-Item $appExe).VersionInfo.ProductVersion
|
||||
}
|
||||
|
||||
return "0.0.0"
|
||||
}
|
||||
|
||||
function Compare-Version {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Left,
|
||||
[Parameter(Mandatory)][string]$Right
|
||||
)
|
||||
|
||||
try {
|
||||
$leftVersion = [System.Version]$Left
|
||||
$rightVersion = [System.Version]$Right
|
||||
return $leftVersion.CompareTo($rightVersion)
|
||||
}
|
||||
catch {
|
||||
return [string]::Compare($Left, $Right, $true)
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Sha256Hex {
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
|
||||
return (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Resolve-ManifestUrl {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ManifestUrl,
|
||||
[Parameter(Mandatory)][string]$Channel
|
||||
)
|
||||
|
||||
if ($ManifestUrl -match '\.json($|\?)') {
|
||||
return $ManifestUrl
|
||||
}
|
||||
|
||||
return ($ManifestUrl.TrimEnd('/') + "/$Channel/version.json")
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
|
||||
throw "ManifestUrl is required."
|
||||
}
|
||||
|
||||
$resolvedManifestUrl = Resolve-ManifestUrl -ManifestUrl $ManifestUrl -Channel $Channel
|
||||
Write-Host "Checking update manifest: $resolvedManifestUrl"
|
||||
|
||||
$manifest = Invoke-RestMethod -Method Get -Uri $resolvedManifestUrl -TimeoutSec 60
|
||||
if (-not $manifest.version -or -not $manifest.artifactUrl -or -not $manifest.sha256) {
|
||||
throw "Update manifest is missing required fields: version, artifactUrl, sha256."
|
||||
}
|
||||
|
||||
$installedVersion = Get-InstalledVersion
|
||||
$availableVersion = [string]$manifest.version
|
||||
|
||||
Write-Host "Installed version: $installedVersion"
|
||||
Write-Host "Available version: $availableVersion"
|
||||
|
||||
if (-not $Force -and (Compare-Version -Left $installedVersion -Right $availableVersion) -ge 0) {
|
||||
Write-Host "OfficeCom Sentinel is already up to date."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$downloadRoot = Join-Path $TempRoot ([Guid]::NewGuid().ToString("N"))
|
||||
$zipPath = Join-Path $downloadRoot "OCSentinelClient.zip"
|
||||
$extractRoot = Join-Path $downloadRoot "payload"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $downloadRoot, $extractRoot | Out-Null
|
||||
|
||||
Write-Host "Downloading artifact: $($manifest.artifactUrl)"
|
||||
Invoke-WebRequest -Uri ([string]$manifest.artifactUrl) -OutFile $zipPath -TimeoutSec 300
|
||||
|
||||
$actualHash = Get-Sha256Hex -Path $zipPath
|
||||
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
throw "SHA-256 mismatch for downloaded artifact. Expected $expectedHash but got $actualHash."
|
||||
}
|
||||
|
||||
Write-Host "Artifact hash verified"
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
|
||||
|
||||
$payloadAppExe = Get-ChildItem -Path $extractRoot -Recurse -Filter "OCSentinelCli.exe" | Select-Object -First 1
|
||||
if ($null -eq $payloadAppExe) {
|
||||
throw "Downloaded payload did not contain OCSentinelCli.exe"
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature -FilePath $payloadAppExe.FullName
|
||||
if ($signature.Status -notin @("Valid", "NotSigned")) {
|
||||
throw "Executable signature validation failed with status: $($signature.Status)"
|
||||
}
|
||||
|
||||
if ($signature.Status -eq "NotSigned") {
|
||||
Write-Warning "Downloaded executable is not code-signed yet. Hash validation succeeded, but code signing should be added before production rollout."
|
||||
}
|
||||
else {
|
||||
Write-Host "Executable signature verified: $($signature.SignerCertificate.Subject)"
|
||||
}
|
||||
|
||||
$payloadInstallScript = Get-ChildItem -Path $extractRoot -Recurse -Filter "install-ocsentinel.ps1" | Select-Object -First 1
|
||||
if ($null -eq $payloadInstallScript) {
|
||||
throw "Downloaded payload did not contain install-ocsentinel.ps1"
|
||||
}
|
||||
|
||||
Write-Host "Installing OfficeCom Sentinel $availableVersion"
|
||||
& powershell.exe -ExecutionPolicy Bypass -File $payloadInstallScript.FullName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Installer exited with code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "Update complete: $installedVersion -> $availableVersion"
|
||||
Reference in New Issue
Block a user