From 7cdc0395c4ed294abc93dd4e131ed7510bf51687 Mon Sep 17 00:00:00 2001 From: OfficeCom Codex Date: Fri, 17 Jul 2026 00:39:28 +0200 Subject: [PATCH] Initial OfficeCom Sentinel client and deployment assets --- .gitignore | 15 + build/build-client-package.ps1 | 74 +++ build/build-release-manifest.ps1 | 47 ++ config/attacktracer-client.example.json | 12 + .../attacktracer-server-settings.example.json | 15 + config/attacktracer-settings.example.json | 20 + config/update-channel.example.json | 8 + docs/attacktracer-analysis.md | 74 +++ docs/attacktracer-ninja-cli.md | 102 ++++ docs/attacktracer-ninja-server.md | 82 +++ docs/attacktracer-ninja-v2-architecture.md | 469 ++++++++++++++++ docs/attacktracer-ninja-v2-deployment.md | 103 ++++ docs/attacktracer-ninja-v2-n8n-contract.md | 131 +++++ docs/attacktracer-ninja-v2-repo-plan.md | 523 ++++++++++++++++++ docs/ninjaone-monitoring-playbook.md | 136 +++++ docs/ninjaone-org-report-playbook.md | 107 ++++ .../AttackTracerNinjaBootstrapper.csproj | 16 + .../AttackTracerNinjaBootstrapper/Program.cs | 71 +++ ...AttackTracerNinjaServerBootstrapper.csproj | 18 + .../Program.cs | 70 +++ installer/README.txt | 14 + installer/install-attacktracer-ninja.ps1 | 77 +++ installer/launch-install.cmd | 3 + .../runtime-build-attacktracer-org-report.ps1 | 307 ++++++++++ ...runtime-run-attacktracer-ninja-monitor.ps1 | 213 +++++++ .../runtime-run-attacktracer-ninja-server.ps1 | 308 +++++++++++ installer/runtime-run-attacktracer-ninja.ps1 | 136 +++++ ...rver-install-attacktracer-ninja-server.ps1 | 113 ++++ ...er-uninstall-attacktracer-ninja-server.ps1 | 16 + installer/uninstall-attacktracer-ninja.ps1 | 16 + installer/update-attacktracer-ninja.ps1 | 131 +++++ .../ninja-vulnerability-export.example.csv | 4 + samples/webhook-payload.example.json | 29 + scripts/build-attacktracer-installer.ps1 | 99 ++++ scripts/build-attacktracer-org-report.ps1 | 306 ++++++++++ .../build-attacktracer-server-installer.ps1 | 55 ++ scripts/extract-attacktracer.ps1 | 72 +++ scripts/protect-attacktracer-secret.ps1 | 25 + scripts/run-attacktracer-ninja-monitor.ps1 | 213 +++++++ scripts/run-attacktracer-ninja-server.ps1 | 307 ++++++++++ scripts/run-attacktracer-ninja.ps1 | 144 +++++ src/AttackTracerNinjaCli/AttackScanner.cs | 477 ++++++++++++++++ .../AttackTracerNinjaCli.csproj | 23 + src/AttackTracerNinjaCli/BuildMetadata.cs | 11 + .../Commands/ScanAndUploadCommand.cs | 41 ++ .../Commands/ScanCommand.cs | 138 +++++ .../Commands/UploadCommand.cs | 100 ++++ .../Commands/VersionCommand.cs | 10 + src/AttackTracerNinjaCli/Configuration.cs | 32 ++ .../Configuration/ClientConfiguration.cs | 34 ++ src/AttackTracerNinjaCli/JsonOptions.cs | 13 + src/AttackTracerNinjaCli/Models.cs | 177 ++++++ .../Models/UploadResult.cs | 14 + src/AttackTracerNinjaCli/Program.cs | 37 ++ src/AttackTracerNinjaCli/ScanOptions.cs | 113 ++++ .../Security/ProtectedSecretStore.cs | 28 + .../Transport/N8nUploadClient.cs | 70 +++ .../VulnerabilityCorrelation.cs | 200 +++++++ 58 files changed, 6199 insertions(+) create mode 100644 .gitignore create mode 100644 build/build-client-package.ps1 create mode 100644 build/build-release-manifest.ps1 create mode 100644 config/attacktracer-client.example.json create mode 100644 config/attacktracer-server-settings.example.json create mode 100644 config/attacktracer-settings.example.json create mode 100644 config/update-channel.example.json create mode 100644 docs/attacktracer-analysis.md create mode 100644 docs/attacktracer-ninja-cli.md create mode 100644 docs/attacktracer-ninja-server.md create mode 100644 docs/attacktracer-ninja-v2-architecture.md create mode 100644 docs/attacktracer-ninja-v2-deployment.md create mode 100644 docs/attacktracer-ninja-v2-n8n-contract.md create mode 100644 docs/attacktracer-ninja-v2-repo-plan.md create mode 100644 docs/ninjaone-monitoring-playbook.md create mode 100644 docs/ninjaone-org-report-playbook.md create mode 100644 installer/AttackTracerNinjaBootstrapper/AttackTracerNinjaBootstrapper.csproj create mode 100644 installer/AttackTracerNinjaBootstrapper/Program.cs create mode 100644 installer/AttackTracerNinjaServerBootstrapper/AttackTracerNinjaServerBootstrapper.csproj create mode 100644 installer/AttackTracerNinjaServerBootstrapper/Program.cs create mode 100644 installer/README.txt create mode 100644 installer/install-attacktracer-ninja.ps1 create mode 100644 installer/launch-install.cmd create mode 100644 installer/runtime-build-attacktracer-org-report.ps1 create mode 100644 installer/runtime-run-attacktracer-ninja-monitor.ps1 create mode 100644 installer/runtime-run-attacktracer-ninja-server.ps1 create mode 100644 installer/runtime-run-attacktracer-ninja.ps1 create mode 100644 installer/server-install-attacktracer-ninja-server.ps1 create mode 100644 installer/server-uninstall-attacktracer-ninja-server.ps1 create mode 100644 installer/uninstall-attacktracer-ninja.ps1 create mode 100644 installer/update-attacktracer-ninja.ps1 create mode 100644 samples/ninja-vulnerability-export.example.csv create mode 100644 samples/webhook-payload.example.json create mode 100644 scripts/build-attacktracer-installer.ps1 create mode 100644 scripts/build-attacktracer-org-report.ps1 create mode 100644 scripts/build-attacktracer-server-installer.ps1 create mode 100644 scripts/extract-attacktracer.ps1 create mode 100644 scripts/protect-attacktracer-secret.ps1 create mode 100644 scripts/run-attacktracer-ninja-monitor.ps1 create mode 100644 scripts/run-attacktracer-ninja-server.ps1 create mode 100644 scripts/run-attacktracer-ninja.ps1 create mode 100644 src/AttackTracerNinjaCli/AttackScanner.cs create mode 100644 src/AttackTracerNinjaCli/AttackTracerNinjaCli.csproj create mode 100644 src/AttackTracerNinjaCli/BuildMetadata.cs create mode 100644 src/AttackTracerNinjaCli/Commands/ScanAndUploadCommand.cs create mode 100644 src/AttackTracerNinjaCli/Commands/ScanCommand.cs create mode 100644 src/AttackTracerNinjaCli/Commands/UploadCommand.cs create mode 100644 src/AttackTracerNinjaCli/Commands/VersionCommand.cs create mode 100644 src/AttackTracerNinjaCli/Configuration.cs create mode 100644 src/AttackTracerNinjaCli/Configuration/ClientConfiguration.cs create mode 100644 src/AttackTracerNinjaCli/JsonOptions.cs create mode 100644 src/AttackTracerNinjaCli/Models.cs create mode 100644 src/AttackTracerNinjaCli/Models/UploadResult.cs create mode 100644 src/AttackTracerNinjaCli/Program.cs create mode 100644 src/AttackTracerNinjaCli/ScanOptions.cs create mode 100644 src/AttackTracerNinjaCli/Security/ProtectedSecretStore.cs create mode 100644 src/AttackTracerNinjaCli/Transport/N8nUploadClient.cs create mode 100644 src/AttackTracerNinjaCli/VulnerabilityCorrelation.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..659eb11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +bin/ +obj/ +artifacts/ +reports/ +_extracted/ +_tools/ +payload/ +*.user +*.suo +*.log +*.zip +*.sha256 +SetupAttackTracer.exe +decompiled/ +msi-admin/ diff --git a/build/build-client-package.ps1 b/build/build-client-package.ps1 new file mode 100644 index 0000000..b899ea2 --- /dev/null +++ b/build/build-client-package.ps1 @@ -0,0 +1,74 @@ +param( + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\AttackTracerNinjaCli\AttackTracerNinjaCli.csproj" +$installerRoot = Join-Path $repoRoot "installer" +$artifactsRoot = Join-Path $repoRoot "artifacts" +$publishRoot = Join-Path $artifactsRoot "publish\win-x64" +$packageRoot = Join-Path $artifactsRoot "client-package" +$zipPath = Join-Path $artifactsRoot "OCSentinelClient-win-x64.zip" + +[xml]$projectXml = Get-Content $projectPath +$version = $projectXml.Project.PropertyGroup.Version | Select-Object -First 1 +if ([string]::IsNullOrWhiteSpace($version)) { + $version = "0.0.0" +} + +Write-Host "Building OfficeCom Sentinel client package version $version" + +if (Test-Path $publishRoot) { Remove-Item -LiteralPath $publishRoot -Recurse -Force } +if (Test-Path $packageRoot) { Remove-Item -LiteralPath $packageRoot -Recurse -Force } +if (Test-Path $zipPath) { Remove-Item -LiteralPath $zipPath -Force } + +New-Item -ItemType Directory -Force -Path $publishRoot, $packageRoot | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot "app"), (Join-Path $packageRoot "scripts"), (Join-Path $packageRoot "config"), (Join-Path $packageRoot "samples") | Out-Null + +& dotnet restore $projectPath -r win-x64 +if ($LASTEXITCODE -ne 0) { + throw "dotnet restore failed" +} + +& dotnet publish $projectPath ` + -c $Configuration ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -o $publishRoot + +if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed" +} + +Copy-Item -Path (Join-Path $publishRoot "OCSentinelCli.exe") -Destination (Join-Path $packageRoot "app\OCSentinelCli.exe") -Force +if (Test-Path (Join-Path $publishRoot "OCSentinelCli.pdb")) { + Copy-Item -Path (Join-Path $publishRoot "OCSentinelCli.pdb") -Destination (Join-Path $packageRoot "app\OCSentinelCli.pdb") -Force +} + +Copy-Item -Path (Join-Path $installerRoot "install-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "scripts\install-ocsentinel.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "uninstall-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "scripts\uninstall-ocsentinel.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "update-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "scripts\update-ocsentinel.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "runtime-run-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "runtime-run-attacktracer-ninja-monitor.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Force +Copy-Item -Path (Join-Path $repoRoot "scripts\protect-attacktracer-secret.ps1") -Destination (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Force + +Copy-Item -Path (Join-Path $repoRoot "config\attacktracer-settings.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-settings.example.json") -Force +Copy-Item -Path (Join-Path $repoRoot "config\attacktracer-client.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-client.example.json") -Force +Copy-Item -Path (Join-Path $repoRoot "config\update-channel.example.json") -Destination (Join-Path $packageRoot "config\update-channel.example.json") -Force +Copy-Item -Path (Join-Path $repoRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Force +Copy-Item -Path (Join-Path $repoRoot "samples\webhook-payload.example.json") -Destination (Join-Path $packageRoot "samples\webhook-payload.example.json") -Force +Copy-Item -Path (Join-Path $installerRoot "README.txt") -Destination (Join-Path $packageRoot "README.txt") -Force + +Set-Content -Path (Join-Path $packageRoot "VERSION.txt") -Value $version -NoNewline + +Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $zipPath -CompressionLevel Optimal -Force + +$sha256 = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() +Set-Content -Path ($zipPath + ".sha256") -Value "$sha256 $(Split-Path -Leaf $zipPath)" -NoNewline + +Write-Host "OfficeCom Sentinel client package created at $zipPath" +Write-Host "SHA256: $sha256" diff --git a/build/build-release-manifest.ps1 b/build/build-release-manifest.ps1 new file mode 100644 index 0000000..207624a --- /dev/null +++ b/build/build-release-manifest.ps1 @@ -0,0 +1,47 @@ +param( + [Parameter(Mandatory)] + [string]$ArtifactUrl, + [string]$Channel = "stable", + [string]$PackagePath = ".\artifacts\OCSentinelClient-win-x64.zip", + [string]$OutputPath = ".\artifacts\version.json", + [string]$MinUpdaterVersion = "1.0.0" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\AttackTracerNinjaCli\AttackTracerNinjaCli.csproj" +$packageFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $PackagePath)) +$outputFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputPath)) + +if (-not (Test-Path $packageFullPath)) { + throw "Package not found: $packageFullPath" +} + +[xml]$projectXml = Get-Content $projectPath +$version = $projectXml.Project.PropertyGroup.Version | Select-Object -First 1 +if ([string]::IsNullOrWhiteSpace($version)) { + $version = "0.0.0" +} + +$sha256 = (Get-FileHash -Path $packageFullPath -Algorithm SHA256).Hash.ToLowerInvariant() + +$manifest = [ordered]@{ + channel = $Channel + version = $version + publishedAtUtc = (Get-Date).ToUniversalTime().ToString("o") + artifactUrl = $ArtifactUrl + sha256 = $sha256 + minUpdaterVersion = $MinUpdaterVersion +} + +$outputDirectory = Split-Path -Parent $outputFullPath +if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +$manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $outputFullPath -Encoding UTF8 + +Write-Host "Release manifest written to $outputFullPath" +Write-Host "Version: $version" +Write-Host "SHA256: $sha256" diff --git a/config/attacktracer-client.example.json b/config/attacktracer-client.example.json new file mode 100644 index 0000000..7aea9b5 --- /dev/null +++ b/config/attacktracer-client.example.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": "2.0", + "environment": "production", + "lookbackDays": 7, + "topFindings": 10, + "n8nWebhookUrl": "https://n8n.example.com/webhook/ocsentinel-ingest", + "deviceIdentifierMode": "machineName", + "uploadTimeoutSeconds": 30, + "enableVulnerabilityCorrelation": true, + "vulnerabilityCsvPath": "", + "secretReference": "device-default" +} diff --git a/config/attacktracer-server-settings.example.json b/config/attacktracer-server-settings.example.json new file mode 100644 index 0000000..e2060a2 --- /dev/null +++ b/config/attacktracer-server-settings.example.json @@ -0,0 +1,15 @@ +{ + "ninjaBaseUrl": "https://app.ninjarmm.com", + "organizationId": 0, + "clientId": "", + "clientSecretEncrypted": "", + "oauthScope": "monitoring management", + "reportsRoot": "\\\\wsus2\\ATNShare$", + "htmlOutputPath": "\\\\wsus2\\ATNShare$\\attacktracer-org-report.html", + "statusFieldName": "attacktracerorgstatus", + "summaryFieldName": "attacktracerorgsummary", + "lastUpdateFieldName": "attacktracerorglastupdate", + "htmlFieldName": "attacktracerorgreport", + "updateHtmlField": false, + "maxAlertRows": 25 +} diff --git a/config/attacktracer-settings.example.json b/config/attacktracer-settings.example.json new file mode 100644 index 0000000..07aac12 --- /dev/null +++ b/config/attacktracer-settings.example.json @@ -0,0 +1,20 @@ +{ + "warningEventThreshold": 1, + "criticalEventThreshold": 20, + "warningUniqueIpThreshold": 1, + "criticalUniqueIpThreshold": 10, + "correlationWarningCveThreshold": 1, + "correlationCriticalCveThreshold": 1, + "ftpRoots": [ + "C:\\inetpub\\logs\\LogFiles", + "D:\\inetpub\\logs\\LogFiles" + ], + "fileZillaRoots": [ + "C:\\Program Files (x86)\\FileZilla Server\\Logs", + "D:\\Program Files (x86)\\FileZilla Server\\Logs" + ], + "excludedIps": [ + "127.0.0.1", + "::1" + ] +} diff --git a/config/update-channel.example.json b/config/update-channel.example.json new file mode 100644 index 0000000..3d99132 --- /dev/null +++ b/config/update-channel.example.json @@ -0,0 +1,8 @@ +{ + "channel": "stable", + "version": "2.0.0", + "publishedAtUtc": "2026-07-16T18:00:00Z", + "artifactUrl": "https://gitlab.example.com/group/project/-/releases/v2.0.0/downloads/OCSentinelClient-win-x64.zip", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "minUpdaterVersion": "1.0.0" +} diff --git a/docs/attacktracer-analysis.md b/docs/attacktracer-analysis.md new file mode 100644 index 0000000..c9e8d88 --- /dev/null +++ b/docs/attacktracer-analysis.md @@ -0,0 +1,74 @@ +# AttackTracer Reverse Engineering Notes + +## What the legacy app does + +`AttackTracer` is a .NET Framework 4.5 WinForms application installed under `Servolutions\BotFence`. + +Its main behavior is: + +- scan Windows Event Logs for failed logins + - Security `4625` for Windows logons + - Application `18456` for SQL Server login failures + - Application `1035` for Exchange-related login failures +- scan IIS/FTP logs under `C:\inetpub\logs\LogFiles` and `D:\inetpub\logs\LogFiles` +- scan FileZilla Server logs under both `C:` and `D:` +- aggregate attacks by source IP +- geolocate IPs through `http://www.autotimezone.net/atzonline/atz.svc` +- render a top-10 result list and an HTML report +- optionally email the report through `http://pulse.serverpulse.com/PulseCounter/notifications.svc` + +## Important weaknesses in the old design + +- UI-first architecture + - the app is a desktop WinForms tool instead of a background service or scheduled automation +- hard-coded paths + - it only checks a few fixed IIS and FileZilla folders +- legacy external dependencies + - geolocation and email both depend on old unauthenticated HTTP WCF endpoints +- weak integration model + - results are shown locally or emailed, but not pushed into a central operations system +- mixed responsibilities + - scanning, aggregation, rendering, and notification all live inside the same form workflow + +## Meaningful direction for a NinjaOne-connected replacement + +Instead of reviving the WinForms app, the better replacement is a small service or scheduled CLI with this split: + +1. collectors + - Windows Security/Event Log collector + - IIS/FTP log collector + - FileZilla collector +2. normalization + - normalize all findings into one event schema like: + - `timestamp` + - `sourceIp` + - `targetType` + - `username` + - `source` +3. correlation + - aggregate by IP and time window + - compute severity, velocity, and repeated targets +4. outputs + - JSON report on disk + - HTML report if needed + - NinjaOne-facing output channel + +## Suggested NinjaOne integration patterns + +Because there is no NinjaOne code in this repository yet, the safest first integration targets are: + +- write a machine-readable JSON summary that a NinjaOne script can collect +- emit a plain-text summary to stdout for script-result capture +- optionally write a CSV or HTML artifact for technicians +- keep notification transport separate so we can later swap in a NinjaOne API client, webhook, or custom-field updater + +## Practical next step + +Build a new `AttackTracer` replacement around a headless scanner first, then add the NinjaOne transport as a separate adapter. + +That lets us preserve the useful detection logic while dropping: + +- WinForms +- WCF +- external HTTP dependencies +- vendor-specific mail plumbing diff --git a/docs/attacktracer-ninja-cli.md b/docs/attacktracer-ninja-cli.md new file mode 100644 index 0000000..93ac4cb --- /dev/null +++ b/docs/attacktracer-ninja-cli.md @@ -0,0 +1,102 @@ +# AttackTracer Ninja CLI + +## Purpose + +`AttackTracerNinjaCli` is the first headless replacement for the legacy `AttackTracer` WinForms tool. + +It keeps the useful detection behavior, but drops the old UI and external WCF dependencies. + +## Current inputs + +- Windows Security Event Log `4625` +- Windows Application Event Log `18456` +- Windows Application Event Log `1035` +- IIS FTP logs under: + - `C:\inetpub\logs\LogFiles` + - `D:\inetpub\logs\LogFiles` +- FileZilla Server logs under: + - `C:\Program Files (x86)\FileZilla Server\Logs` + - `D:\Program Files (x86)\FileZilla Server\Logs` + +## Current outputs + +- human-readable console summary +- JSON report for later collection by `ninja1` +- optional `ATTACKTRACER_*` key/value lines for RMM parsing +- optional correlation with exported NinjaOne CVE data for the local device + +## Usage + +```powershell +dotnet run --project .\src\AttackTracerNinjaCli -- --output .\reports\attacktracer-summary.json +``` + +Or through the wrapper script: + +```powershell +.\scripts\run-attacktracer-ninja.ps1 -LookbackDays 7 -TopCount 10 -FailOnThreshold +``` + +With vulnerability correlation: + +```powershell +.\scripts\run-attacktracer-ninja.ps1 -LookbackDays 7 -TopCount 10 -VulnerabilityCsvPath .\samples\ninja-vulnerability-export.example.csv +``` + +Useful flags: + +- `--json-only` +- `--lookback-days 7` +- `--top 20` +- `--config .\config\attacktracer-settings.example.json` +- `--ninja-output` +- `--fail-on-attacks` +- `--fail-on-threshold` +- `--vulnerability-csv .\path\to\ninja-export.csv` + +## Configuration + +A sample config is available at [config/attacktracer-settings.example.json](C:\Users\Besitzer\Documents\AttackTracerNinjaVersion\config\attacktracer-settings.example.json). + +It currently supports: + +- warning and critical thresholds +- correlation thresholds for local CVE findings +- FTP root overrides +- FileZilla root overrides +- source IP exclusions + +## CVE correlation + +You can correlate AttackTracer findings with exported vulnerability data for the current host. + +Expected minimum CSV columns: + +- a device-name column such as `device`, `hostname`, or `computername` +- a CVE column such as `cve` or `cve_id` + +Optional columns: + +- `severity` +- `cvss` +- `remediation` + +A sample file is available at [samples/ninja-vulnerability-export.example.csv](C:\Users\Besitzer\Documents\AttackTracerNinjaVersion\samples\ninja-vulnerability-export.example.csv). + +## Next integration step + +The intended `ninja1` path is: + +1. run the installed monitor wrapper from a NinjaOne script or scheduled task +2. let the monitor wrapper write endpoint-level NinjaOne custom fields when `Ninja-Property-Set` or `ninjarmm-cli` is available +3. collect the JSON artifact if you want deeper troubleshooting data +4. use the custom fields and/or `ATTACKTRACER_*` console lines for alerting + +For organization-level reporting, you can mirror each device JSON report to a shared folder and render a local HTML summary using [docs/ninjaone-org-report-playbook.md](C:\Users\Besitzer\Documents\AttackTracerNinjaVersion\docs\ninjaone-org-report-playbook.md). + +## Deliberate omissions for v1 + +- no geolocation +- no email sending +- no WinForms UI +- no dependency on legacy HTTP services diff --git a/docs/attacktracer-ninja-server.md b/docs/attacktracer-ninja-server.md new file mode 100644 index 0000000..edefe96 --- /dev/null +++ b/docs/attacktracer-ninja-server.md @@ -0,0 +1,82 @@ +# AttackTracer Ninja Server + +> Legacy: This document describes the older share/server architecture. The recommended direction is now the V2 client + n8n + Postgres model documented in `attacktracer-ninja-v2-architecture.md`. + +## Purpose + +`AttackTracerNinjaServer` is the central companion package for one designated server. + +It is separate from the normal endpoint client and is responsible for: + +- reading mirrored JSON reports from the network share +- building the consolidated HTML report +- updating NinjaOne organization custom fields through the NinjaOne API + +## Separation of roles + +### Endpoint client + +Install the standard `AttackTracerNinja` package on all monitored systems. + +Use it to: + +- scan local logs +- write device custom fields +- mirror JSON reports to the central share + +### Server package + +Install `AttackTracerNinjaServer` on exactly one central server. + +Use it to: + +- read `\\share\*.json` +- generate `attacktracer-org-report.html` +- update: + - `attacktracerorgstatus` + - `attacktracerorgsummary` + - `attacktracerorglastupdate` +- optionally update `attacktracerorgreport` through the API + +## Installer + +Build output: + +- `artifacts\AttackTracerNinjaServerSetup.exe` + +During installation, the server installer prompts for: + +- Ninja base URL +- organization ID +- OAuth client ID +- OAuth client secret +- OAuth scope +- reports share path +- HTML output path +- target org field names + +Suggested OAuth scope default: + +- `monitoring management` + +## Runtime command + +Installed command: + +```powershell +& "C:\Program Files\AttackTracerNinjaServer\scripts\run-attacktracer-ninja-server.ps1" +``` + +## OAuth requirements + +Use a NinjaOne API client with at least: + +- `Monitoring` +- `Management` +- `Client Credentials` + +Relevant official references: + +- [Client Credentials Flow](https://app.ninjarmm.com/apidocs-beta/authorization/flows/client-credentials-flow) +- [API OAuth Token Configuration](https://www.ninjaone.com/docs/application-programming-interface-api/oauth-token-configuration/) +- [Organization custom fields](https://app.ninjarmm.com/apidocs-beta/core-resources/operations/getNodeCustomFields_1) diff --git a/docs/attacktracer-ninja-v2-architecture.md b/docs/attacktracer-ninja-v2-architecture.md new file mode 100644 index 0000000..ad6bdd7 --- /dev/null +++ b/docs/attacktracer-ninja-v2-architecture.md @@ -0,0 +1,469 @@ +# AttackTracer Ninja V2 Architecture + +## Goal + +Build a lightweight but robust endpoint agent that: + +- runs on every monitored Windows device +- reads local attack telemetry and optional local vulnerability exports +- writes device-level NinjaOne custom fields locally +- uploads signed JSON reports to a central n8n ingestion workflow +- receives updates through NinjaOne tasks from GitLab-hosted releases + +This design replaces: + +- the shared-folder aggregation model +- the dedicated `AttackTracerNinjaServer` +- organization-level field processing on an endpoint + +Organization-wide correlation and NinjaOne organization API updates move to n8n. + +## Core principles + +1. Endpoint collection is local, central correlation is remote. +2. Device custom field writes stay local through NinjaOne-supported endpoint mechanisms. +3. Organization logic never depends on one central Windows server. +4. Update delivery is controlled by NinjaOne, with release artifacts hosted in GitLab. +5. Endpoint uploads are authenticated and tamper-evident. +6. The central pipeline treats endpoint data as useful but never fully trusted. + +## Recommended component split + +### 1. Endpoint agent + +Use a compiled Windows agent written in C#. + +Responsibilities: + +- scan Windows Security/Application logs +- parse supported local logs such as FTP/FileZilla +- optionally ingest local exported vulnerability data +- build normalized JSON report +- print Ninja-style key/value status lines +- expose a command for device-local execution from NinjaOne +- upload signed report to n8n + +Why compiled instead of PowerShell-only: + +- harder to casually tamper with than plain scripts +- easier to sign +- easier to version and hash-verify +- simpler to protect internal protocol logic such as signing and replay prevention + +### 2. Thin PowerShell deployment wrapper + +Use PowerShell only for: + +- install +- uninstall +- update +- scheduled execution wrapper +- NinjaOne field publishing wrapper when needed + +This keeps operational flexibility while protecting core collection logic inside a signed binary. + +### 3. GitLab release channel + +GitLab hosts: + +- release ZIP or installer +- version manifest +- SHA-256 checksum file +- optional detached signature + +Recommended files per release: + +- `AttackTracerNinjaClient-win-x64.zip` +- `version.json` +- `AttackTracerNinjaClient-win-x64.zip.sha256` +- `release-notes.md` + +### 4. n8n ingestion pipeline + +n8n receives endpoint JSON through an authenticated webhook and performs: + +- signature validation +- timestamp and replay validation +- schema validation +- persistence to Postgres +- organization aggregation +- NinjaOne organization field updates +- optional notifications + +### 5. Postgres as primary store + +Postgres should be the system of record for incoming reports. + +Recommended tables: + +- `devices` +- `device_reports` +- `device_findings` +- `device_vulnerability_findings` +- `organization_rollups` +- `ingestion_events` + +### 6. Optional Nextcloud archive + +Nextcloud can be used for: + +- archived JSON bundles +- generated HTML reports +- long-term human-readable reports + +Do not use it as the primary operational datastore. + +## Endpoint security model + +## Threat assumptions + +Assume an attacker may: + +- modify files under the install directory +- stop scheduled tasks or Ninja jobs +- alter local logs +- replay older JSON uploads +- inspect locally stored configuration + +Assume an attacker with full local admin or SYSTEM access can eventually subvert the endpoint. The architecture therefore aims to: + +- raise the effort of tampering +- make tampering detectable +- reduce blast radius of stolen secrets +- preserve central evidence of missing or suspicious reporting + +## Required protections + +### Signed binaries + +- Sign the compiled agent executable. +- Optionally sign the deployment PowerShell scripts. +- The updater must verify Authenticode signature and expected hash before replacing files. + +### Protected local secrets + +Do not embed one global master secret in all clients. + +Use one of these approaches: + +- per-tenant ingest token wrapped with DPAPI on each machine +- per-device secret provisioned during install and stored encrypted with DPAPI +- short-lived signed enrollment flow if you later want stronger provisioning + +Minimum recommendation: + +- store an n8n upload secret encrypted via DPAPI in a local config file readable only by SYSTEM/Administrators + +### Signed report uploads + +Each report upload should include: + +- device identifier +- report timestamp in UTC +- monotonic nonce or GUID +- client version +- payload hash +- HMAC signature over canonicalized request fields + +Suggested headers: + +- `X-ATN-Device` +- `X-ATN-Timestamp` +- `X-ATN-Nonce` +- `X-ATN-Version` +- `X-ATN-Signature` + +n8n must reject: + +- stale timestamps outside tolerance +- duplicate nonce values +- invalid HMAC signatures + +### Tamper evidence + +The endpoint should include in its report: + +- installed client version +- scanner execution start/end UTC +- whether upload succeeded +- hash of produced JSON payload +- optional configuration version + +n8n should track: + +- expected reporting cadence per device +- missing devices +- repeated version lag +- repeated upload failures +- sudden disappearance of formerly noisy devices + +### Least-privilege local behavior + +- install under `C:\Program Files\AttackTracerNinja` +- write mutable state under `C:\ProgramData\AttackTracerNinja` +- restrict config/log/state ACLs to `SYSTEM` and Administrators +- avoid storing writable binaries under user-controlled locations + +## Update architecture + +## Distribution model + +NinjaOne remains the deployment engine. + +Recommended flow: + +1. Build signed release in CI. +2. Publish release artifact to GitLab. +3. Publish `version.json` with latest version metadata. +4. NinjaOne scheduled update task runs on endpoints. +5. Update task checks local version against GitLab manifest. +6. If newer, download artifact, verify hash/signature, install, and record result. + +## Version manifest + +Example `version.json`: + +```json +{ + "channel": "stable", + "version": "2.0.0", + "publishedAtUtc": "2026-07-16T18:00:00Z", + "artifactUrl": "https://gitlab.example.com/group/project/-/releases/v2.0.0/downloads/AttackTracerNinjaClient-win-x64.zip", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "minUpdaterVersion": "1.0.0" +} +``` + +## Endpoint update task + +The NinjaOne update task should: + +- run as SYSTEM +- read installed version +- fetch `version.json` +- compare versions +- download ZIP to a temporary directory +- verify SHA-256 +- verify Authenticode signature on executable +- stop running agent if needed +- replace files atomically +- write update result to a local log + +The update task should never install unsigned or hash-mismatched artifacts. + +## Runtime execution model + +Recommended commands: + +- `AttackTracerNinjaCli.exe scan` +- `AttackTracerNinjaCli.exe scan --ninja-output` +- `AttackTracerNinjaCli.exe upload --report ` +- `AttackTracerNinjaCli.exe scan-and-upload` + +The PowerShell wrapper invoked by NinjaOne should typically run: + +```powershell +& "C:\Program Files\AttackTracerNinja\app\AttackTracerNinjaCli.exe" scan-and-upload --ninja-output +``` + +Wrapper responsibilities: + +- ensure paths exist +- capture stdout/stderr to log +- set NinjaOne device custom fields from the produced status +- return a useful exit code for monitoring + +## Suggested data flow + +1. Endpoint agent scans local data. +2. Agent writes a JSON report locally. +3. Wrapper publishes device custom fields to NinjaOne. +4. Agent signs and uploads the JSON report to n8n. +5. n8n validates authenticity and freshness. +6. n8n stores raw and normalized data in Postgres. +7. n8n computes organization-level summaries. +8. n8n updates NinjaOne organization fields through the API. +9. Optional HTML and long-form artifacts are generated centrally. + +## n8n workflow design + +## Workflow A: ingest-device-report + +Trigger: + +- Webhook + +Steps: + +1. Validate required headers. +2. Validate timestamp tolerance. +3. Check nonce replay in Postgres. +4. Recompute HMAC and compare. +5. Validate JSON schema. +6. Upsert device metadata. +7. Insert raw report row. +8. Insert findings rows. +9. Mark ingestion success. + +Failure handling: + +- log validation error +- store rejected attempt metadata +- optionally alert on repeated bad signatures + +## Workflow B: organization-rollup + +Trigger: + +- Cron every 5 or 15 minutes + +Steps: + +1. Query latest accepted report per device. +2. Compute org status and summary. +3. Detect stale devices and missing submissions. +4. Update NinjaOne organization fields: + - `attacktracerorgstatus` + - `attacktracerorgsummary` + - `attacktracerorglastupdate` +5. Optionally generate HTML and archive it + +## Workflow C: stale-device-alerting + +Trigger: + +- Cron + +Logic: + +- find devices with no valid upload within expected interval +- raise notification or ticket + +## Recommended report contract + +Each JSON report should include: + +```json +{ + "schemaVersion": "2.0", + "machineName": "WSUS", + "deviceIdHint": "", + "organizationHint": "", + "generatedAtUtc": "2026-07-16T18:42:11Z", + "clientVersion": "2.0.0", + "lookbackDays": 7, + "baseStatus": "ok", + "alertState": "ok", + "totalEvents": 0, + "uniqueIpCount": 0, + "errorCount": 0, + "attackFindings": [], + "vulnerabilityCorrelation": { + "totalCount": 0, + "criticalCount": 0, + "highCvssCount": 0 + }, + "runtime": { + "startedAtUtc": "2026-07-16T18:42:09Z", + "finishedAtUtc": "2026-07-16T18:42:11Z", + "uploadAttempted": true + } +} +``` + +Authentication metadata should travel in headers, not inside the JSON body. + +## Packaging recommendation + +Use a ZIP-based package for GitLab delivery and NinjaOne installation. + +Recommended layout: + +```text +AttackTracerNinjaClient-win-x64.zip + app/ + AttackTracerNinjaCli.exe + AttackTracerNinjaCli.dll + scripts/ + install-attacktracer-ninja.ps1 + uninstall-attacktracer-ninja.ps1 + update-attacktracer-ninja.ps1 + run-attacktracer-ninja.ps1 + config/ + attacktracer-settings.example.json + VERSION.txt +``` + +This avoids the operational overhead of a heavy GUI installer while staying easy to deploy from NinjaOne. + +## Hardening recommendations + +- enable script and binary code signing where possible +- set strict ACLs on `Program Files` and `ProgramData` content +- log every update attempt locally +- include a watchdog check for missing executions +- keep secrets out of command-line parameters where possible +- prefer HTTPS with certificate validation for all uploads +- optionally pin the server certificate thumbprint if your environment allows it + +## Migration plan + +### Phase 1: define v2 contract + +- freeze current JSON model and derive `schemaVersion 2.0` +- define n8n webhook contract +- define version manifest format + +### Phase 2: build central pipeline + +- create Postgres schema +- create n8n ingest workflow +- create n8n rollup workflow +- test NinjaOne org field updates from n8n + +### Phase 3: refactor endpoint client + +- remove share mirroring logic +- remove organization/server logic from endpoint package +- add signed upload path +- add DPAPI-backed local secret storage + +### Phase 4: implement update channel + +- publish GitLab release artifacts +- implement version manifest check +- implement hash/signature validation +- implement NinjaOne update task + +### Phase 5: controlled rollout + +- pilot on a small device group +- validate upload cadence and rollups +- compare with old system +- then roll out to all devices + +### Phase 6: retire old architecture + +- stop `AttackTracerNinjaServer` +- remove share-based reporting dependency +- deprecate old org-report scripts + +## Recommendation summary + +Recommended final direction: + +- keep a compiled endpoint agent +- use PowerShell only as thin install/update/run wrapper +- distribute and update through NinjaOne +- host signed release artifacts in GitLab +- upload signed JSON reports to n8n +- store operational data in Postgres +- perform organization-level NinjaOne API writes only from n8n + +This gives the best balance of: + +- endpoint robustness +- easier rollout +- central visibility +- reduced single-point-of-failure risk +- maintainable future growth diff --git a/docs/attacktracer-ninja-v2-deployment.md b/docs/attacktracer-ninja-v2-deployment.md new file mode 100644 index 0000000..5f1d5c4 --- /dev/null +++ b/docs/attacktracer-ninja-v2-deployment.md @@ -0,0 +1,103 @@ +# AttackTracer Ninja V2 Deployment + +## Goal + +Deploy and update the endpoint client through NinjaOne while hosting release artifacts in GitLab. + +## Release assets + +Each GitLab release should publish: + +- `OCSentinelClient-win-x64.zip` +- `OCSentinelClient-win-x64.zip.sha256` +- `version.json` + +Build these locally with: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build\build-client-package.ps1 +powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 ` + -ArtifactUrl "https://gitlab.example.com/group/project/-/releases/v2.0.0/downloads/OCSentinelClient-win-x64.zip" +``` + +See example manifest: + +- [update-channel.example.json](C:/Users/Besitzer/Documents/AttackTracerNinjaVersion/config/update-channel.example.json) + +## Endpoint package contents + +The installed package should include: + +- `app\OCSentinelCli.exe` +- `scripts\run-ocsentinel.ps1` +- `scripts\run-ocsentinel-monitor.ps1` +- `scripts\update-ocsentinel.ps1` +- `scripts\protect-ocsentinel-secret.ps1` +- `config\ocsentinel-settings.json` +- `config\ocsentinel-client.json` + +## Initial install through NinjaOne + +Recommended NinjaOne task: + +```powershell +& "C:\Program Files\OCSentinel\scripts\update-ocsentinel.ps1" ` + -ManifestUrl "https://gitlab.example.com/group/project/-/releases/permalink/latest/downloads/version.json" ` + -Force +``` + +If the client is not installed yet, you can also first distribute a bootstrap ZIP or setup package, then switch to the updater-only model. + +## Routine update task + +Recommended scheduled task command: + +```powershell +& "C:\Program Files\OCSentinel\scripts\update-ocsentinel.ps1" ` + -ManifestUrl "https://gitlab.example.com/group/project/-/releases/permalink/latest/downloads/version.json" +``` + +## Behavior + +The updater: + +1. downloads the manifest +2. compares installed and available version +3. downloads the ZIP only when newer +4. validates SHA-256 +5. validates Authenticode signature when present +6. runs the package installer + +## Secret bootstrap + +After installation, provision the upload secret once: + +```powershell +& "C:\Program Files\OCSentinel\scripts\protect-ocsentinel-secret.ps1" ` + -SecretValue "" +``` + +This writes a DPAPI-protected file under: + +- `C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat` + +## Runtime task + +Recommended runtime task: + +```powershell +& "C:\Program Files\OCSentinel\scripts\run-ocsentinel-monitor.ps1" ` + -Mode status ` + -OutputPath "..\reports\attacktracer-summary.json" +``` + +Upload is enabled automatically when: + +- `config\attacktracer-client.json` exists +- the protected secret file exists + +Otherwise the client falls back to local scan behavior. + +## Migration guidance + +During migration you can keep old share-based logic disabled by default and only enable the new n8n upload path as secrets and webhook config become available. diff --git a/docs/attacktracer-ninja-v2-n8n-contract.md b/docs/attacktracer-ninja-v2-n8n-contract.md new file mode 100644 index 0000000..6ca1f60 --- /dev/null +++ b/docs/attacktracer-ninja-v2-n8n-contract.md @@ -0,0 +1,131 @@ +# AttackTracer Ninja V2 n8n Contract + +## Purpose + +This document defines the concrete webhook contract between the endpoint client and n8n. + +## Request + +Method: + +- `POST` + +Content type: + +- `application/json` + +Webhook URL: + +- example: `https://n8n.example.com/webhook/attacktracer-ingest` + +## Required headers + +- `X-ATN-Device` +- `X-ATN-Timestamp` +- `X-ATN-Nonce` +- `X-ATN-Version` +- `X-ATN-Payload-SHA256` +- `X-ATN-Signature` + +## Header semantics + +- `X-ATN-Device` + - endpoint machine name used for attribution +- `X-ATN-Timestamp` + - UTC timestamp in ISO 8601 format +- `X-ATN-Nonce` + - unique per request, used for replay protection +- `X-ATN-Version` + - client version string +- `X-ATN-Payload-SHA256` + - SHA-256 over the JSON body, lowercase hex +- `X-ATN-Signature` + - HMAC-SHA256 over the canonical signing string, lowercase hex + +## Canonical signing string + +The client signs this exact string: + +```text +device + "\n" + timestamp + "\n" + nonce + "\n" + version + "\n" + payloadSha256 +``` + +## Signature algorithm + +- `HMAC-SHA256` + +## Recommended n8n validation + +1. Reject when a required header is missing. +2. Reject when timestamp skew exceeds allowed tolerance. +3. Reject when nonce was already seen. +4. Recompute payload hash and compare to `X-ATN-Payload-SHA256`. +5. Recompute HMAC and compare to `X-ATN-Signature`. +6. Parse JSON only after headers and signature validation pass. + +## Suggested timestamp tolerance + +- 5 minutes + +## Suggested replay protection persistence + +Persist nonce records in Postgres with: + +- nonce +- device +- timestamp +- first_seen_utc + +## JSON body schema + +The body is a `ScanResult` JSON document with schema version `2.0`. + +Key fields: + +- `schemaVersion` +- `machineName` +- `generatedAtUtc` +- `clientVersion` +- `lookbackDays` +- `alertState` +- `baseAlertState` +- `totalEvents` +- `uniqueIpCount` +- `errors` +- `vulnerabilityCorrelation` +- `runtime` + +## Example body + +See: + +- [webhook-payload.example.json](C:/Users/Besitzer/Documents/AttackTracerNinjaVersion/samples/webhook-payload.example.json) + +## n8n response recommendation + +On success: + +- HTTP `200` or `202` +- JSON body with minimal confirmation + +Example: + +```json +{ + "accepted": true, + "device": "WSUS", + "nonce": "f53a3f0b0c2c466ea1717d88d55fd393" +} +``` + +On failure: + +- HTTP `4xx` for validation/signature problems +- HTTP `5xx` for processing/storage problems + +## Device identity guidance + +The webhook should not trust `machineName` alone for authorization. + +For initial rollout, use the shared secret as the trust anchor. +For later hardening, add a per-device secret or enrollment identity. diff --git a/docs/attacktracer-ninja-v2-repo-plan.md b/docs/attacktracer-ninja-v2-repo-plan.md new file mode 100644 index 0000000..564d578 --- /dev/null +++ b/docs/attacktracer-ninja-v2-repo-plan.md @@ -0,0 +1,523 @@ +# AttackTracer Ninja V2 Repo Plan + +## Purpose + +This document turns the V2 architecture into a concrete repository refactor plan. + +It answers: + +- which existing components should be removed or deprecated +- which new components should be created +- how the client package should be structured +- what the n8n webhook contract should look like +- in which order the migration should happen + +## Target result + +The repository should end up centered around one endpoint client package that: + +- runs on every managed Windows device +- writes NinjaOne device fields locally +- uploads signed JSON reports to n8n +- can be installed and updated from NinjaOne using GitLab-hosted release artifacts + +The repository should no longer rely on: + +- share mirroring +- a central `AttackTracerNinjaServer` +- organization aggregation on a Windows endpoint + +## Existing components to remove or deprecate + +## Remove from active architecture + +These should be retired from the primary product path: + +- `scripts/build-attacktracer-org-report.ps1` +- `installer/runtime-build-attacktracer-org-report.ps1` +- `scripts/run-attacktracer-ninja-server.ps1` +- `installer/runtime-run-attacktracer-ninja-server.ps1` +- `scripts/build-attacktracer-server-installer.ps1` +- `installer/server-install-attacktracer-ninja-server.ps1` +- `installer/server-uninstall-attacktracer-ninja-server.ps1` +- `installer/AttackTracerNinjaServerBootstrapper/*` +- `docs/attacktracer-ninja-server.md` +- share-based org-report playbook content in `docs/ninjaone-org-report-playbook.md` + +## Keep only as legacy reference + +These can remain temporarily during migration but should be clearly marked legacy: + +- `scripts/run-attacktracer-ninja-monitor.ps1` +- `scripts/run-attacktracer-ninja.ps1` +- `docs/ninjaone-monitoring-playbook.md` +- `config/attacktracer-settings.example.json` + +## Existing components to preserve and refactor + +These are still valuable and should become the base for V2: + +- `src/AttackTracerNinjaCli/AttackScanner.cs` +- `src/AttackTracerNinjaCli/VulnerabilityCorrelation.cs` +- `src/AttackTracerNinjaCli/Models.cs` +- `src/AttackTracerNinjaCli/JsonOptions.cs` +- `src/AttackTracerNinjaCli/Program.cs` +- `src/AttackTracerNinjaCli/ScanOptions.cs` + +Core scanning logic should remain in C#. + +## New repository structure + +Recommended target layout: + +```text +src/ + AttackTracerNinjaCli/ + AttackTracerNinjaCli.csproj + Commands/ + ScanCommand.cs + UploadCommand.cs + ScanAndUploadCommand.cs + VersionCommand.cs + Security/ + HmacSigner.cs + NonceStore.cs + ProtectedSecretStore.cs + Transport/ + N8nUploadClient.cs + UploadEnvelopeBuilder.cs + Models/ + ReportEnvelope.cs + ScanReport.cs + UploadResult.cs + Configuration/ + ClientConfiguration.cs + ConfigurationLoader.cs + +scripts/ + install-attacktracer-ninja.ps1 + uninstall-attacktracer-ninja.ps1 + update-attacktracer-ninja.ps1 + run-attacktracer-ninja.ps1 + publish-ninja-fields.ps1 + +config/ + attacktracer-client.example.json + update-channel.example.json + +docs/ + attacktracer-ninja-v2-architecture.md + attacktracer-ninja-v2-repo-plan.md + attacktracer-ninja-v2-n8n-contract.md + attacktracer-ninja-v2-deployment.md + +build/ + build-client-package.ps1 + build-release-manifest.ps1 + +samples/ + webhook-payload.example.json + version.example.json +``` + +## New endpoint client responsibilities + +## Binary responsibilities + +The compiled client should do these jobs: + +- local attack scanning +- vulnerability correlation +- JSON report generation +- report signing +- authenticated upload to n8n +- stable exit codes +- machine-readable output for NinjaOne wrappers + +## Wrapper responsibilities + +PowerShell wrappers should only do: + +- install/uninstall +- update +- local config bootstrap +- invoking the binary +- publishing NinjaOne device custom fields +- logging wrapper-level failures + +## New client command model + +Recommended commands: + +### `scan` + +Performs local scan and writes a report file. + +Example: + +```powershell +AttackTracerNinjaCli.exe scan --output "C:\ProgramData\AttackTracerNinja\reports\latest.json" --ninja-output +``` + +### `upload` + +Uploads an existing report to n8n. + +Example: + +```powershell +AttackTracerNinjaCli.exe upload --report "C:\ProgramData\AttackTracerNinja\reports\latest.json" +``` + +### `scan-and-upload` + +Performs a scan and directly uploads the result. + +Example: + +```powershell +AttackTracerNinjaCli.exe scan-and-upload --output "C:\ProgramData\AttackTracerNinja\reports\latest.json" --ninja-output +``` + +### `version` + +Prints installed version and build metadata. + +## Config model + +Recommended local config file: + +- `C:\ProgramData\AttackTracerNinja\config\attacktracer-client.json` + +Recommended fields: + +```json +{ + "schemaVersion": "2.0", + "environment": "production", + "lookbackDays": 7, + "topFindings": 10, + "n8nWebhookUrl": "https://n8n.example.com/webhook/attacktracer-ingest", + "deviceIdentifierMode": "machineName", + "uploadTimeoutSeconds": 30, + "enableVulnerabilityCorrelation": true, + "vulnerabilityCsvPath": "", + "secretReference": "device-default" +} +``` + +Secrets should not be stored here in clear text. + +## Local secret handling plan + +## Secret storage + +Create a protected local secret file under: + +- `C:\ProgramData\AttackTracerNinja\secrets\upload-secret.dat` + +Use DPAPI machine protection to encrypt the secret. + +## Secret bootstrap + +Initial rollout options: + +1. NinjaOne install task writes a tenant token once and immediately protects it with DPAPI. +2. Later evolution: unique per-device secret issued centrally. + +Recommended first cut: + +- tenant-level ingest secret wrapped with DPAPI +- HMAC signature over request metadata + payload hash + +## n8n webhook contract + +## Endpoint request + +Method: + +- `POST` + +URL: + +- provided via config, for example: + - `https://n8n.example.com/webhook/attacktracer-ingest` + +Headers: + +- `Content-Type: application/json` +- `X-ATN-Device` +- `X-ATN-Timestamp` +- `X-ATN-Nonce` +- `X-ATN-Version` +- `X-ATN-Payload-SHA256` +- `X-ATN-Signature` + +Body: + +- JSON scan report only + +Signature input recommendation: + +```text +device + "\n" + timestamp + "\n" + nonce + "\n" + version + "\n" + payloadSha256 +``` + +Algorithm: + +- `HMAC-SHA256` + +## n8n validation rules + +n8n should reject when: + +- any required header is missing +- timestamp is outside tolerance +- nonce already exists +- payload hash mismatches body +- signature mismatches +- schema is invalid + +## Suggested report payload schema + +```json +{ + "schemaVersion": "2.0", + "machineName": "WSUS", + "generatedAtUtc": "2026-07-16T19:12:00Z", + "clientVersion": "2.0.0", + "baseStatus": "ok", + "alertState": "ok", + "totalEvents": 0, + "uniqueIpCount": 0, + "errorCount": 0, + "attackFindings": [], + "vulnerabilityCorrelation": { + "totalCount": 0, + "criticalCount": 0, + "highCvssCount": 0 + }, + "runtime": { + "startedAtUtc": "2026-07-16T19:11:57Z", + "finishedAtUtc": "2026-07-16T19:12:00Z", + "uploadAttempted": true + } +} +``` + +## GitLab release plan + +## Release artifacts + +Each release should publish: + +- `AttackTracerNinjaClient-win-x64.zip` +- `AttackTracerNinjaClient-win-x64.zip.sha256` +- `version.json` + +## ZIP layout + +```text +AttackTracerNinjaClient-win-x64.zip + app/ + AttackTracerNinjaCli.exe + AttackTracerNinjaCli.dll + scripts/ + install-attacktracer-ninja.ps1 + uninstall-attacktracer-ninja.ps1 + update-attacktracer-ninja.ps1 + run-attacktracer-ninja.ps1 + publish-ninja-fields.ps1 + config/ + attacktracer-client.example.json + VERSION.txt +``` + +## Manifest format + +```json +{ + "channel": "stable", + "version": "2.0.0", + "artifactUrl": "https://gitlab.example.com/group/project/-/releases/v2.0.0/downloads/AttackTracerNinjaClient-win-x64.zip", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "publishedAtUtc": "2026-07-16T19:15:00Z" +} +``` + +## NinjaOne task model + +## Task A: install/update client + +Runs as SYSTEM. + +Responsibilities: + +- fetch `version.json` +- compare with local version +- download artifact when newer +- verify SHA-256 +- verify Authenticode signature on executable +- install or update package + +## Task B: run scan + +Runs as SYSTEM on each endpoint. + +Responsibilities: + +- invoke `run-attacktracer-ninja.ps1` +- publish device custom fields +- trigger upload to n8n + +## Device custom field path + +Keep the current device-level field strategy. + +Wrappers should continue using: + +- `Ninja-Property-Set` when available +- Ninja CLI fallback if needed + +Fields to preserve: + +- `attacktracerstatus` +- `attacktracerreason` +- `attacktracerbasestatus` +- `attacktracerevents` +- `attacktraceruniqueips` +- `attacktracercvecritical` +- `attacktracercvetotal` +- `attacktracermode` +- `attacktracertriggered` +- `attacktracerlastscanutc` + +## Concrete code refactor plan + +## Step 1: isolate scan model + +Refactor current CLI so scan output is represented by one stable report class. + +Create: + +- `Models/ScanReport.cs` + +Move report shape ownership there. + +## Step 2: add transport layer + +Create: + +- `Transport/N8nUploadClient.cs` +- `Transport/UploadEnvelopeBuilder.cs` + +Responsibilities: + +- prepare headers +- hash payload +- sign request +- upload with timeout and retry policy + +## Step 3: add secret protection + +Create: + +- `Security/ProtectedSecretStore.cs` + +Responsibilities: + +- store secret using DPAPI +- load secret at runtime + +## Step 4: add nonce/replay support + +Create: + +- `Security/NonceStore.cs` + +Local nonce history is optional, but useful for diagnostics. + +## Step 5: split commands + +Refactor `Program.cs` into command-oriented classes: + +- `ScanCommand.cs` +- `UploadCommand.cs` +- `ScanAndUploadCommand.cs` +- `VersionCommand.cs` + +## Step 6: simplify wrappers + +Replace older server/share assumptions in wrappers. + +`run-attacktracer-ninja.ps1` should become: + +- invoke binary +- capture output +- write Ninja device fields +- exit with monitoring-friendly code + +## Step 7: replace build pipeline + +Create: + +- `build/build-client-package.ps1` +- `build/build-release-manifest.ps1` + +Deprecate: + +- heavy installer-first flow if ZIP distribution is enough + +## Step 8: deprecate server package + +Mark these as legacy and remove from active release build: + +- `AttackTracerNinjaServer*` +- org HTML writer logic +- share mirroring flow + +## Implementation order + +## Phase A: repo cleanup and design freeze + +1. Mark server/share docs as legacy +2. Add new config and payload docs +3. Freeze V2 JSON schema + +## Phase B: client refactor + +1. Add command split +2. Add signed upload path +3. Add DPAPI secret handling +4. Keep existing scan logic intact + +## Phase C: packaging + +1. Build ZIP package +2. Build version manifest +3. Add install/update scripts + +## Phase D: central integration + +1. Build n8n ingest workflow +2. Build Postgres schema +3. Build NinjaOne org-field update workflow + +## Phase E: rollout + +1. Pilot group +2. Validate cadence and missing-report detection +3. Roll out widely +4. Retire server/share path + +## Final recommendation + +Implement V2 as: + +- compiled endpoint collector +- PowerShell operational wrapper +- GitLab-hosted signed releases +- NinjaOne-based deployment and update +- n8n-based central ingestion and org aggregation + +This gives a cleaner codebase and a safer operational model than extending the old share/server architecture further. diff --git a/docs/ninjaone-monitoring-playbook.md b/docs/ninjaone-monitoring-playbook.md new file mode 100644 index 0000000..41b8b69 --- /dev/null +++ b/docs/ninjaone-monitoring-playbook.md @@ -0,0 +1,136 @@ +# NinjaOne Monitoring Playbook + +## Goal + +Use `AttackTracerNinja` in NinjaOne with simple, predictable monitor behaviors and endpoint-visible custom-field data. + +## Recommended scripts + +Primary data collection: + +- [scripts/run-attacktracer-ninja.ps1](C:\Users\Besitzer\Documents\AttackTracerNinjaVersion\scripts\run-attacktracer-ninja.ps1) + +Monitor-oriented wrapper: + +- [scripts/run-attacktracer-ninja-monitor.ps1](C:\Users\Besitzer\Documents\AttackTracerNinjaVersion\scripts\run-attacktracer-ninja-monitor.ps1) + +## Recommended monitor layout + +## Recommended custom fields + +Create these device custom fields in NinjaOne and allow script write access: + +- `attacktracerstatus` +- `attacktracerreason` +- `attacktracerbasestatus` +- `attacktracerevents` +- `attacktraceruniqueips` +- `attacktracercvecritical` +- `attacktracercvetotal` +- `attacktracermode` +- `attacktracertriggered` +- `attacktracerlastscanutc` + +Suggested field types: + +- text: `attacktracerstatus`, `attacktracerreason`, `attacktracerbasestatus`, `attacktracermode`, `attacktracerlastscanutc` +- number/integer: `attacktracerevents`, `attacktraceruniqueips`, `attacktracercvecritical`, `attacktracercvetotal` +- checkbox or text: `attacktracertriggered` + +### 1. Attack status monitor + +Purpose: +- trigger when the final correlated status is not `ok` + +Suggested command: + +```powershell +& "C:\Program Files\AttackTracerNinja\scripts\run-attacktracer-ninja-monitor.ps1" -Mode status -LookbackDays 7 +``` + +Meaning: +- alerts on attack-only findings +- alerts on attack-plus-CVE escalation + +### 2. Attack-only monitor + +Purpose: +- trigger only on attack activity, ignoring pure CVE context + +Suggested command: + +```powershell +& "C:\Program Files\AttackTracerNinja\scripts\run-attacktracer-ninja-monitor.ps1" -Mode attack-only -LookbackDays 7 +``` + +Meaning: +- good for brute-force / failed-login monitoring +- independent of vulnerability imports + +### 3. Critical CVE monitor + +Purpose: +- trigger when the imported NinjaOne vulnerability export shows critical/high CVEs for this device + +Suggested command: + +```powershell +& "C:\Program Files\AttackTracerNinja\scripts\run-attacktracer-ninja-monitor.ps1" -Mode cve-critical -LookbackDays 7 -VulnerabilityCsvPath "C:\Program Files\AttackTracerNinja\samples\ninja-vulnerability-export.example.csv" +``` + +Meaning: +- CVE-focused monitor +- no attack activity required + +### 4. Attack plus CVE correlation monitor + +Purpose: +- trigger only when this endpoint has attack activity and critical/high CVEs at the same time + +Suggested command: + +```powershell +& "C:\Program Files\AttackTracerNinja\scripts\run-attacktracer-ninja-monitor.ps1" -Mode attack-plus-cve -LookbackDays 7 -VulnerabilityCsvPath "C:\Program Files\AttackTracerNinja\samples\ninja-vulnerability-export.example.csv" +``` + +Meaning: +- highest-signal monitor +- ideal for priority triage + +## Suggested alert interpretation + +- `status` + - use for general security operations visibility +- `attack-only` + - use for incident-style login abuse detection +- `cve-critical` + - use for vulnerability backlog / patch pressure +- `attack-plus-cve` + - use for urgent escalation + +## Useful output fields from the main runner + +The main runner emits: + +- `ATTACKTRACER_STATUS` +- `ATTACKTRACER_REASON` +- `ATTACKTRACER_BASE_STATUS` +- `ATTACKTRACER_EVENTS` +- `ATTACKTRACER_UNIQUE_IPS` +- `ATTACKTRACER_ERRORS` +- `ATTACKTRACER_CVE_TOTAL` +- `ATTACKTRACER_CVE_CRITICAL` +- `ATTACKTRACER_CVE_HIGH_CVSS` + +These are useful if you prefer a condition-based script monitor rather than exit-code-only behavior. + +The installed monitor wrapper also attempts to populate NinjaOne custom fields automatically using `Ninja-Property-Set` first and `C:\ProgramData\NinjaRMMAgent\ninjarmm-cli.exe` as a fallback. + +## Practical recommendation + +If you want the smallest workable setup, start with: + +1. a scheduled script that runs `status` and updates the custom fields +2. a condition or device health check on `attacktracertriggered = true` + +That gives you one broad monitor, keeps the latest values visible on the device, and avoids relying only on transient script output. diff --git a/docs/ninjaone-org-report-playbook.md b/docs/ninjaone-org-report-playbook.md new file mode 100644 index 0000000..7f9d8a1 --- /dev/null +++ b/docs/ninjaone-org-report-playbook.md @@ -0,0 +1,107 @@ +# NinjaOne Organization Report Playbook + +> Legacy: This playbook documents the older shared-folder organization reporting model. For the recommended replacement, use the V2 n8n-based design in `attacktracer-ninja-v2-architecture.md`. + +## Goal + +Build a central AttackTracer overview as a local HTML report generated from mirrored device reports. + +## Recommended design + +### Device layer + +Each endpoint continues to write these device custom fields: + +- `attacktracerstatus` +- `attacktracerreason` +- `attacktracerbasestatus` +- `attacktracerevents` +- `attacktraceruniqueips` +- `attacktracercvecritical` +- `attacktracercvetotal` +- `attacktracermode` +- `attacktracertriggered` +- `attacktracerlastscanutc` + +Each endpoint can also mirror its JSON report to a shared folder: + +```powershell +& "C:\Program Files\AttackTracerNinja\scripts\run-attacktracer-ninja-monitor.ps1" -Mode status -MirrorRoot "\\fileserver\AttackTracer\OrgA" +``` + +That will create one JSON file per machine, such as: + +- `\\fileserver\AttackTracer\OrgA\WSUS.json` +- `\\fileserver\AttackTracer\OrgA\MILSRV222.json` + +## Central report host + +Use one always-on Windows server as the central report host: + +- stable network path access to the mirrored report share +- enough permissions to read every mirrored JSON file +- optional browser/file access for opening the rendered HTML report + +This host only builds the local HTML report. It does not write organization-level custom fields back into NinjaOne. + +If your NinjaOne environment exposes `Set-NinjaOrganizationProperty` or `Ninja-Organization-Property-Set`, the same script can also update these text-based organization fields: + +- `attacktracerorgstatus` +- `attacktracerorgsummary` +- `attacktracerorglastupdate` + +## Org report generator + +Use the organization generator script on the delegate machine: + +- [scripts/build-attacktracer-org-report.ps1](C:\Users\Besitzer\Documents\AttackTracerNinjaVersion\scripts\build-attacktracer-org-report.ps1) + +Installed path after setup: + +- `C:\Program Files\AttackTracerNinja\scripts\build-attacktracer-org-report.ps1` + +### Generate HTML only + +```powershell +& "C:\Program Files\AttackTracerNinja\scripts\build-attacktracer-org-report.ps1" ` + -ReportsRoot "\\fileserver\AttackTracer\OrgA" ` + -OutputPath "..\reports\attacktracer-org-report.html" +``` + +### Recommended production command + +```powershell +& "C:\Program Files\AttackTracerNinja\scripts\build-attacktracer-org-report.ps1" ` + -ReportsRoot "\\fileserver\AttackTracer\OrgA" ` + -OutputPath "\\fileserver\AttackTracer\OrgA\attacktracer-org-report.html" +``` + +### Optional: also update organization summary fields + +```powershell +& "C:\Program Files\AttackTracerNinja\scripts\build-attacktracer-org-report.ps1" ` + -ReportsRoot "\\fileserver\AttackTracer\OrgA" ` + -OutputPath "\\fileserver\AttackTracer\OrgA\attacktracer-org-report.html" ` + -WriteNinjaOrgSummary +``` + +## What the HTML contains + +The generated HTML report includes: + +- scanned device count +- alerting device count +- critical device count +- total event count +- unique source IP count +- an alert table with the most relevant rows +- a compact `Log sauber` device list + +## Operational recommendation + +Schedule it in two stages: + +1. run the endpoint monitor script on all managed servers and mirror JSON output to the organization share +2. run the organization report generator on the central report host a few minutes later + +This keeps the device logic simple and gives you one stable HTML organization summary to open locally or from the share. diff --git a/installer/AttackTracerNinjaBootstrapper/AttackTracerNinjaBootstrapper.csproj b/installer/AttackTracerNinjaBootstrapper/AttackTracerNinjaBootstrapper.csproj new file mode 100644 index 0000000..fdb3593 --- /dev/null +++ b/installer/AttackTracerNinjaBootstrapper/AttackTracerNinjaBootstrapper.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0-windows + enable + enable + false + 1.2.2 + + + + + + + diff --git a/installer/AttackTracerNinjaBootstrapper/Program.cs b/installer/AttackTracerNinjaBootstrapper/Program.cs new file mode 100644 index 0000000..f53c0a8 --- /dev/null +++ b/installer/AttackTracerNinjaBootstrapper/Program.cs @@ -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. + } + } + } +} diff --git a/installer/AttackTracerNinjaServerBootstrapper/AttackTracerNinjaServerBootstrapper.csproj b/installer/AttackTracerNinjaServerBootstrapper/AttackTracerNinjaServerBootstrapper.csproj new file mode 100644 index 0000000..23b1997 --- /dev/null +++ b/installer/AttackTracerNinjaServerBootstrapper/AttackTracerNinjaServerBootstrapper.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0-windows + enable + enable + false + AttackTracerNinjaServerBootstrapper + AttackTracerNinjaServerBootstrapper + 1.0.9 + + + + + + + diff --git a/installer/AttackTracerNinjaServerBootstrapper/Program.cs b/installer/AttackTracerNinjaServerBootstrapper/Program.cs new file mode 100644 index 0000000..c6653d0 --- /dev/null +++ b/installer/AttackTracerNinjaServerBootstrapper/Program.cs @@ -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 + { + } + } + } +} diff --git a/installer/README.txt b/installer/README.txt new file mode 100644 index 0000000..b4a3de6 --- /dev/null +++ b/installer/README.txt @@ -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\ diff --git a/installer/install-attacktracer-ninja.ps1 b/installer/install-attacktracer-ninja.ps1 new file mode 100644 index 0000000..2df014a --- /dev/null +++ b/installer/install-attacktracer-ninja.ps1 @@ -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')" diff --git a/installer/launch-install.cmd b/installer/launch-install.cmd new file mode 100644 index 0000000..61a0b68 --- /dev/null +++ b/installer/launch-install.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -ExecutionPolicy Bypass -File "%~dp0install-attacktracer-ninja.ps1" +exit /b %errorlevel% diff --git a/installer/runtime-build-attacktracer-org-report.ps1 b/installer/runtime-build-attacktracer-org-report.ps1 new file mode 100644 index 0000000..94073d2 --- /dev/null +++ b/installer/runtime-build-attacktracer-org-report.ps1 @@ -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('
') + [void]$sb.AppendLine("

AttackTracer Report - $(Escape-Html ((Get-Date).ToString("dd.MM.yyyy")))

") + [void]$sb.AppendLine("
$(Escape-Html ([string]$sortedReports.Count)) Geraete gescannt | $(Escape-Html ([string]$alertingReports.Count)) auffaellig | $(Escape-Html ([string]$criticalReports.Count)) kritisch | $(Escape-Html ([string]$totalEvents)) Events | $(Escape-Html ([string]$allIps.Count)) eindeutige IPs
") + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + + if ($alertRows.Count -eq 0) { + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + } + 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(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(' ') + } + } + + [void]$sb.AppendLine('
ServerVorfallKontoZeitpunktAnzahlIP
Keine Angriffe oder Korrelationen im ausgewerteten Bestand gefunden.
$(Escape-Html $row.MachineName)$(Escape-Html $row.Incident)$(Escape-Html $row.Account)$(Escape-Html $timestampText)$(Escape-Html ([string]$row.Count))$(Escape-Html $row.Ip)
') + + if ($cleanDevices.Count -gt 0) { + [void]$sb.AppendLine('
') + [void]$sb.AppendLine(" Log sauber / Keine Angriffe: $(Escape-Html ($cleanDevices -join ', '))") + [void]$sb.AppendLine('
') + } + + [void]$sb.AppendLine("
Automatisch generiert am $(Escape-Html $generatedAt)
") + [void]$sb.AppendLine('
') + + 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 +} diff --git a/installer/runtime-run-attacktracer-ninja-monitor.ps1 b/installer/runtime-run-attacktracer-ninja-monitor.ps1 new file mode 100644 index 0000000..5b43b58 --- /dev/null +++ b/installer/runtime-run-attacktracer-ninja-monitor.ps1 @@ -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 diff --git a/installer/runtime-run-attacktracer-ninja-server.ps1 b/installer/runtime-run-attacktracer-ninja-server.ps1 new file mode 100644 index 0000000..a0a9735 --- /dev/null +++ b/installer/runtime-run-attacktracer-ninja-server.ps1 @@ -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" diff --git a/installer/runtime-run-attacktracer-ninja.ps1 b/installer/runtime-run-attacktracer-ninja.ps1 new file mode 100644 index 0000000..07d837f --- /dev/null +++ b/installer/runtime-run-attacktracer-ninja.ps1 @@ -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 diff --git a/installer/server-install-attacktracer-ninja-server.ps1 b/installer/server-install-attacktracer-ninja-server.ps1 new file mode 100644 index 0000000..259264d --- /dev/null +++ b/installer/server-install-attacktracer-ninja-server.ps1 @@ -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')" diff --git a/installer/server-uninstall-attacktracer-ninja-server.ps1 b/installer/server-uninstall-attacktracer-ninja-server.ps1 new file mode 100644 index 0000000..b7ab22b --- /dev/null +++ b/installer/server-uninstall-attacktracer-ninja-server.ps1 @@ -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" diff --git a/installer/uninstall-attacktracer-ninja.ps1 b/installer/uninstall-attacktracer-ninja.ps1 new file mode 100644 index 0000000..ab9667d --- /dev/null +++ b/installer/uninstall-attacktracer-ninja.ps1 @@ -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" diff --git a/installer/update-attacktracer-ninja.ps1 b/installer/update-attacktracer-ninja.ps1 new file mode 100644 index 0000000..30573c3 --- /dev/null +++ b/installer/update-attacktracer-ninja.ps1 @@ -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" diff --git a/samples/ninja-vulnerability-export.example.csv b/samples/ninja-vulnerability-export.example.csv new file mode 100644 index 0000000..7b05a6f --- /dev/null +++ b/samples/ninja-vulnerability-export.example.csv @@ -0,0 +1,4 @@ +device,cve,severity,cvss,remediation +LUKASOC,CVE-2026-0001,Critical,9.8,Install vendor patch KB123456 +LUKASOC,CVE-2025-9999,High,8.1,Upgrade affected application +OTHERHOST,CVE-2024-1111,Medium,5.4,Pending normal patch cycle diff --git a/samples/webhook-payload.example.json b/samples/webhook-payload.example.json new file mode 100644 index 0000000..7148bb7 --- /dev/null +++ b/samples/webhook-payload.example.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": "2.0", + "machineName": "WSUS", + "generatedAtLocal": "2026-07-16T20:15:00+02:00", + "generatedAtUtc": "2026-07-16T18:15:00+00:00", + "clientVersion": "2.0.0", + "lookbackDays": 7, + "totalEvents": 0, + "uniqueIpCount": 0, + "alertState": "ok", + "alertReason": "No thresholds exceeded.", + "baseAlertState": "ok", + "baseAlertReason": "No thresholds exceeded.", + "vulnerabilityCorrelation": { + "sourcePath": "", + "totalCount": 0, + "criticalCount": 0, + "highCvssCount": 0, + "findings": [] + }, + "runtime": { + "startedAtUtc": "2026-07-16T18:14:58+00:00", + "finishedAtUtc": "2026-07-16T18:15:00+00:00", + "uploadAttempted": true + }, + "events": [], + "topSources": [], + "errors": [] +} diff --git a/scripts/build-attacktracer-installer.ps1 b/scripts/build-attacktracer-installer.ps1 new file mode 100644 index 0000000..0ea0610 --- /dev/null +++ b/scripts/build-attacktracer-installer.ps1 @@ -0,0 +1,99 @@ +param( + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\AttackTracerNinjaCli\AttackTracerNinjaCli.csproj" +$installerRoot = Join-Path $repoRoot "installer" +$artifactsRoot = Join-Path $repoRoot "artifacts" +$publishRoot = Join-Path $artifactsRoot "publish\win-x64" +$packageRoot = Join-Path $artifactsRoot "installer-payload" +$bootstrapperProject = Join-Path $repoRoot "installer\AttackTracerNinjaBootstrapper\AttackTracerNinjaBootstrapper.csproj" +$bootstrapperPayload = Join-Path $repoRoot "installer\AttackTracerNinjaBootstrapper\payload.zip" +$bootstrapperPublish = Join-Path $artifactsRoot "bootstrapper\win-x64" +$outputExe = Join-Path $artifactsRoot "AttackTracerNinjaSetup.exe" + +function Get-ShortPath([string]$path) { + $resolved = [System.IO.Path]::GetFullPath($path) + $cmdPath = $resolved.Replace('"', '""') + $shortPath = cmd /c "for %I in (""$cmdPath"") do @echo %~sI" + return ($shortPath | Select-Object -Last 1).Trim() +} + +[xml]$projectXml = Get-Content $projectPath +$version = $projectXml.Project.PropertyGroup.Version | Select-Object -First 1 +if ([string]::IsNullOrWhiteSpace($version)) { + $version = "1.2.2" +} + +Write-Host "Publishing AttackTracerNinja version $version" + +if (Test-Path $publishRoot) { Remove-Item -LiteralPath $publishRoot -Recurse -Force } +if (Test-Path $packageRoot) { Remove-Item -LiteralPath $packageRoot -Recurse -Force } +if (Test-Path $bootstrapperPublish) { Remove-Item -LiteralPath $bootstrapperPublish -Recurse -Force } +if (Test-Path $bootstrapperPayload) { Remove-Item -LiteralPath $bootstrapperPayload -Force } +if (Test-Path $outputExe) { Remove-Item -LiteralPath $outputExe -Force } +New-Item -ItemType Directory -Force -Path $publishRoot, $packageRoot, $bootstrapperPublish | Out-Null + +& dotnet restore $projectPath -r win-x64 +if ($LASTEXITCODE -ne 0) { + throw "dotnet restore failed" +} + +& dotnet publish $projectPath ` + -c $Configuration ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -o $publishRoot + +if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed" +} + +Copy-Item -Path (Join-Path $publishRoot "AttackTracerNinjaCli.exe") -Destination (Join-Path $packageRoot "AttackTracerNinjaCli.exe") -Force +if (Test-Path (Join-Path $publishRoot "AttackTracerNinjaCli.pdb")) { + Copy-Item -Path (Join-Path $publishRoot "AttackTracerNinjaCli.pdb") -Destination (Join-Path $packageRoot "AttackTracerNinjaCli.pdb") -Force +} +Copy-Item -Path (Join-Path $repoRoot "config\attacktracer-settings.example.json") -Destination (Join-Path $packageRoot "attacktracer-settings.example.json") -Force +Copy-Item -Path (Join-Path $repoRoot "config\attacktracer-client.example.json") -Destination (Join-Path $packageRoot "attacktracer-client.example.json") -Force +Copy-Item -Path (Join-Path $repoRoot "config\update-channel.example.json") -Destination (Join-Path $packageRoot "update-channel.example.json") -Force +Copy-Item -Path (Join-Path $repoRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $packageRoot "ninja-vulnerability-export.example.csv") -Force +Copy-Item -Path (Join-Path $installerRoot "install-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "install-attacktracer-ninja.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "launch-install.cmd") -Destination (Join-Path $packageRoot "launch-install.cmd") -Force +Copy-Item -Path (Join-Path $installerRoot "uninstall-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "uninstall-attacktracer-ninja.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "runtime-run-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "run-attacktracer-ninja.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "runtime-run-attacktracer-ninja-monitor.ps1") -Destination (Join-Path $packageRoot "run-attacktracer-ninja-monitor.ps1") -Force +Copy-Item -Path (Join-Path $repoRoot "scripts\protect-attacktracer-secret.ps1") -Destination (Join-Path $packageRoot "protect-attacktracer-secret.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "update-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "update-attacktracer-ninja.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "runtime-build-attacktracer-org-report.ps1") -Destination (Join-Path $packageRoot "build-attacktracer-org-report.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "README.txt") -Destination (Join-Path $packageRoot "README.txt") -Force +Set-Content -Path (Join-Path $packageRoot "VERSION.txt") -Value $version -NoNewline + +Write-Host "Creating embedded payload zip" +Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $bootstrapperPayload -CompressionLevel Optimal -Force + +Write-Host "Publishing bootstrapper installer" +& dotnet restore $bootstrapperProject -r win-x64 +if ($LASTEXITCODE -ne 0) { + throw "Bootstrapper restore failed" +} + +& dotnet publish $bootstrapperProject ` + -c $Configuration ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -o $bootstrapperPublish + +if ($LASTEXITCODE -ne 0) { + throw "Bootstrapper publish failed" +} + +Copy-Item -Path (Join-Path $bootstrapperPublish "AttackTracerNinjaBootstrapper.exe") -Destination $outputExe -Force + +Write-Host "Installer created at $outputExe" diff --git a/scripts/build-attacktracer-org-report.ps1 b/scripts/build-attacktracer-org-report.ps1 new file mode 100644 index 0000000..0590cba --- /dev/null +++ b/scripts/build-attacktracer-org-report.ps1 @@ -0,0 +1,306 @@ +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('
') + [void]$sb.AppendLine("

AttackTracer Report - $(Escape-Html ((Get-Date).ToString("dd.MM.yyyy")))

") + [void]$sb.AppendLine("
$(Escape-Html ([string]$sortedReports.Count)) Geraete gescannt | $(Escape-Html ([string]$alertingReports.Count)) auffaellig | $(Escape-Html ([string]$criticalReports.Count)) kritisch | $(Escape-Html ([string]$totalEvents)) Events | $(Escape-Html ([string]$allIps.Count)) eindeutige IPs
") + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + + if ($alertRows.Count -eq 0) { + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + } + 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(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(" ") + [void]$sb.AppendLine(' ') + } + } + + [void]$sb.AppendLine('
ServerVorfallKontoZeitpunktAnzahlIP
Keine Angriffe oder Korrelationen im ausgewerteten Bestand gefunden.
$(Escape-Html $row.MachineName)$(Escape-Html $row.Incident)$(Escape-Html $row.Account)$(Escape-Html $timestampText)$(Escape-Html ([string]$row.Count))$(Escape-Html $row.Ip)
') + + if ($cleanDevices.Count -gt 0) { + [void]$sb.AppendLine('
') + [void]$sb.AppendLine(" Log sauber / Keine Angriffe: $(Escape-Html ($cleanDevices -join ', '))") + [void]$sb.AppendLine('
') + } + + [void]$sb.AppendLine("
Automatisch generiert am $(Escape-Html $generatedAt)
") + [void]$sb.AppendLine('
') + + 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." +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$reportsRootPath = Resolve-PathLike -PathValue $ReportsRoot -BasePath $repoRoot +$outputPathFull = Resolve-PathLike -PathValue $OutputPath -BasePath $repoRoot + +$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 +} diff --git a/scripts/build-attacktracer-server-installer.ps1 b/scripts/build-attacktracer-server-installer.ps1 new file mode 100644 index 0000000..1457318 --- /dev/null +++ b/scripts/build-attacktracer-server-installer.ps1 @@ -0,0 +1,55 @@ +param( + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$installerRoot = Join-Path $repoRoot "installer" +$artifactsRoot = Join-Path $repoRoot "artifacts" +$packageRoot = Join-Path $artifactsRoot "server-installer-payload" +$bootstrapperProject = Join-Path $repoRoot "installer\AttackTracerNinjaServerBootstrapper\AttackTracerNinjaServerBootstrapper.csproj" +$bootstrapperPayload = Join-Path $repoRoot "installer\AttackTracerNinjaServerBootstrapper\payload.zip" +$bootstrapperPublish = Join-Path $artifactsRoot "server-bootstrapper\win-x64" +$outputExe = Join-Path $artifactsRoot "AttackTracerNinjaServerSetup.exe" +$version = "1.0.9" + +Write-Host "Publishing AttackTracerNinjaServer version $version" + +if (Test-Path $packageRoot) { Remove-Item -LiteralPath $packageRoot -Recurse -Force } +if (Test-Path $bootstrapperPublish) { Remove-Item -LiteralPath $bootstrapperPublish -Recurse -Force } +if (Test-Path $bootstrapperPayload) { Remove-Item -LiteralPath $bootstrapperPayload -Force } +if (Test-Path $outputExe) { Remove-Item -LiteralPath $outputExe -Force } +New-Item -ItemType Directory -Force -Path $packageRoot, $bootstrapperPublish | Out-Null + +Copy-Item -Path (Join-Path $repoRoot "config\attacktracer-server-settings.example.json") -Destination (Join-Path $packageRoot "attacktracer-server-settings.example.json") -Force +Copy-Item -Path (Join-Path $installerRoot "server-install-attacktracer-ninja-server.ps1") -Destination (Join-Path $packageRoot "install-attacktracer-ninja-server.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "server-uninstall-attacktracer-ninja-server.ps1") -Destination (Join-Path $packageRoot "uninstall-attacktracer-ninja-server.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "runtime-build-attacktracer-org-report.ps1") -Destination (Join-Path $packageRoot "build-attacktracer-org-report.ps1") -Force +Copy-Item -Path (Join-Path $installerRoot "runtime-run-attacktracer-ninja-server.ps1") -Destination (Join-Path $packageRoot "run-attacktracer-ninja-server.ps1") -Force +Set-Content -Path (Join-Path $packageRoot "VERSION.txt") -Value $version -NoNewline + +Write-Host "Creating embedded payload zip" +Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $bootstrapperPayload -CompressionLevel Optimal -Force + +Write-Host "Publishing server bootstrapper installer" +& dotnet restore $bootstrapperProject -r win-x64 +if ($LASTEXITCODE -ne 0) { + throw "Bootstrapper restore failed" +} + +& dotnet publish $bootstrapperProject ` + -c $Configuration ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -o $bootstrapperPublish + +if ($LASTEXITCODE -ne 0) { + throw "Bootstrapper publish failed" +} + +Copy-Item -Path (Join-Path $bootstrapperPublish "AttackTracerNinjaServerBootstrapper.exe") -Destination $outputExe -Force + +Write-Host "Server installer created at $outputExe" diff --git a/scripts/extract-attacktracer.ps1 b/scripts/extract-attacktracer.ps1 new file mode 100644 index 0000000..45b422c --- /dev/null +++ b/scripts/extract-attacktracer.ps1 @@ -0,0 +1,72 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$setupExe = Join-Path $repoRoot "SetupAttackTracer.exe" +$payloadDir = Join-Path $repoRoot "payload" +$msiAdminDir = Join-Path $repoRoot "msi-admin" +$decompiledDir = Join-Path $repoRoot "decompiled\AttackTracer" +$ilspy = Join-Path $env:USERPROFILE ".dotnet\tools\ilspycmd.exe" + +if (-not (Test-Path $setupExe)) { + throw "SetupAttackTracer.exe not found at $setupExe" +} + +if (-not (Test-Path $ilspy)) { + throw "ilspycmd.exe not found at $ilspy" +} + +New-Item -ItemType Directory -Force -Path $payloadDir | Out-Null +New-Item -ItemType Directory -Force -Path $msiAdminDir | Out-Null +New-Item -ItemType Directory -Force -Path $decompiledDir | Out-Null + +$tempBefore = @(Get-ChildItem $env:TEMP -Directory | Select-Object -ExpandProperty FullName) +$proc = Start-Process -FilePath $setupExe -PassThru +Start-Sleep -Seconds 4 + +$tempAfter = @(Get-ChildItem $env:TEMP -Directory | Select-Object -ExpandProperty FullName) +$newTempDirs = Compare-Object $tempBefore $tempAfter | + Where-Object SideIndicator -eq "=>" | + Select-Object -ExpandProperty InputObject + +try { + if (-not $newTempDirs) { + throw "No new temp directory detected while launching SetupAttackTracer.exe" + } + + $payloadSource = $null + foreach ($dir in $newTempDirs) { + if (Test-Path (Join-Path $dir "AttackTracer.msi")) { + $payloadSource = $dir + break + } + } + + if (-not $payloadSource) { + throw "Could not locate AttackTracer.msi in the installer temp directories" + } + + Copy-Item -Path (Join-Path $payloadSource "*") -Destination $payloadDir -Recurse -Force + + $msiPath = Join-Path $payloadDir "AttackTracer.msi" + if (-not (Test-Path $msiPath)) { + throw "AttackTracer.msi was not copied into $payloadDir" + } + + & msiexec /a $msiPath /qn TARGETDIR=$msiAdminDir + + $appExe = Join-Path $msiAdminDir "program files\Servolutions\BotFence\AttackTracer.exe" + if (-not (Test-Path $appExe)) { + throw "Deployed application executable not found at $appExe" + } + + & $ilspy -p -o $decompiledDir $appExe + + Write-Host "Payload extracted to: $payloadDir" + Write-Host "MSI admin image: $msiAdminDir" + Write-Host "Decompiled sources: $decompiledDir" +} +finally { + if ($proc -and -not $proc.HasExited) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/protect-attacktracer-secret.ps1 b/scripts/protect-attacktracer-secret.ps1 new file mode 100644 index 0000000..edc56d2 --- /dev/null +++ b/scripts/protect-attacktracer-secret.ps1 @@ -0,0 +1,25 @@ +param( + [Parameter(Mandatory)] + [string]$SecretValue, + [string]$OutputPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat" +) + +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName System.Security + +$outputFullPath = [System.IO.Path]::GetFullPath($OutputPath) +$outputDirectory = Split-Path -Parent $outputFullPath +if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +$secretBytes = [System.Text.Encoding]::UTF8.GetBytes($SecretValue) +$protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect( + $secretBytes, + $null, + [System.Security.Cryptography.DataProtectionScope]::LocalMachine +) + +[System.IO.File]::WriteAllBytes($outputFullPath, $protectedBytes) +Write-Host "Protected secret written to $outputFullPath" diff --git a/scripts/run-attacktracer-ninja-monitor.ps1 b/scripts/run-attacktracer-ninja-monitor.ps1 new file mode 100644 index 0000000..778db62 --- /dev/null +++ b/scripts/run-attacktracer-ninja-monitor.ps1 @@ -0,0 +1,213 @@ +param( + [int]$LookbackDays = 7, + [int]$TopCount = 10, + [string]$OutputPath = ".\reports\attacktracer-summary.json", + [string]$ConfigPath = ".\config\attacktracer-settings.example.json", + [string]$VulnerabilityCsvPath = "", + [string]$MirrorRoot = "", + [ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")] + [string]$Mode = "status" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$runnerScript = Join-Path $repoRoot "scripts\run-attacktracer-ninja.ps1" +$outputFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $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]@{ + "attacktracerstatus" = [string]$Report.AlertState + "attacktracerreason" = $Reason + "attacktracerbasestatus" = [string]$Report.BaseAlertState + "attacktracerevents" = [string]([int]$Report.TotalEvents) + "attacktraceruniqueips" = [string]([int]$Report.UniqueIpCount) + "attacktracercvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount) + "attacktracercvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount) + "attacktracermode" = $Mode + "attacktracertriggered" = $Triggered.ToString().ToLowerInvariant() + "attacktracerlastscanutc" = $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 $repoRoot)) +} + +$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 "AttackTracer Ninja 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 diff --git a/scripts/run-attacktracer-ninja-server.ps1 b/scripts/run-attacktracer-ninja-server.ps1 new file mode 100644 index 0000000..80f04d2 --- /dev/null +++ b/scripts/run-attacktracer-ninja-server.ps1 @@ -0,0 +1,307 @@ +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 " || ") +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$configBasePath = $PSScriptRoot +$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 $repoRoot +$htmlOutputPath = Resolve-PathLike -PathValue $config.htmlOutputPath -BasePath $repoRoot +$orgReportsScript = Join-Path $repoRoot "scripts\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" diff --git a/scripts/run-attacktracer-ninja.ps1 b/scripts/run-attacktracer-ninja.ps1 new file mode 100644 index 0000000..0e33d84 --- /dev/null +++ b/scripts/run-attacktracer-ninja.ps1 @@ -0,0 +1,144 @@ +param( + [int]$LookbackDays = 7, + [int]$TopCount = 10, + [string]$OutputPath = ".\reports\attacktracer-summary.json", + [string]$ConfigPath = ".\config\attacktracer-settings.example.json", + [string]$ClientConfigPath = ".\config\attacktracer-client.example.json", + [string]$SecretPath = "", + [string]$VulnerabilityCsvPath = "", + [string]$MirrorRoot = "", + [ValidateSet("disabled", "auto", "required")] + [string]$UploadMode = "auto", + [switch]$FailOnAttacks, + [switch]$FailOnThreshold +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\AttackTracerNinjaCli\AttackTracerNinjaCli.csproj" +$outputFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputPath)) +$buildOutputDir = Join-Path $repoRoot "src\AttackTracerNinjaCli\bin\Debug\net10.0" +$dllPath = Join-Path $buildOutputDir "AttackTracerNinjaCli.dll" + +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)) +} + +$arguments = @( + $dllPath +) + +$configFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $ConfigPath)) +if ($UploadMode -eq "disabled") { + $arguments += "scan" +} +else { + $clientConfigFullPath = Resolve-PathLike -PathValue $ClientConfigPath -BasePath $repoRoot + $secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $repoRoot } + $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 = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $VulnerabilityCsvPath)) + if (Test-Path $vulnerabilityCsvFullPath) { + $arguments += @("--vulnerability-csv", $vulnerabilityCsvFullPath) + } +} + +if ($FailOnAttacks) { + $arguments += "--fail-on-attacks" +} + +if ($FailOnThreshold) { + $arguments += "--fail-on-threshold" +} + +$null = & dotnet build $projectPath +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +if (-not (Test-Path $dllPath)) { + throw "Expected compiled DLL was not created: $dllPath" +} + +& dotnet @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 $repoRoot + 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 "Ninja wrapper 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 diff --git a/src/AttackTracerNinjaCli/AttackScanner.cs b/src/AttackTracerNinjaCli/AttackScanner.cs new file mode 100644 index 0000000..2f72dca --- /dev/null +++ b/src/AttackTracerNinjaCli/AttackScanner.cs @@ -0,0 +1,477 @@ +using System.Diagnostics.Eventing.Reader; +using System.Globalization; +using System.Net; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; + +namespace AttackTracerNinjaCli; + +[SupportedOSPlatform("windows")] +internal sealed class AttackScanner +{ + private static readonly string[] DefaultFtpRoots = + [ + @"C:\inetpub\logs\LogFiles", + @"D:\inetpub\logs\LogFiles" + ]; + + private static readonly string[] DefaultFileZillaRoots = + [ + @"C:\Program Files (x86)\FileZilla Server\Logs", + @"D:\Program Files (x86)\FileZilla Server\Logs" + ]; + + public ScanResult Run(ScanOptions options) + { + DateTimeOffset startedAtUtc = DateTimeOffset.UtcNow; + ScannerConfiguration configuration = LoadConfiguration(options); + var attacks = new List(); + var errors = new List(); + DateTimeOffset since = DateTimeOffset.Now.AddDays(-options.LookbackDays); + + ScanWindowsLogons(attacks, errors, since); + ScanSqlLogons(attacks, errors, since); + ScanExchangeLogons(attacks, errors, since); + ScanIisFtpLogs(attacks, errors, since, configuration); + ScanFileZillaLogs(attacks, errors, since, configuration); + + if (configuration.ExcludedIps.Count > 0) + { + attacks = attacks + .Where(attack => !configuration.ExcludedIps.Contains(attack.SourceIp, StringComparer.OrdinalIgnoreCase)) + .ToList(); + } + + attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp)); + + List topSources = attacks + .GroupBy(static attack => attack.SourceIp) + .Select(group => AggregatedAttack.FromGroup(group)) + .OrderByDescending(static aggregate => aggregate.Count) + .ThenBy(static aggregate => aggregate.SourceIp, StringComparer.OrdinalIgnoreCase) + .Take(options.TopCount) + .ToList(); + + int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration); + string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState); + VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath) + ? VulnerabilityCorrelationSummary.Empty() + : VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors); + CorrelationAssessment correlationAssessment = VulnerabilityCorrelation.Assess(baseAlertState, attacks.Count, vulnerabilityCorrelation, configuration); + DateTimeOffset generatedAtLocal = DateTimeOffset.Now; + DateTimeOffset generatedAtUtc = DateTimeOffset.UtcNow; + + return new ScanResult + { + SchemaVersion = "2.0", + MachineName = Environment.MachineName, + GeneratedAtLocal = generatedAtLocal, + GeneratedAtUtc = generatedAtUtc, + ClientVersion = BuildMetadata.Version, + LookbackDays = options.LookbackDays, + TotalEvents = attacks.Count, + UniqueIpCount = uniqueIpCount, + AlertState = correlationAssessment.FinalAlertState, + AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason, + BaseAlertState = baseAlertState, + BaseAlertReason = baseAlertReason, + VulnerabilityCorrelation = vulnerabilityCorrelation, + Runtime = new ScanRuntimeMetadata + { + StartedAtUtc = startedAtUtc, + FinishedAtUtc = generatedAtUtc, + UploadAttempted = false + }, + Events = attacks, + TopSources = topSources, + Errors = errors + }; + } + + private static ScannerConfiguration LoadConfiguration(ScanOptions options) + { + if (string.IsNullOrWhiteSpace(options.ConfigPath)) + { + return new ScannerConfiguration(); + } + + return ScannerConfiguration.Load(options.ConfigPath); + } + + private static void ScanWindowsLogons(List attacks, List errors, DateTimeOffset since) + { + const string query = "*[System/EventID=4625]"; + TryScanEventLog("Security", query, errors, eventRecord => + { + if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp)) + { + return; + } + + string ip = ReadProperty(eventRecord, 19); + if (string.IsNullOrWhiteSpace(ip) || ip == "-") + { + return; + } + + AddAttack(attacks, new AttackEvent + { + Timestamp = timestamp, + SourceIp = ip, + Target = "Windows login", + Username = ReadProperty(eventRecord, 5, "[unknown]"), + Source = "Security", + InstanceId = 4625 + }); + }); + } + + private static void ScanSqlLogons(List attacks, List errors, DateTimeOffset since) + { + const string query = "*[System/EventID=18456]"; + TryScanEventLog("Application", query, errors, eventRecord => + { + if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp)) + { + return; + } + + string ip = ExtractIp(ReadProperty(eventRecord, 2)); + if (string.IsNullOrWhiteSpace(ip)) + { + return; + } + + AddAttack(attacks, new AttackEvent + { + Timestamp = timestamp, + SourceIp = ip, + Target = "SQL Server", + Username = ReadProperty(eventRecord, 0, "[unknown]"), + Source = "Application", + InstanceId = 18456 + }); + }); + } + + private static void ScanExchangeLogons(List attacks, List errors, DateTimeOffset since) + { + const string query = "*[System/EventID=1035]"; + TryScanEventLog("Application", query, errors, eventRecord => + { + if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp)) + { + return; + } + + string ip = ReadProperty(eventRecord, 3); + if (string.IsNullOrWhiteSpace(ip) || ip.Length <= 4) + { + return; + } + + AddAttack(attacks, new AttackEvent + { + Timestamp = timestamp, + SourceIp = ip, + Target = "Exchange", + Username = "[not logged]", + Source = "Application", + InstanceId = 1035 + }); + }); + } + + private static void ScanIisFtpLogs(List attacks, List errors, DateTimeOffset since, ScannerConfiguration configuration) + { + IEnumerable roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots; + foreach (string root in roots) + { + try + { + if (!Directory.Exists(root)) + { + continue; + } + + foreach (string directory in Directory.GetDirectories(root, "FTP*")) + { + foreach (string file in Directory.GetFiles(directory, "*.log")) + { + ParseIisFtpLogFile(file, attacks, errors, since); + } + } + } + catch (Exception ex) + { + errors.Add($"FTP scan failed for {root}: {ex.Message}"); + } + } + } + + private static void ParseIisFtpLogFile(string filePath, List attacks, List errors, DateTimeOffset since) + { + string username = "[unknown]"; + + try + { + foreach (string line in File.ReadLines(filePath)) + { + if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) + { + continue; + } + + string[] parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 9) + { + continue; + } + + if (!DateTime.TryParse( + $"{parts[0]} {parts[1]}", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime timestampUtc)) + { + continue; + } + + var timestamp = new DateTimeOffset(timestampUtc).ToLocalTime(); + if (timestamp < since) + { + continue; + } + + string sourceIp = parts[2]; + string command = parts[6]; + string parameter = parts[7]; + string status = parts[8]; + + if (string.Equals(command, "USER", StringComparison.OrdinalIgnoreCase)) + { + username = parameter; + } + else if (string.Equals(command, "PASS", StringComparison.OrdinalIgnoreCase) + && !string.Equals(status, "230", StringComparison.OrdinalIgnoreCase)) + { + AddAttack(attacks, new AttackEvent + { + Timestamp = timestamp, + SourceIp = sourceIp, + Target = "FTP login", + Username = username, + Source = "IIS FTP", + InstanceId = -1 + }); + } + } + } + catch (Exception ex) + { + errors.Add($"FTP log parse failed for {filePath}: {ex.Message}"); + } + } + + private static void ScanFileZillaLogs(List attacks, List errors, DateTimeOffset since, ScannerConfiguration configuration) + { + IEnumerable roots = configuration.FileZillaRoots.Count > 0 ? configuration.FileZillaRoots : DefaultFileZillaRoots; + foreach (string root in roots) + { + try + { + if (!Directory.Exists(root)) + { + continue; + } + + foreach (string file in Directory.GetFiles(root, "*.log")) + { + ParseFileZillaLogFile(file, attacks, errors, since); + } + } + catch (Exception ex) + { + errors.Add($"FileZilla scan failed for {root}: {ex.Message}"); + } + } + } + + private static void ParseFileZillaLogFile(string filePath, List attacks, List errors, DateTimeOffset since) + { + string username = "[unknown]"; + + try + { + foreach (string line in File.ReadLines(filePath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + int closeParen = line.IndexOf(')'); + if (closeParen < 0) + { + continue; + } + + string remainder = line[(closeParen + 1)..]; + int dash = remainder.IndexOf('-'); + if (dash < 0) + { + continue; + } + + string timestampText = remainder[..dash].Trim(); + remainder = remainder[(dash + 1)..].TrimStart(); + + if (!DateTime.TryParse(timestampText, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedTimestamp)) + { + continue; + } + + DateTimeOffset timestamp = new(parsedTimestamp); + if (timestamp < since) + { + continue; + } + + if (remainder.Contains("(not logged in)", StringComparison.OrdinalIgnoreCase)) + { + int idx = remainder.IndexOf(')'); + if (idx >= 0 && idx + 1 < remainder.Length) + { + remainder = remainder[(idx + 1)..]; + } + } + + int openIp = remainder.IndexOf('('); + int closeIp = remainder.IndexOf(')'); + if (openIp < 0 || closeIp <= openIp) + { + continue; + } + + string ip = remainder[(openIp + 1)..closeIp].Trim(); + string message = remainder[(closeIp + 1)..].Trim(); + + if (message.Contains("password incorrect", StringComparison.OrdinalIgnoreCase)) + { + AddAttack(attacks, new AttackEvent + { + Timestamp = timestamp, + SourceIp = ip, + Target = "FileZilla FTP login", + Username = username, + Source = "FileZilla", + InstanceId = -1 + }); + } + else if (message.Contains("> USER", StringComparison.OrdinalIgnoreCase)) + { + int userIndex = message.IndexOf("> USER", StringComparison.OrdinalIgnoreCase); + username = message[(userIndex + 6)..].Trim(); + } + } + } + catch (Exception ex) + { + errors.Add($"FileZilla log parse failed for {filePath}: {ex.Message}"); + } + } + + private static void TryScanEventLog(string logName, string query, List errors, Action processRecord) + { + try + { + using var reader = new EventLogReader(new EventLogQuery(logName, PathType.LogName, query)); + for (EventRecord? eventRecord = reader.ReadEvent(); eventRecord != null; eventRecord = reader.ReadEvent()) + { + using (eventRecord) + { + processRecord(eventRecord); + } + } + } + catch (Exception ex) + { + errors.Add($"Event log scan failed for {logName} ({query}): {ex.Message}"); + } + } + + private static bool TryGetTimestamp(EventRecord eventRecord, DateTimeOffset since, out DateTimeOffset timestamp) + { + if (!eventRecord.TimeCreated.HasValue) + { + timestamp = default; + return false; + } + + timestamp = new DateTimeOffset(eventRecord.TimeCreated.Value); + return timestamp >= since; + } + + private static string ReadProperty(EventRecord eventRecord, int index, string fallback = "") + { + if (index < 0 || index >= eventRecord.Properties.Count) + { + return fallback; + } + + object? value = eventRecord.Properties[index].Value; + return value?.ToString()?.Trim() ?? fallback; + } + + private static void AddAttack(List attacks, AttackEvent attack) + { + if (string.IsNullOrWhiteSpace(attack.SourceIp)) + { + return; + } + + if (!LooksLikeIpAddress(attack.SourceIp)) + { + return; + } + + attacks.Add(attack with { SourceIp = attack.SourceIp.Trim() }); + } + + private static string ExtractIp(string input) + { + Match match = Regex.Match(input, @"\b(?:\d{1,3}\.){3}\d{1,3}\b"); + return match.Success ? match.Value : string.Empty; + } + + private static bool LooksLikeIpAddress(string input) + { + return IPAddress.TryParse(input, out _); + } + + private static string GetAlertState(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration) + { + if (totalEvents >= configuration.CriticalEventThreshold || uniqueIpCount >= configuration.CriticalUniqueIpThreshold) + { + return "critical"; + } + + if (totalEvents >= configuration.WarningEventThreshold || uniqueIpCount >= configuration.WarningUniqueIpThreshold) + { + return "warning"; + } + + return "ok"; + } + + private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState) + { + return alertState switch + { + "critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.", + "warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.", + _ => "No thresholds exceeded." + }; + } +} diff --git a/src/AttackTracerNinjaCli/AttackTracerNinjaCli.csproj b/src/AttackTracerNinjaCli/AttackTracerNinjaCli.csproj new file mode 100644 index 0000000..ce4ee80 --- /dev/null +++ b/src/AttackTracerNinjaCli/AttackTracerNinjaCli.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + OCSentinelCli + OCSentinelCli + OfficeCom Sentinel + OfficeCom + 1.2.3 + 1.2.3.0 + 1.2.3.0 + 1.2.3 + + + + + + + + diff --git a/src/AttackTracerNinjaCli/BuildMetadata.cs b/src/AttackTracerNinjaCli/BuildMetadata.cs new file mode 100644 index 0000000..613e7f2 --- /dev/null +++ b/src/AttackTracerNinjaCli/BuildMetadata.cs @@ -0,0 +1,11 @@ +using System.Reflection; + +namespace AttackTracerNinjaCli; + +internal static class BuildMetadata +{ + public static string Version => + Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion + ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() + ?? "0.0.0"; +} diff --git a/src/AttackTracerNinjaCli/Commands/ScanAndUploadCommand.cs b/src/AttackTracerNinjaCli/Commands/ScanAndUploadCommand.cs new file mode 100644 index 0000000..f7149d2 --- /dev/null +++ b/src/AttackTracerNinjaCli/Commands/ScanAndUploadCommand.cs @@ -0,0 +1,41 @@ +namespace AttackTracerNinjaCli.Commands; + +internal static class ScanAndUploadCommand +{ + public static int Execute(string[] args) + { + string outputPath = @"C:\ProgramData\AttackTracerNinja\reports\latest.json"; + bool hasOutput = false; + + for (int i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "--output", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + { + outputPath = args[i + 1]; + hasOutput = true; + break; + } + } + + List scanArgs = [.. args]; + if (!hasOutput) + { + scanArgs.Add("--output"); + scanArgs.Add(outputPath); + } + + int scanExitCode = ScanCommand.Execute([.. scanArgs]); + if (scanExitCode != 0) + { + return scanExitCode; + } + + var uploadArgs = new List + { + "--report", + outputPath + }; + + return UploadCommand.Execute([.. uploadArgs]); + } +} diff --git a/src/AttackTracerNinjaCli/Commands/ScanCommand.cs b/src/AttackTracerNinjaCli/Commands/ScanCommand.cs new file mode 100644 index 0000000..61afac1 --- /dev/null +++ b/src/AttackTracerNinjaCli/Commands/ScanCommand.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using System.Runtime.Versioning; + +namespace AttackTracerNinjaCli.Commands; + +[SupportedOSPlatform("windows")] +internal static class ScanCommand +{ + public static int Execute(string[] args) + { + ScanOptions options; + + try + { + options = ScanOptions.Parse(args); + } + catch (ArgumentException ex) + { + Console.Error.WriteLine(ex.Message); + Console.Error.WriteLine(); + Console.Error.WriteLine(ScanOptions.Usage); + return 2; + } + + if (options.ShowHelp) + { + Console.WriteLine(ScanOptions.Usage); + return 0; + } + + try + { + var scanner = new AttackScanner(); + ScanResult result = scanner.Run(options); + string json = JsonSerializer.Serialize(result, JsonOptions.Default); + + if (!string.IsNullOrWhiteSpace(options.OutputPath)) + { + string outputPath = Path.GetFullPath(options.OutputPath); + string? outputDirectory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + File.WriteAllText(outputPath, json); + } + + WriteConsoleOutput(result, options, json); + + if (options.FailOnThreshold && !string.Equals(result.AlertState, "ok", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + + return options.FailOnAttacks && result.TotalEvents > 0 ? 1 : 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Scanner failed: {ex}"); + return 1; + } + } + + private static void WriteConsoleOutput(ScanResult result, ScanOptions options, string json) + { + if (!options.JsonOnly) + { + Console.WriteLine($"OfficeCom Sentinel summary for {result.MachineName}"); + Console.WriteLine($"Generated: {result.GeneratedAtLocal:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine($"Events: {result.TotalEvents}"); + Console.WriteLine($"Unique IPs: {result.UniqueIpCount}"); + Console.WriteLine($"Status: {result.AlertState}"); + Console.WriteLine($"Reason: {result.AlertReason}"); + Console.WriteLine($"Base status: {result.BaseAlertState}"); + Console.WriteLine($"Scan errors: {result.Errors.Count}"); + Console.WriteLine(); + + if (result.TopSources.Count == 0) + { + Console.WriteLine("No attacks found in the selected lookback window."); + } + else + { + Console.WriteLine("Top source IPs:"); + for (int i = 0; i < result.TopSources.Count; i++) + { + AggregatedAttack aggregate = result.TopSources[i]; + Console.WriteLine($"{i + 1}. {aggregate.SourceIp} | {aggregate.Count} hits | {aggregate.FirstSeenLocal:yyyy-MM-dd HH:mm:ss} -> {aggregate.LastSeenLocal:yyyy-MM-dd HH:mm:ss} | {aggregate.RateLabel}"); + Console.WriteLine($" Targets: {string.Join(", ", aggregate.Targets)} | Users: {string.Join(", ", aggregate.Usernames)}"); + } + } + + if (result.VulnerabilityCorrelation.TotalCount > 0) + { + Console.WriteLine(); + Console.WriteLine("Vulnerability correlation:"); + Console.WriteLine($"CVE findings for this host: {result.VulnerabilityCorrelation.TotalCount}"); + Console.WriteLine($"Critical/High findings: {result.VulnerabilityCorrelation.CriticalCount}"); + Console.WriteLine($"CVSS >= 8 findings: {result.VulnerabilityCorrelation.HighCvssCount}"); + } + + if (!string.IsNullOrWhiteSpace(options.OutputPath)) + { + Console.WriteLine(); + Console.WriteLine($"JSON report written to {Path.GetFullPath(options.OutputPath)}"); + } + + if (result.Errors.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Warnings:"); + foreach (string error in result.Errors) + { + Console.WriteLine($"- {error}"); + } + } + } + else + { + Console.WriteLine(json); + } + + if (options.NinjaOutput) + { + Console.WriteLine(); + Console.WriteLine($"ATTACKTRACER_STATUS={result.AlertState}"); + Console.WriteLine($"ATTACKTRACER_REASON={result.AlertReason}"); + Console.WriteLine($"ATTACKTRACER_BASE_STATUS={result.BaseAlertState}"); + Console.WriteLine($"ATTACKTRACER_EVENTS={result.TotalEvents}"); + Console.WriteLine($"ATTACKTRACER_UNIQUE_IPS={result.UniqueIpCount}"); + Console.WriteLine($"ATTACKTRACER_ERRORS={result.Errors.Count}"); + Console.WriteLine($"ATTACKTRACER_CVE_TOTAL={result.VulnerabilityCorrelation.TotalCount}"); + Console.WriteLine($"ATTACKTRACER_CVE_CRITICAL={result.VulnerabilityCorrelation.CriticalCount}"); + Console.WriteLine($"ATTACKTRACER_CVE_HIGH_CVSS={result.VulnerabilityCorrelation.HighCvssCount}"); + } + } +} diff --git a/src/AttackTracerNinjaCli/Commands/UploadCommand.cs b/src/AttackTracerNinjaCli/Commands/UploadCommand.cs new file mode 100644 index 0000000..86e3206 --- /dev/null +++ b/src/AttackTracerNinjaCli/Commands/UploadCommand.cs @@ -0,0 +1,100 @@ +using AttackTracerNinjaCli.Configuration; +using System.Text.Json; +using AttackTracerNinjaCli.Security; +using AttackTracerNinjaCli.Transport; + +namespace AttackTracerNinjaCli.Commands; + +internal static class UploadCommand +{ + public static int Execute(string[] args) + { + string? reportPath = null; + string? configPath = null; + string? secretPath = null; + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--report": + reportPath = ReadValue(args, ref i, "--report"); + break; + case "--client-config": + configPath = ReadValue(args, ref i, "--client-config"); + break; + case "--secret-path": + secretPath = ReadValue(args, ref i, "--secret-path"); + break; + case "--help": + case "-h": + Console.WriteLine("Usage: OCSentinelCli upload --report [--client-config ] [--secret-path ]"); + return 0; + default: + throw new ArgumentException($"Unknown argument: {args[i]}"); + } + } + + if (string.IsNullOrWhiteSpace(reportPath)) + { + throw new ArgumentException("Missing required argument: --report"); + } + + string resolvedConfigPath = Path.GetFullPath(configPath ?? @"C:\ProgramData\OCSentinel\config\ocsentinel-client.json"); + string resolvedSecretPath = Path.GetFullPath(secretPath ?? @"C:\ProgramData\OCSentinel\secrets\upload-secret.dat"); + ClientConfiguration config = ClientConfiguration.Load(resolvedConfigPath); + + if (string.IsNullOrWhiteSpace(config.N8nWebhookUrl)) + { + throw new InvalidOperationException("Client configuration does not define n8nWebhookUrl."); + } + + if (!File.Exists(resolvedSecretPath)) + { + throw new FileNotFoundException($"Protected upload secret file not found: {resolvedSecretPath}"); + } + + string reportFullPath = Path.GetFullPath(reportPath); + string json = File.ReadAllText(reportFullPath); + ScanResult? parsedReport = JsonSerializer.Deserialize(json, JsonOptions.Default); + if (parsedReport is not null) + { + parsedReport = parsedReport with + { + Runtime = parsedReport.Runtime with + { + UploadAttempted = true + } + }; + + json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default); + File.WriteAllText(reportFullPath, json); + } + + string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath); + var client = new N8nUploadClient(); + var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds); + + if (!result.Success) + { + Console.Error.WriteLine($"Upload failed ({result.StatusCode}): {result.Message}"); + return 1; + } + + Console.WriteLine($"Upload succeeded ({result.StatusCode})"); + Console.WriteLine($"Nonce: {result.Nonce}"); + Console.WriteLine($"Payload SHA256: {result.PayloadSha256}"); + return 0; + } + + private static string ReadValue(string[] args, ref int index, string argName) + { + if (index + 1 >= args.Length) + { + throw new ArgumentException($"Missing value for {argName}"); + } + + index++; + return args[index]; + } +} diff --git a/src/AttackTracerNinjaCli/Commands/VersionCommand.cs b/src/AttackTracerNinjaCli/Commands/VersionCommand.cs new file mode 100644 index 0000000..488deb9 --- /dev/null +++ b/src/AttackTracerNinjaCli/Commands/VersionCommand.cs @@ -0,0 +1,10 @@ +namespace AttackTracerNinjaCli.Commands; + +internal static class VersionCommand +{ + public static int Execute() + { + Console.WriteLine(BuildMetadata.Version); + return 0; + } +} diff --git a/src/AttackTracerNinjaCli/Configuration.cs b/src/AttackTracerNinjaCli/Configuration.cs new file mode 100644 index 0000000..532684f --- /dev/null +++ b/src/AttackTracerNinjaCli/Configuration.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace AttackTracerNinjaCli; + +internal sealed record ScannerConfiguration +{ + public int WarningEventThreshold { get; init; } = 1; + + public int CriticalEventThreshold { get; init; } = 20; + + public int WarningUniqueIpThreshold { get; init; } = 1; + + public int CriticalUniqueIpThreshold { get; init; } = 10; + + public int CorrelationWarningCveThreshold { get; init; } = 1; + + public int CorrelationCriticalCveThreshold { get; init; } = 1; + + public List FtpRoots { get; init; } = []; + + public List FileZillaRoots { get; init; } = []; + + public List ExcludedIps { get; init; } = []; + + public static ScannerConfiguration Load(string path) + { + string fullPath = Path.GetFullPath(path); + string json = File.ReadAllText(fullPath); + ScannerConfiguration? config = JsonSerializer.Deserialize(json, JsonOptions.Default); + return config ?? new ScannerConfiguration(); + } +} diff --git a/src/AttackTracerNinjaCli/Configuration/ClientConfiguration.cs b/src/AttackTracerNinjaCli/Configuration/ClientConfiguration.cs new file mode 100644 index 0000000..2796e31 --- /dev/null +++ b/src/AttackTracerNinjaCli/Configuration/ClientConfiguration.cs @@ -0,0 +1,34 @@ +using System.Text.Json; + +namespace AttackTracerNinjaCli.Configuration; + +internal sealed record ClientConfiguration +{ + public string SchemaVersion { get; init; } = "2.0"; + + public string Environment { get; init; } = "production"; + + public int LookbackDays { get; init; } = 7; + + public int TopFindings { get; init; } = 10; + + public string N8nWebhookUrl { get; init; } = string.Empty; + + public string DeviceIdentifierMode { get; init; } = "machineName"; + + public int UploadTimeoutSeconds { get; init; } = 30; + + public bool EnableVulnerabilityCorrelation { get; init; } = true; + + public string VulnerabilityCsvPath { get; init; } = string.Empty; + + public string SecretReference { get; init; } = "device-default"; + + public static ClientConfiguration Load(string path) + { + string fullPath = Path.GetFullPath(path); + string json = File.ReadAllText(fullPath); + ClientConfiguration? config = JsonSerializer.Deserialize(json, JsonOptions.Default); + return config ?? new ClientConfiguration(); + } +} diff --git a/src/AttackTracerNinjaCli/JsonOptions.cs b/src/AttackTracerNinjaCli/JsonOptions.cs new file mode 100644 index 0000000..86c0455 --- /dev/null +++ b/src/AttackTracerNinjaCli/JsonOptions.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AttackTracerNinjaCli; + +internal static class JsonOptions +{ + public static readonly JsonSerializerOptions Default = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} diff --git a/src/AttackTracerNinjaCli/Models.cs b/src/AttackTracerNinjaCli/Models.cs new file mode 100644 index 0000000..024ace2 --- /dev/null +++ b/src/AttackTracerNinjaCli/Models.cs @@ -0,0 +1,177 @@ +namespace AttackTracerNinjaCli; + +internal sealed record AttackEvent +{ + public DateTimeOffset Timestamp { get; init; } + + public string SourceIp { get; init; } = string.Empty; + + public long InstanceId { get; init; } + + public string Target { get; init; } = string.Empty; + + public string Username { get; init; } = string.Empty; + + public string Source { get; init; } = string.Empty; +} + +internal sealed record AggregatedAttack +{ + public string SourceIp { get; init; } = string.Empty; + + public int Count { get; init; } + + public DateTimeOffset FirstSeenLocal { get; init; } + + public DateTimeOffset LastSeenLocal { get; init; } + + public string RateLabel { get; init; } = string.Empty; + + public List Targets { get; init; } = []; + + public List Usernames { get; init; } = []; + + public List Sources { get; init; } = []; + + public static AggregatedAttack FromGroup(IGrouping group) + { + List ordered = group.OrderBy(static attack => attack.Timestamp).ToList(); + DateTimeOffset firstSeen = ordered[0].Timestamp; + DateTimeOffset lastSeen = ordered[^1].Timestamp; + + return new AggregatedAttack + { + SourceIp = group.Key, + Count = ordered.Count, + FirstSeenLocal = firstSeen, + LastSeenLocal = lastSeen, + RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen), + Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), + Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), + Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList() + }; + } + + private static string FormatRate(int count, DateTimeOffset firstSeen, DateTimeOffset lastSeen) + { + int seconds = (int)Math.Max(1, (lastSeen - firstSeen).TotalSeconds); + if (seconds <= 1) + { + return $"{count * 60}/min"; + } + + if (seconds <= 60) + { + return $"{count * 60 / seconds}/min"; + } + + if (seconds <= 3600) + { + return $"{count * 3600 / seconds}/hour"; + } + + return $"{count * 86400 / seconds}/day"; + } +} + +internal sealed record ScanResult +{ + public string SchemaVersion { get; init; } = "2.0"; + + public string MachineName { get; init; } = string.Empty; + + public DateTimeOffset GeneratedAtLocal { get; init; } + + public DateTimeOffset GeneratedAtUtc { get; init; } + + public string ClientVersion { get; init; } = string.Empty; + + public int LookbackDays { get; init; } + + public int TotalEvents { get; init; } + + public int UniqueIpCount { get; init; } + + public string AlertState { get; init; } = "ok"; + + public string AlertReason { get; init; } = "No thresholds exceeded."; + + public string BaseAlertState { get; init; } = "ok"; + + public string BaseAlertReason { get; init; } = "No thresholds exceeded."; + + public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new(); + + public ScanRuntimeMetadata Runtime { get; init; } = new(); + + public List Events { get; init; } = []; + + public List TopSources { get; init; } = []; + + public List Errors { get; init; } = []; +} + +internal sealed record ScanRuntimeMetadata +{ + public DateTimeOffset StartedAtUtc { get; init; } + + public DateTimeOffset FinishedAtUtc { get; init; } + + public bool UploadAttempted { get; init; } +} + +internal sealed record VulnerabilityFinding +{ + public string DeviceName { get; init; } = string.Empty; + + public string CveId { get; init; } = string.Empty; + + public string Severity { get; init; } = string.Empty; + + public double? CvssScore { get; init; } + + public string Remediation { get; init; } = string.Empty; +} + +internal sealed record VulnerabilityCorrelationSummary +{ + public string SourcePath { get; init; } = string.Empty; + + public int TotalCount { get; init; } + + public int CriticalCount { get; init; } + + public int HighCvssCount { get; init; } + + public List Findings { get; init; } = []; + + public static VulnerabilityCorrelationSummary Empty(string sourcePath = "") + { + return new VulnerabilityCorrelationSummary { SourcePath = sourcePath }; + } + + public static VulnerabilityCorrelationSummary FromFindings(string sourcePath, List findings) + { + int criticalCount = findings.Count(f => + string.Equals(f.Severity, "critical", StringComparison.OrdinalIgnoreCase) || + string.Equals(f.Severity, "high", StringComparison.OrdinalIgnoreCase)); + + int highCvssCount = findings.Count(f => f.CvssScore.HasValue && f.CvssScore.Value >= 8.0); + + return new VulnerabilityCorrelationSummary + { + SourcePath = sourcePath, + TotalCount = findings.Count, + CriticalCount = criticalCount, + HighCvssCount = highCvssCount, + Findings = findings + }; + } +} + +internal sealed record CorrelationAssessment +{ + public string FinalAlertState { get; init; } = "ok"; + + public string CorrelationReason { get; init; } = "No CVE correlation applied."; +} diff --git a/src/AttackTracerNinjaCli/Models/UploadResult.cs b/src/AttackTracerNinjaCli/Models/UploadResult.cs new file mode 100644 index 0000000..0b795fd --- /dev/null +++ b/src/AttackTracerNinjaCli/Models/UploadResult.cs @@ -0,0 +1,14 @@ +namespace AttackTracerNinjaCli.Models; + +internal sealed record UploadResult +{ + public bool Success { get; init; } + + public int StatusCode { get; init; } + + public string Message { get; init; } = string.Empty; + + public string Nonce { get; init; } = string.Empty; + + public string PayloadSha256 { get; init; } = string.Empty; +} diff --git a/src/AttackTracerNinjaCli/Program.cs b/src/AttackTracerNinjaCli/Program.cs new file mode 100644 index 0000000..2eec4d9 --- /dev/null +++ b/src/AttackTracerNinjaCli/Program.cs @@ -0,0 +1,37 @@ +using System.Runtime.Versioning; +using AttackTracerNinjaCli.Commands; + +namespace AttackTracerNinjaCli; + +[SupportedOSPlatform("windows")] +internal static class Program +{ + private static int Main(string[] args) + { + try + { + if (args.Length == 0) + { + return ScanCommand.Execute(args); + } + + string command = args[0]; + string[] remainingArgs = args[1..]; + + return command switch + { + "scan" => ScanCommand.Execute(remainingArgs), + "upload" => UploadCommand.Execute(remainingArgs), + "scan-and-upload" => ScanAndUploadCommand.Execute(remainingArgs), + "version" => VersionCommand.Execute(), + _ when command.StartsWith('-') || command.StartsWith('/') => ScanCommand.Execute(args), + _ => ScanCommand.Execute(args) + }; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Command failed: {ex}"); + return 1; + } + } +} diff --git a/src/AttackTracerNinjaCli/ScanOptions.cs b/src/AttackTracerNinjaCli/ScanOptions.cs new file mode 100644 index 0000000..60e88b5 --- /dev/null +++ b/src/AttackTracerNinjaCli/ScanOptions.cs @@ -0,0 +1,113 @@ +namespace AttackTracerNinjaCli; + +internal sealed record ScanOptions +{ + public const string Usage = """ +Usage: + OCSentinelCli [--output ] [--lookback-days ] [--top ] [--config ] [--vulnerability-csv ] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help] + +Options: + --output Write the JSON report to the given file. + --lookback-days Only include events newer than now minus n days. Default: 30 + --top Number of aggregated source IPs to show. Default: 10 + --config Load thresholds, path overrides, and exclusions from JSON. + --vulnerability-csv + Correlate local attack results with exported CVE data for this host. + --json-only Print only JSON to stdout. + --ninja-output Print extra key=value lines for RMM/Ninja-style parsing. + --fail-on-attacks Return exit code 1 when attacks are found. + --fail-on-threshold Return exit code 1 when status is warning or critical. + --help Show this message. +"""; + + public string? OutputPath { get; init; } + + public int LookbackDays { get; init; } = 30; + + public int TopCount { get; init; } = 10; + + public bool JsonOnly { get; init; } + + public bool NinjaOutput { get; init; } + + public bool FailOnAttacks { get; init; } + + public bool FailOnThreshold { get; init; } + + public string? ConfigPath { get; init; } + + public string? VulnerabilityCsvPath { get; init; } + + public bool ShowHelp { get; init; } + + public static ScanOptions Parse(string[] args) + { + var options = new ScanOptions(); + + for (int i = 0; i < args.Length; i++) + { + string arg = args[i]; + switch (arg) + { + case "--help": + case "-h": + case "/?": + options = options with { ShowHelp = true }; + break; + case "--json-only": + options = options with { JsonOnly = true }; + break; + case "--ninja-output": + options = options with { NinjaOutput = true }; + break; + case "--fail-on-attacks": + options = options with { FailOnAttacks = true }; + break; + case "--fail-on-threshold": + options = options with { FailOnThreshold = true }; + break; + case "--output": + options = options with { OutputPath = ReadValue(args, ref i, arg) }; + break; + case "--config": + options = options with { ConfigPath = ReadValue(args, ref i, arg) }; + break; + case "--vulnerability-csv": + options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) }; + break; + case "--lookback-days": + options = options with { LookbackDays = ReadPositiveInt(args, ref i, arg) }; + break; + case "--top": + options = options with { TopCount = ReadPositiveInt(args, ref i, arg) }; + break; + default: + throw new ArgumentException($"Unknown argument: {arg}"); + } + } + + return options; + } + + private static string ReadValue(string[] args, ref int index, string argName) + { + if (index + 1 >= args.Length) + { + throw new ArgumentException($"Missing value for {argName}"); + } + + index++; + return args[index]; + } + + private static int ReadPositiveInt(string[] args, ref int index, string argName) + { + string raw = ReadValue(args, ref index, argName); + if (!int.TryParse(raw, out int value) || value <= 0) + { + throw new ArgumentException($"{argName} must be a positive integer"); + } + + return value; + } +} diff --git a/src/AttackTracerNinjaCli/Security/ProtectedSecretStore.cs b/src/AttackTracerNinjaCli/Security/ProtectedSecretStore.cs new file mode 100644 index 0000000..be06327 --- /dev/null +++ b/src/AttackTracerNinjaCli/Security/ProtectedSecretStore.cs @@ -0,0 +1,28 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AttackTracerNinjaCli.Security; + +internal static class ProtectedSecretStore +{ + public static string LoadSecret(string path) + { + byte[] protectedBytes = File.ReadAllBytes(path); + byte[] plainBytes = ProtectedData.Unprotect(protectedBytes, null, DataProtectionScope.LocalMachine); + return Encoding.UTF8.GetString(plainBytes); + } + + public static void SaveSecret(string path, string secret) + { + string fullPath = Path.GetFullPath(path); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + byte[] plainBytes = Encoding.UTF8.GetBytes(secret); + byte[] protectedBytes = ProtectedData.Protect(plainBytes, null, DataProtectionScope.LocalMachine); + File.WriteAllBytes(fullPath, protectedBytes); + } +} diff --git a/src/AttackTracerNinjaCli/Transport/N8nUploadClient.cs b/src/AttackTracerNinjaCli/Transport/N8nUploadClient.cs new file mode 100644 index 0000000..ad56f10 --- /dev/null +++ b/src/AttackTracerNinjaCli/Transport/N8nUploadClient.cs @@ -0,0 +1,70 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using AttackTracerNinjaCli.Models; + +namespace AttackTracerNinjaCli.Transport; + +internal sealed class N8nUploadClient +{ + public UploadResult UploadJson( + string webhookUrl, + string machineName, + string clientVersion, + string payloadJson, + string sharedSecret, + int timeoutSeconds) + { + string timestamp = DateTimeOffset.UtcNow.ToString("O"); + string nonce = Guid.NewGuid().ToString("N"); + string payloadHash = ComputeSha256(payloadJson); + string signature = ComputeSignature(machineName, timestamp, nonce, clientVersion, payloadHash, sharedSecret); + + using var httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(Math.Max(5, timeoutSeconds)) + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl) + { + Content = new StringContent(payloadJson, Encoding.UTF8, "application/json") + }; + + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Add("X-ATN-Device", machineName); + request.Headers.Add("X-ATN-Timestamp", timestamp); + request.Headers.Add("X-ATN-Nonce", nonce); + request.Headers.Add("X-ATN-Version", clientVersion); + request.Headers.Add("X-ATN-Payload-SHA256", payloadHash); + request.Headers.Add("X-ATN-Signature", signature); + + using HttpResponseMessage response = httpClient.Send(request); + string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + return new UploadResult + { + Success = response.IsSuccessStatusCode, + StatusCode = (int)response.StatusCode, + Message = string.IsNullOrWhiteSpace(responseText) ? response.ReasonPhrase ?? string.Empty : responseText, + Nonce = nonce, + PayloadSha256 = payloadHash + }; + } + + private static string ComputeSha256(string value) + { + byte[] bytes = Encoding.UTF8.GetBytes(value); + byte[] hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string ComputeSignature(string machineName, string timestamp, string nonce, string version, string payloadHash, string sharedSecret) + { + string canonical = string.Join("\n", machineName, timestamp, nonce, version, payloadHash); + byte[] secretBytes = Encoding.UTF8.GetBytes(sharedSecret); + byte[] canonicalBytes = Encoding.UTF8.GetBytes(canonical); + using var hmac = new HMACSHA256(secretBytes); + byte[] hash = hmac.ComputeHash(canonicalBytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/AttackTracerNinjaCli/VulnerabilityCorrelation.cs b/src/AttackTracerNinjaCli/VulnerabilityCorrelation.cs new file mode 100644 index 0000000..83064bf --- /dev/null +++ b/src/AttackTracerNinjaCli/VulnerabilityCorrelation.cs @@ -0,0 +1,200 @@ +using System.Globalization; + +namespace AttackTracerNinjaCli; + +internal static class VulnerabilityCorrelation +{ + private static readonly string[] DeviceNameHeaders = ["device", "device_name", "hostname", "computer", "computername", "endpoint", "machine", "system"]; + private static readonly string[] CveHeaders = ["cve", "cve_id", "cveid", "vulnerability", "vulnerability_id"]; + private static readonly string[] SeverityHeaders = ["severity", "risk", "level"]; + private static readonly string[] CvssHeaders = ["cvss", "cvss_score", "score", "base_score"]; + private static readonly string[] RemediationHeaders = ["remediation", "patch", "kb", "fix", "solution"]; + + public static VulnerabilityCorrelationSummary LoadForMachine(string machineName, string csvPath, List errors) + { + try + { + string fullPath = Path.GetFullPath(csvPath); + if (!File.Exists(fullPath)) + { + errors.Add($"Vulnerability correlation file not found: {fullPath}"); + return VulnerabilityCorrelationSummary.Empty(fullPath); + } + + string[] lines = File.ReadAllLines(fullPath); + if (lines.Length == 0) + { + return VulnerabilityCorrelationSummary.Empty(fullPath); + } + + string[] headers = SplitCsvLine(lines[0]); + var headerMap = headers + .Select((header, index) => new { Header = header.Trim(), Index = index }) + .ToDictionary(static pair => Normalize(pair.Header), static pair => pair.Index, StringComparer.OrdinalIgnoreCase); + + int deviceIndex = FindHeaderIndex(headerMap, DeviceNameHeaders); + int cveIndex = FindHeaderIndex(headerMap, CveHeaders); + int severityIndex = FindHeaderIndex(headerMap, SeverityHeaders); + int cvssIndex = FindHeaderIndex(headerMap, CvssHeaders); + int remediationIndex = FindHeaderIndex(headerMap, RemediationHeaders); + + if (deviceIndex < 0 || cveIndex < 0) + { + errors.Add($"Vulnerability correlation file is missing a device or CVE column: {fullPath}"); + return VulnerabilityCorrelationSummary.Empty(fullPath); + } + + var findings = new List(); + for (int i = 1; i < lines.Length; i++) + { + string line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] cells = SplitCsvLine(line); + string deviceName = GetCell(cells, deviceIndex); + if (!string.Equals(deviceName, machineName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string cveId = GetCell(cells, cveIndex); + if (string.IsNullOrWhiteSpace(cveId)) + { + continue; + } + + string severity = severityIndex >= 0 ? GetCell(cells, severityIndex) : string.Empty; + string remediation = remediationIndex >= 0 ? GetCell(cells, remediationIndex) : string.Empty; + double? cvss = TryParseDouble(cvssIndex >= 0 ? GetCell(cells, cvssIndex) : string.Empty); + + findings.Add(new VulnerabilityFinding + { + DeviceName = deviceName, + CveId = cveId, + Severity = severity, + CvssScore = cvss, + Remediation = remediation + }); + } + + return VulnerabilityCorrelationSummary.FromFindings(fullPath, findings); + } + catch (Exception ex) + { + errors.Add($"Vulnerability correlation load failed for {csvPath}: {ex.Message}"); + return VulnerabilityCorrelationSummary.Empty(Path.GetFullPath(csvPath)); + } + } + + public static CorrelationAssessment Assess( + string currentAlertState, + int totalEvents, + VulnerabilityCorrelationSummary vulnerabilitySummary, + ScannerConfiguration configuration) + { + string finalState = currentAlertState; + string reason = "No CVE correlation applied."; + + bool hasAttacks = totalEvents > 0; + bool hasCriticalCvEs = vulnerabilitySummary.CriticalCount >= configuration.CorrelationCriticalCveThreshold; + bool hasWarningCvEs = vulnerabilitySummary.TotalCount >= configuration.CorrelationWarningCveThreshold; + + if (hasAttacks && hasCriticalCvEs) + { + finalState = "critical"; + reason = $"Attack activity correlated with {vulnerabilitySummary.CriticalCount} critical/high CVE findings on this endpoint."; + } + else if (hasAttacks && hasWarningCvEs && string.Equals(finalState, "ok", StringComparison.OrdinalIgnoreCase)) + { + finalState = "warning"; + reason = $"Attack activity correlated with {vulnerabilitySummary.TotalCount} CVE findings on this endpoint."; + } + else if (vulnerabilitySummary.TotalCount > 0) + { + reason = $"Loaded {vulnerabilitySummary.TotalCount} CVE findings for this endpoint, but no alert escalation was required."; + } + + return new CorrelationAssessment + { + FinalAlertState = finalState, + CorrelationReason = reason + }; + } + + private static int FindHeaderIndex(Dictionary headerMap, string[] candidates) + { + foreach (string candidate in candidates) + { + if (headerMap.TryGetValue(Normalize(candidate), out int index)) + { + return index; + } + } + + return -1; + } + + private static string Normalize(string value) + { + return value.Trim().Replace(" ", "_").Replace("-", "_").ToLowerInvariant(); + } + + private static string[] SplitCsvLine(string line) + { + var result = new List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (c == '"') + { + if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') + { + current.Append('"'); + i++; + } + else + { + inQuotes = !inQuotes; + } + } + else if (c == ',' && !inQuotes) + { + result.Add(current.ToString()); + current.Clear(); + } + else + { + current.Append(c); + } + } + + result.Add(current.ToString()); + return [.. result]; + } + + private static string GetCell(string[] cells, int index) + { + return index >= 0 && index < cells.Length ? cells[index].Trim() : string.Empty; + } + + private static double? TryParseDouble(string value) + { + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out double result)) + { + return result; + } + + if (double.TryParse(value, NumberStyles.Any, CultureInfo.CurrentCulture, out result)) + { + return result; + } + + return null; + } +}