Initial OfficeCom Sentinel client and deployment assets

This commit is contained in:
OfficeCom Codex
2026-07-17 00:39:28 +02:00
commit 7cdc0395c4
58 changed files with 6199 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
bin/
obj/
artifacts/
reports/
_extracted/
_tools/
payload/
*.user
*.suo
*.log
*.zip
*.sha256
SetupAttackTracer.exe
decompiled/
msi-admin/

View 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"

View 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"

View 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"
}

View 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
}

View 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"
]
}

View 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"
}

View 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

View 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

View 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)

View 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

View 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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -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>

View 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.
}
}
}
}

View File

@@ -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>

View 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
View 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\

View 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')"

View File

@@ -0,0 +1,3 @@
@echo off
powershell.exe -ExecutionPolicy Bypass -File "%~dp0install-attacktracer-ninja.ps1"
exit /b %errorlevel%

View 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
}

View 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

View 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"

View 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

View 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')"

View 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"

View 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"

View 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"

View 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
1 device cve severity cvss remediation
2 LUKASOC CVE-2026-0001 Critical 9.8 Install vendor patch KB123456
3 LUKASOC CVE-2025-9999 High 8.1 Upgrade affected application
4 OTHERHOST CVE-2024-1111 Medium 5.4 Pending normal patch cycle

View 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": []
}

View 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"

View 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
}

View 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"

View 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
}
}

View 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"

View 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

View 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"

View 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

View 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."
};
}
}

View 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>

View 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";
}

View 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]);
}
}

View 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}");
}
}
}

View 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];
}
}

View File

@@ -0,0 +1,10 @@
namespace AttackTracerNinjaCli.Commands;
internal static class VersionCommand
{
public static int Execute()
{
Console.WriteLine(BuildMetadata.Version);
return 0;
}
}

View 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();
}
}

View File

@@ -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();
}
}

View 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
};
}

View 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.";
}

View 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;
}

View 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;
}
}
}

View 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;
}
}

View 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);
}
}

View 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();
}
}

View 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;
}
}