Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/simulator-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Simulator CI

# Gates the public-facing simulator. simulate.sh is the script external users
# run against their own ingestion deployment, so a syntax error or a broken
# signer would land in their hands; CI catches both before merge. The smoke
# test is hermetic (no ingestion server): it builds a real scenario payload,
# signs it, and asserts the signed-request shape the webhook expects. Every
# action is pinned to a full commit SHA, not a tag, so a compromised or
# retagged action cannot inject code into CI.

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

permissions:
contents: read

jobs:
shellcheck-and-smoke:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

- name: Syntax check (sh -n)
# Catches a broken simulate.sh before any external user runs it. Also
# checks the scenario scripts and the smoke test itself.
run: |
sh -n simulate.sh
for s in scenarios/*.sh; do sh -n "$s"; done
sh -n scripts/smoke.sh

- name: ShellCheck
# Static analysis of the shell scripts. Uses the shellcheck binary
# preinstalled on the ubuntu-latest runner rather than a third-party
# action, so there is no extra action SHA to pin and trust.
# Scoped to error severity: the scenario scripts set metadata vars
# (SCENARIO_NAME, etc.) that are consumed only after simulate.sh sources
# them, so shellcheck's per-file analysis flags them as unused/unassigned
# (SC2034 / SC2153) - false positives across the source boundary. Real
# errors still fail the build; sh -n above already covers syntax.
run: |
shellcheck --version
shellcheck --severity=error -x simulate.sh scripts/smoke.sh
shellcheck --severity=error scenarios/*.sh

- name: Signature-shape smoke test
# Hermetic: sources simulate.sh (the BASH_SOURCE guard means main does
# not fire on source), builds a scenario payload, signs it, and asserts
# a 64-hex HMAC-SHA256 digest under a "sha256=" header. No server.
run: bash scripts/smoke.sh
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ All notable changes to the Vyrox attack simulator are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-06-14

The demo-fire scenario suite plus CI for the public-facing simulator.

### Added
- **`demo-fire.sh`** — fire alerts by SEVERITY (random pick from a pool) or by
exact scenario name; `--both` (both demo tenants), `--all` (every scenario in
a band), `--populate` (full LOW -> MEDIUM -> HIGH -> CRITICAL spread). Five
scenarios per band, command lines chosen so Vyrox's own triage lands each
alert in its band.
- **`scripts/smoke.sh`** — hermetic signature-shape smoke test (no ingestion
server): builds a real scenario payload, signs it, and asserts the
`sha256=<64-hex>` HMAC-SHA256 wire shape the webhook expects.
- **CI** (`.github/workflows/simulator-ci.yml`) — `sh -n` syntax check,
ShellCheck (error severity), and the smoke test on every push/PR. Actions are
pinned to a full commit SHA and the job runs with `contents: read` only.

### Changed
- **`simulate.sh` only dispatches when executed directly** (a `BASH_SOURCE`
guard), so the smoke test can source it to exercise the signer and payload
builder without sending anything.

### Fixed
- **`high_defender_tamper.sh` crashed `demo-fire.sh --populate`** with "line 31:
true: unbound variable": the PowerShell literal `$true` sat inside an unquoted
heredoc, so `set -u` treated it as an unset shell variable. Escaped to `\$true`
so the payload carries the literal `$true`. All scenarios now build clean under
`set -u`.

## [0.1.0] - 2026-05-25

First tagged release of the attack simulator, fire realistic, signed EDR
Expand Down
2 changes: 1 addition & 1 deletion scenarios/high_defender_tamper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ build_payload() {
"process": {
"user_name": "CORP\\\\attacker",
"file_name": "powershell.exe",
"command_line": "powershell Set-MpPreference -DisableRealtimeMonitoring $true",
"command_line": "powershell Set-MpPreference -DisableRealtimeMonitoring \$true",
"sha256": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
},
"tactic": "${SCENARIO_TACTIC}",
Expand Down
82 changes: 82 additions & 0 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# =============================================================================
# Vyrox Simulator smoke test (CI-05)
# =============================================================================
#
# A fast, hermetic check that the public-facing simulator still emits a
# correctly-shaped, signed request. It needs NO ingestion server: it sources
# simulate.sh (which, thanks to the BASH_SOURCE guard, defines its functions
# without dispatching), builds a real scenario payload, signs it the exact way
# send_alert does, and asserts the signature shape the ingestion webhook
# expects.
#
# What it proves:
# 1. simulate.sh is syntactically valid (sh -n, also run in CI separately).
# 2. build_payload produces valid JSON for a known scenario.
# 3. build_signature produces a 64-char lowercase-hex HMAC-SHA256 digest.
# 4. that digest matches an independent openssl computation (the signer is
# doing real HMAC-SHA256 over the payload with the secret, not a stub).
# 5. the wire header send_alert would set is "sha256=<hex>" (the prefix the
# ingestion service strips), so the contract with the webhook holds.
#
# Exit 0 on success, non-zero (with a message) on the first failed assertion.
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SIM_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"

fail() {
echo "[FAIL] $*" >&2
exit 1
}
ok() {
echo "[OK] $*"
}

# A throwaway test secret (NOT a real credential): the smoke test only checks
# the signature SHAPE, it never reaches a server, so any value works.
TEST_SECRET="smoke-test-secret-not-a-real-credential"
TEST_TENANT="smoke-tenant"

# 1. Syntax check (cheap, also gives a clear message if the script is broken).
sh -n "${SIM_ROOT}/simulate.sh" || fail "simulate.sh failed syntax check (sh -n)"
ok "simulate.sh passes syntax check"

# Source the simulator. The BASH_SOURCE guard in simulate.sh means main does
# NOT run on source, so this defines build_payload/build_signature/etc with no
# network side effects. The scenario scripts define build_payload; source the
# one we test, same as run_scenario does.
# shellcheck source=/dev/null
source "${SIM_ROOT}/simulate.sh"
# shellcheck source=/dev/null
source "${SIM_ROOT}/scenarios/mimikatz.sh"

# 2. build_payload produces valid JSON.
payload="$(build_payload "${TEST_TENANT}")"
[[ -n "${payload}" ]] || fail "build_payload returned an empty payload"
echo "${payload}" | python3 -m json.tool >/dev/null 2>&1 \
|| fail "build_payload did not produce valid JSON"
ok "build_payload emits valid JSON for the mimikatz scenario"

# 3 + 4. build_signature produces a 64-char lowercase-hex digest that matches
# an independent HMAC-SHA256 computation over the same bytes.
signature="$(build_signature "${payload}" "${TEST_SECRET}")"
[[ "${signature}" =~ ^[0-9a-f]{64}$ ]] \
|| fail "build_signature output is not a 64-char lowercase-hex digest: '${signature}'"
ok "build_signature emits a 64-char hex HMAC-SHA256 digest"

expected="$(printf '%s' "${payload}" | openssl dgst -sha256 -hmac "${TEST_SECRET}" | sed 's/^.* //')"
[[ "${signature}" == "${expected}" ]] \
|| fail "build_signature digest does not match an independent openssl HMAC-SHA256"
ok "signature matches an independent HMAC-SHA256 computation"

# 5. The wire header send_alert sets is "sha256=<hex>": the prefix the
# ingestion webhook strips before verifying.
header_value="sha256=${signature}"
[[ "${header_value}" =~ ^sha256=[0-9a-f]{64}$ ]] \
|| fail "X-Vyrox-Signature header is not 'sha256=<64-hex>': '${header_value}'"
ok "X-Vyrox-Signature header has the expected 'sha256=<hex>' shape"

echo "[PASS] simulator smoke test: payload + signed-request shape is correct"
8 changes: 7 additions & 1 deletion simulate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -355,4 +355,10 @@ main() {
fi
}

main "$@"
# Only dispatch when executed directly. When the script is SOURCED (the CI
# smoke test sources it to exercise build_payload / build_signature without a
# live ingestion server), the functions are defined but main does not fire, so
# sourcing has no side effects and sends nothing.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
Loading