diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index f5de7fa..9971c0b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -25,6 +25,14 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + demo-validate: + name: Demo script validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Validate local demo helpers + run: ./examples/demo/validate.sh + # ── Clippy ────────────────────────────────────────────────────────────────── # Run on all platforms so Linux-specific code (aya, libc), Windows-specific # code (ferrisetw, windows-rs), and macOS-specific code are each linted diff --git a/docs/getting-started.md b/docs/getting-started.md index c899ae0..2b49223 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -71,18 +71,36 @@ Confirm that an alert was written: Get-Content .\logs\alerts.json.* ``` + Or use the end-to-end demo helper: + + ```powershell + .\examples\demo\run-local-demo.ps1 + ``` + === "Linux" ```bash cat logs/alerts.json.* ``` + Or use the end-to-end demo helper: + + ```bash + ./examples/demo/run-local-demo.sh + ``` + === "macOS" ```bash cat logs/alerts.json.* ``` + Or use the end-to-end demo helper: + + ```bash + ./examples/demo/run-local-demo.sh + ``` + Bundled demo rules: | Platform | Rule | diff --git a/examples/demo/README.md b/examples/demo/README.md new file mode 100644 index 0000000..c392d05 --- /dev/null +++ b/examples/demo/README.md @@ -0,0 +1,89 @@ +# Local Demo Script + +End-to-end helper for the bundled `whoami` Sigma demo: verify prerequisites, +fire the trigger, wait for an alert, and print the latest ECS NDJSON line from +today's alert file. + +The scripts read `alerts.directory` and `alerts.filename` from `config.toml` +(defaults: `logs` and `alerts.json`), then watch +`{directory}/{filename}.{YYYY-MM-DD}`. + +Rustinel must already be running in another terminal: + +```bash +sudo ./rustinel run +``` + +## Linux & macOS + +From the Rustinel install directory (where `config.toml` and `rules/` live): + +```bash +./examples/demo/run-local-demo.sh +``` + +Options: + +```text +--root PATH Rustinel install directory (default: auto-detect) +--trigger-only Skip agent and prerequisite checks +--timeout SECS Seconds to wait for a new alert (default: 15) +--siem NAME Print next-step commands for elastic or splunk +-h, --help Show this help +``` + +## Windows + +From an elevated PowerShell in the install directory: + +```powershell +.\examples\demo\run-local-demo.ps1 +``` + +Options: + +```text +-Root PATH Rustinel install directory (default: auto-detect) +-TriggerOnly Skip agent and prerequisite checks +-TimeoutSeconds Seconds to wait for a new alert (default: 15) +-Siem elastic|splunk Print next-step commands for a SIEM demo +-Help Show this help +``` + +## Expected success + +On success the script exits `0` and prints the latest alert, for example: + +```json +{ + "@timestamp": "...", + "event": { "kind": "alert", ... }, + "rule": { "name": "Example - Whoami Execution (Linux)", ... } +} +``` + +## Validation + +Run the lightweight checks for config parsing and argument handling: + +```bash +./examples/demo/validate.sh +``` + +## SIEM next steps + +After a local alert is confirmed: + +```bash +./examples/demo/run-local-demo.sh --siem elastic +./examples/demo/run-local-demo.sh --siem splunk +``` + +See [SIEM Demos](../../docs/siem-demos.md) for full Elastic and Splunk lab setup. + +## Troubleshooting + +- **Agent not running** — start Rustinel first (`sudo ./rustinel run` on Linux/macOS). +- **Timeout** — confirm bundled rules are present under `rules/sigma/` and Sigma is enabled in `config.toml`. +- **Custom alert paths** — set `[alerts].directory` and `[alerts].filename` in `config.toml`; the demo scripts follow those values. +- **macOS** — support is experimental; see [Getting Started](../../docs/getting-started.md). diff --git a/examples/demo/lib.sh b/examples/demo/lib.sh new file mode 100644 index 0000000..23ff401 --- /dev/null +++ b/examples/demo/lib.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Shared helpers for examples/demo scripts. + +read_toml_value() { + local section="$1" + local key="$2" + local file="$3" + + awk -v section="[$section]" -v key="$key" ' + BEGIN { in_section = 0 } + $0 == section { in_section = 1; next } + /^\[/ { in_section = 0 } + in_section && $1 == key { + line = $0 + sub(/^[^=]*=[ \t]*/, "", line) + sub(/[ \t]*#.*$/, "", line) + gsub(/^[ \t]+|[ \t]+$/, "", line) + gsub(/^"/, "", line) + gsub(/"$/, "", line) + gsub(/^'\''/, "", line) + gsub(/'\''$/, "", line) + print line + exit + } + ' "$file" +} + +read_alerts_config() { + local root="$1" + local config_file="$root/config.toml" + local alerts_dir alerts_filename + + if [[ ! -f "$config_file" ]]; then + return 1 + fi + + alerts_dir="$(read_toml_value alerts directory "$config_file")" + alerts_filename="$(read_toml_value alerts filename "$config_file")" + + [[ -z "$alerts_dir" ]] && alerts_dir="logs" + [[ -z "$alerts_filename" ]] && alerts_filename="alerts.json" + + if [[ "$alerts_dir" != /* ]]; then + alerts_dir="$root/$alerts_dir" + fi + + printf '%s\n%s\n' "$alerts_dir" "$alerts_filename" +} + +today_date() { + date +%Y-%m-%d +} + +today_alert_file() { + local alerts_dir="$1" + local alerts_filename="$2" + + printf '%s/%s.%s' "$alerts_dir" "$alerts_filename" "$(today_date)" +} + +now_ms() { + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import time; print(int(time.time() * 1000))' + else + echo $(( $(date +%s) * 1000 )) + fi +} + +alert_file_line_count() { + local file="$1" + + if [[ ! -f "$file" ]]; then + echo 0 + return + fi + + wc -l <"$file" | tr -d ' ' +} + +latest_alert_line() { + local alerts_dir="$1" + local alerts_filename="$2" + local today_file latest_file="" file + + today_file="$(today_alert_file "$alerts_dir" "$alerts_filename")" + if [[ -f "$today_file" ]]; then + tail -n 1 "$today_file" + return 0 + fi + + shopt -s nullglob + local files=("$alerts_dir/$alerts_filename".*) + for file in "${files[@]}"; do + if [[ -z "$latest_file" || "$file" -nt "$latest_file" ]]; then + latest_file="$file" + fi + done + shopt -u nullglob + + if [[ -z "$latest_file" ]]; then + return 1 + fi + + tail -n 1 "$latest_file" +} diff --git a/examples/demo/run-local-demo.ps1 b/examples/demo/run-local-demo.ps1 new file mode 100644 index 0000000..9ec241e --- /dev/null +++ b/examples/demo/run-local-demo.ps1 @@ -0,0 +1,285 @@ +[CmdletBinding()] +param( + [string]$Root = "", + [switch]$TriggerOnly, + [int]$TimeoutSeconds = 15, + [ValidateSet("", "elastic", "splunk")] + [string]$Siem = "", + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Show-Usage { + @" +Verify the bundled whoami demo end-to-end. + +Rustinel must already be running (for example: .\rustinel.exe run). + +Usage: + .\run-local-demo.ps1 [-Root PATH] [-TriggerOnly] [-TimeoutSeconds SECS] [-Siem elastic|splunk] + +Options: + -Root PATH Rustinel install directory (default: auto-detect) + -TriggerOnly Skip agent and prerequisite checks + -TimeoutSeconds Seconds to wait for a new alert (default: 15) + -Siem elastic|splunk Print next-step commands for a SIEM demo + -Help Show this help +"@ +} + +function Get-TomlValue { + param( + [string]$Section, + [string]$Key, + [string]$Path + ) + + $inSection = $false + foreach ($line in Get-Content -LiteralPath $Path) { + $trim = $line.Trim() + if ($trim -eq "[$Section]") { + $inSection = $true + continue + } + if ($trim -match '^\[') { + $inSection = $false + continue + } + if ($inSection -and $trim -match "^$([regex]::Escape($Key))\s*=") { + $value = ($trim -split '=', 2)[1].Trim() + $value = ($value -replace '#.*$', '').Trim() + return $value.Trim('"').Trim("'") + } + } + + return $null +} + +function Get-AlertsConfig { + param([string]$InstallRoot) + + $configPath = Join-Path $InstallRoot "config.toml" + if (-not (Test-Path -LiteralPath $configPath)) { + throw "Missing config.toml in $InstallRoot" + } + + $alertsDir = Get-TomlValue -Section "alerts" -Key "directory" -Path $configPath + $alertsFilename = Get-TomlValue -Section "alerts" -Key "filename" -Path $configPath + + if ([string]::IsNullOrWhiteSpace($alertsDir)) { + $alertsDir = "logs" + } + if ([string]::IsNullOrWhiteSpace($alertsFilename)) { + $alertsFilename = "alerts.json" + } + + if (-not [System.IO.Path]::IsPathRooted($alertsDir)) { + $alertsDir = Join-Path $InstallRoot $alertsDir + } + + return [PSCustomObject]@{ + Directory = $alertsDir + Filename = $alertsFilename + } +} + +function Get-TodayAlertFile { + param( + [string]$AlertsDirectory, + [string]$AlertsFilename + ) + + $date = Get-Date -Format "yyyy-MM-dd" + return Join-Path $AlertsDirectory "$AlertsFilename.$date" +} + +function Detect-Root { + $dir = (Get-Location).Path + while ($dir) { + if (Test-Path -LiteralPath (Join-Path $dir "config.toml")) { + return $dir + } + $parent = Split-Path -Parent $dir + if (-not $parent -or $parent -eq $dir) { + break + } + $dir = $parent + } + + $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + $candidate = (Resolve-Path (Join-Path $scriptRoot "..\..")).Path + if (Test-Path -LiteralPath (Join-Path $candidate "config.toml")) { + return $candidate + } + + return $null +} + +function Find-Binary { + param([string]$InstallRoot) + + foreach ($candidate in @( + (Join-Path $InstallRoot "rustinel.exe"), + (Join-Path $InstallRoot "target\release\rustinel.exe"), + (Join-Path $InstallRoot "target\debug\rustinel.exe") + )) { + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + return $null +} + +function Get-AlertFileLineCount { + param([string]$AlertFile) + + if (-not (Test-Path -LiteralPath $AlertFile)) { + return 0 + } + + return (Get-Content -LiteralPath $AlertFile | Measure-Object -Line).Lines +} + +function Get-LatestAlertLine { + param( + [string]$AlertsDirectory, + [string]$AlertsFilename + ) + + $todayFile = Get-TodayAlertFile -AlertsDirectory $AlertsDirectory -AlertsFilename $AlertsFilename + if (Test-Path -LiteralPath $todayFile) { + return (Get-Content -LiteralPath $todayFile -Tail 1) + } + + $pattern = Join-Path $AlertsDirectory "$AlertsFilename.*" + $files = @(Get-ChildItem -Path $pattern -File -ErrorAction SilentlyContinue) + if (-not $files -or $files.Count -eq 0) { + return $null + } + + $latest = $files | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + return (Get-Content -LiteralPath $latest.FullName -Tail 1) +} + +function Write-AlertJson { + param([string]$Line) + + try { + $Line | ConvertFrom-Json | ConvertTo-Json -Depth 20 + } + catch { + Write-Output $Line + } +} + +function Show-SiemHint { + param( + [string]$AlertsDirectory, + [string]$TodayAlertFile, + [string]$Name + ) + + switch ($Name) { + "elastic" { + @" + +Next — Elastic SIEM demo: + cd examples\siem\elastic + docker compose up -d elasticsearch kibana + `$env:RUSTINEL_ALERTS_DIR = "$AlertsDirectory" + docker compose up filebeat + +Kibana: http://localhost:5601 — search: event.kind : "alert" +"@ + } + "splunk" { + @" + +Next — Splunk SIEM demo: + cd examples\siem\splunk + docker compose up -d + python3 send-alerts.py $TodayAlertFile + +Splunk Web: http://localhost:8000 — search: index=main source=rustinel event.kind=alert +"@ + } + } +} + +if ($Help) { + Show-Usage + exit 0 +} + +if (-not $Root) { + $Root = Detect-Root + if (-not $Root) { + Write-Error "Could not find Rustinel install directory (expected config.toml). Run from the install directory or pass -Root PATH." + } +} + +$Root = (Resolve-Path -LiteralPath $Root).Path + +if (-not $TriggerOnly) { + if (-not (Test-Path -LiteralPath (Join-Path $Root "config.toml"))) { + Write-Error "Missing config.toml in $Root" + } + + if (-not (Find-Binary -InstallRoot $Root)) { + Write-Error "Rustinel binary not found under $Root. Build with cargo build --release or install a release from https://github.com/Karib0u/rustinel/releases" + } + + $agent = Get-Process -Name rustinel -ErrorAction SilentlyContinue + if (-not $agent) { + Write-Error @" +Rustinel agent is not running. +Start it in another terminal: + cd $Root + .\rustinel.exe run +"@ + } + + Write-Host "Rustinel install: $Root" + Write-Host "Agent: running" +} + +$alertsConfig = Get-AlertsConfig -InstallRoot $Root +$todayFile = Get-TodayAlertFile -AlertsDirectory $alertsConfig.Directory -AlertsFilename $alertsConfig.Filename + +$before = Get-AlertFileLineCount -AlertFile $todayFile +Write-Host "Watching alert file: $todayFile" +Write-Host "Firing bundled demo trigger (whoami /all)..." +whoami /all | Out-Null + +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +$found = $false + +while ((Get-Date) -lt $deadline) { + $after = Get-AlertFileLineCount -AlertFile $todayFile + if ($after -gt $before) { + $found = $true + break + } + Start-Sleep -Milliseconds 200 +} + +if (-not $found) { + Write-Error "No new alert within ${TimeoutSeconds}s. Check $todayFile and confirm Sigma rules are loaded." +} + +$line = Get-LatestAlertLine -AlertsDirectory $alertsConfig.Directory -AlertsFilename $alertsConfig.Filename +if (-not $line) { + Write-Error "Alert count increased but no alert line could be read." +} + +Write-Host "Latest alert:" +Write-AlertJson -Line $line + +if ($Siem) { + Show-SiemHint -AlertsDirectory $alertsConfig.Directory -TodayAlertFile $todayFile -Name $Siem +} + +Write-Host "Demo succeeded." +exit 0 diff --git a/examples/demo/run-local-demo.sh b/examples/demo/run-local-demo.sh new file mode 100755 index 0000000..2894c2f --- /dev/null +++ b/examples/demo/run-local-demo.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +ROOT="" +TRIGGER_ONLY=0 +TIMEOUT_SECS=15 +SIEM="" + +usage() { + cat <<'EOF' +Verify the bundled whoami demo end-to-end. + +Rustinel must already be running (for example: sudo ./rustinel run). + +Usage: + run-local-demo.sh [--root PATH] [--trigger-only] [--timeout SECS] [--siem NAME] + +Options: + --root PATH Rustinel install directory (default: auto-detect) + --trigger-only Skip agent and prerequisite checks + --timeout SECS Seconds to wait for a new alert (default: 15) + --siem NAME Print next-step commands for elastic or splunk + -h, --help Show this help +EOF +} + +detect_root() { + local dir="$PWD" + + while [[ "$dir" != "/" ]]; do + if [[ -f "$dir/config.toml" ]]; then + printf '%s' "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + + if [[ -f "$SCRIPT_DIR/../../config.toml" ]]; then + cd "$SCRIPT_DIR/../.." && pwd + return 0 + fi + + return 1 +} + +find_binary() { + local root="$1" + local candidate + + for candidate in \ + "$root/rustinel" \ + "$root/target/release/rustinel" \ + "$root/target/debug/rustinel"; do + if [[ -x "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi + done + + return 1 +} + +agent_running() { + pgrep -x rustinel >/dev/null 2>&1 +} + +print_alert() { + local line="$1" + + if command -v python3 >/dev/null 2>&1; then + printf '%s\n' "$line" | python3 -m json.tool + elif command -v jq >/dev/null 2>&1; then + printf '%s\n' "$line" | jq . + else + printf '%s\n' "$line" + fi +} + +print_siem_hint() { + local root="$1" + local alerts_dir="$2" + local alerts_filename="$3" + local name="$4" + local today_file + + today_file="$(today_alert_file "$alerts_dir" "$alerts_filename")" + + case "$name" in + elastic) + cat <&2 + return 2 + ;; + esac +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --root) + ROOT="${2:?missing value for --root}" + shift 2 + ;; + --trigger-only) + TRIGGER_ONLY=1 + shift + ;; + --timeout) + TIMEOUT_SECS="${2:?missing value for --timeout}" + shift 2 + ;; + --siem) + SIEM="${2:?missing value for --siem}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "$SIEM" && "$SIEM" != "elastic" && "$SIEM" != "splunk" ]]; then + echo "Unknown SIEM: $SIEM (expected elastic or splunk)" >&2 + exit 2 +fi + +if [[ -z "$ROOT" ]]; then + if ! ROOT="$(detect_root)"; then + echo "Could not find Rustinel install directory (expected config.toml)." >&2 + echo "Run from the install directory or pass --root PATH." >&2 + exit 1 + fi +fi + +ROOT="$(cd "$ROOT" && pwd)" + +if [[ "$TRIGGER_ONLY" -eq 0 ]]; then + if [[ ! -f "$ROOT/config.toml" ]]; then + echo "Missing config.toml in $ROOT" >&2 + exit 1 + fi + + if ! find_binary "$ROOT" >/dev/null; then + echo "Rustinel binary not found under $ROOT" >&2 + echo "Build with: cargo build --release" >&2 + echo "Or install a release: https://github.com/Karib0u/rustinel/releases" >&2 + exit 1 + fi + + if ! agent_running; then + echo "Rustinel agent is not running." >&2 + echo "Start it in another terminal:" >&2 + echo " cd $ROOT && sudo ./rustinel run" >&2 + exit 1 + fi + + echo "Rustinel install: $ROOT" + echo "Agent: running" +fi + +if ! config_lines="$(read_alerts_config "$ROOT")"; then + echo "Unable to read alerts settings from $ROOT/config.toml" >&2 + exit 1 +fi + +alerts_dir="${config_lines%%$'\n'*}" +alerts_filename="${config_lines#*$'\n'}" +today_file="$(today_alert_file "$alerts_dir" "$alerts_filename")" + +before="$(alert_file_line_count "$today_file")" +echo "Watching alert file: $today_file" +echo "Firing bundled demo trigger (whoami)..." +whoami >/dev/null + +start_ms="$(now_ms)" +deadline_ms=$((start_ms + TIMEOUT_SECS * 1000)) +found=0 + +while [[ "$(now_ms)" -lt "$deadline_ms" ]]; do + after="$(alert_file_line_count "$today_file")" + if [[ "$after" -gt "$before" ]]; then + found=1 + break + fi + sleep 0.2 +done + +if [[ "$found" -ne 1 ]]; then + echo "No new alert within ${TIMEOUT_SECS}s." >&2 + echo "Check $today_file and confirm Sigma rules are loaded." >&2 + exit 1 +fi + +if ! line="$(latest_alert_line "$alerts_dir" "$alerts_filename")"; then + echo "Alert count increased but no alert line could be read." >&2 + exit 1 +fi + +echo "Latest alert:" +print_alert "$line" + +if [[ -n "$SIEM" ]]; then + print_siem_hint "$ROOT" "$alerts_dir" "$alerts_filename" "$SIEM" +fi + +echo "Demo succeeded." +exit 0 diff --git a/examples/demo/validate.sh b/examples/demo/validate.sh new file mode 100755 index 0000000..c9e4505 --- /dev/null +++ b/examples/demo/validate.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +pass=0 +fail=0 + +assert_eq() { + local label="$1" + local expected="$2" + local actual="$3" + + if [[ "$expected" == "$actual" ]]; then + echo "PASS: $label" + pass=$((pass + 1)) + else + echo "FAIL: $label" >&2 + echo " expected: $expected" >&2 + echo " actual: $actual" >&2 + fail=$((fail + 1)) + fi +} + +assert_exit() { + local label="$1" + local expected="$2" + shift 2 + set +e + "$@" >/dev/null 2>&1 + local status=$? + set -e + + if [[ "$status" -eq "$expected" ]]; then + echo "PASS: $label" + pass=$((pass + 1)) + else + echo "FAIL: $label (exit $status, expected $expected)" >&2 + fail=$((fail + 1)) + fi +} + +tmpdir="" +cleanup() { + if [[ -n "$tmpdir" && -d "$tmpdir" ]]; then + rm -rf "$tmpdir" + fi +} +trap cleanup EXIT + +tmpdir="$(mktemp -d)" + +cat >"$tmpdir/config.toml" <<'EOF' +[alerts] +directory = "custom-alerts" +filename = "demo-alerts.json" +EOF + +config_lines="$(read_alerts_config "$tmpdir")" +alerts_dir="${config_lines%%$'\n'*}" +alerts_filename="${config_lines#*$'\n'}" +assert_eq "reads custom alerts.directory" "$tmpdir/custom-alerts" "$alerts_dir" +assert_eq "reads custom alerts.filename" "demo-alerts.json" "$alerts_filename" + +today_file="$(today_alert_file "$alerts_dir" "$alerts_filename")" +expected_today="$tmpdir/custom-alerts/demo-alerts.json.$(today_date)" +assert_eq "builds today's alert file path" "$expected_today" "$today_file" + +cat >"$tmpdir/config.toml" <<'EOF' +[alerts] +directory = "/var/log/rustinel" +filename = "alerts.json" +EOF + +config_lines="$(read_alerts_config "$tmpdir")" +alerts_dir="${config_lines%%$'\n'*}" +assert_eq "preserves absolute alerts.directory" "/var/log/rustinel" "$alerts_dir" + +assert_exit "run-local-demo.sh --help exits 0" 0 \ + "$SCRIPT_DIR/run-local-demo.sh" --help + +assert_exit "unknown --siem value exits 2" 2 \ + "$SCRIPT_DIR/run-local-demo.sh" --root "$tmpdir" --trigger-only --siem invalid + +echo +echo "Validation summary: $pass passed, $fail failed" +if [[ "$fail" -gt 0 ]]; then + exit 1 +fi