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