From d3d81baee476b61411ede8be15ca4e91eb2c49e9 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Fri, 26 Jun 2026 11:12:20 -0400 Subject: [PATCH 01/13] docs: OSS adoption upgrade Bring the README and maintainer-health files up to the adoption standard. README: - Prominent website link to lidless.dev/soc-stack near the top - Live CI and release status badges plus the existing tool badges - Sharpened first paragraph: what / why / how it differs - New keyword-rich "What it does" section (SOC lab, Proxmox, Wazuh, TheHive, Cortex, MISP, Zeek, Suricata, homelab, MCP) - Redacted result-JSON example block (RFC 5737 documentation IPs) - "Why not something else?" and "What soc-stack is not" sections - Local clone quickstart and an honest single-maintainer status note Health files: - Add CODE_OF_CONDUCT.md (Contributor Covenant 2.1) - Add .github/ISSUE_TEMPLATE/config.yml (no blank issues, route security and questions off the issue tracker) - Add a no-PII / no-leak checkbox to the pull request template - Add an Unreleased CHANGELOG entry for the docs work --- .github/ISSUE_TEMPLATE/config.yml | 11 +++ .github/PULL_REQUEST_TEMPLATE.md | 1 + CHANGELOG.md | 4 ++ CODE_OF_CONDUCT.md | 19 ++++++ README.md | 109 ++++++++++++++++++++++++++---- 5 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 CODE_OF_CONDUCT.md diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a9075f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Project website and tool index + url: https://lidless.dev/soc-stack + about: Overview, the wider lidless.dev SOC tooling, and where this fits. + - name: Security report (do not file publicly) + url: https://github.com/solomonneas/soc-stack/security/advisories/new + about: Report a vulnerability privately. See SECURITY.md. Do not open a public issue for anything that leaks a secret. + - name: Question or usage help + url: https://github.com/solomonneas/soc-stack/discussions + about: For setup questions and general usage, start a discussion rather than filing a bug. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9e7c177..88f30ef 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,6 +10,7 @@ - [ ] Integration assertion passes on the self-hosted Proxmox runner (if touching a component or lib) - [ ] No em-dashes in commit messages or files - [ ] No `Co-Authored-By` trailers +- [ ] No leaked PII, secrets, real private IPs, hostnames, or `/home/` paths (documentation IPs use the RFC 5737 range; real values are scrubbed) ## Related diff --git a/CHANGELOG.md b/CHANGELOG.md index 01270fb..2704557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to soc-stack are documented in this file. Format follows [Ke ## [Unreleased] ### Added +- README adoption pass: prominent website link, live CI and release badges, a keyword-rich "What it does" section, a redacted result-JSON example block, and "Why not something else?" / "What soc-stack is not" sections +- `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1) +- `.github/ISSUE_TEMPLATE/config.yml` (disables blank issues, routes security reports and questions off the issue tracker) +- No-PII / no-secret-leak checkbox in the pull request template - TTY component picker when run locally without `--components`/`--manifest` - `--include-secrets-json` (result JSON redacts credentials by default) and `--mcp-bind-host` (MCP SSE binds 127.0.0.1 by default) - `SECURITY.md` documenting the threat model and hardening posture diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..68844c5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,19 @@ +# Code of Conduct + +This project follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +The full text lives at the link above. The short version: be respectful, assume good faith, keep feedback technical, and remember that everyone here is volunteering their time. + +## Scope + +The Code of Conduct applies to all project spaces: GitHub issues and pull requests on this repo, project-related discussions on Discord or other chat surfaces, and any in-person events where someone is representing the project. + +## Reporting + +If you experience or witness a violation, please email **me@solomonneas.dev**. Reports are confidential. You should get an acknowledgment within 72 hours. + +The maintainer is also the recipient of security reports, see [SECURITY.md](SECURITY.md). Please tag the subject line so it is easy to route (for example "CoC report" vs "security report"). + +## Enforcement + +The maintainer is responsible for clarifying and enforcing standards. Enforcement actions can include private warnings, public clarifications, temporary or permanent bans from project spaces, and reverting or rejecting contributions. diff --git a/README.md b/README.md index 479a389..aafc6d9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,14 @@

SOC Stack

- One-shot Proxmox installer for a complete Security Operations Center. + One command on a Proxmox host builds a complete, self-hosted Security Operations Center lab in about 30 minutes. +

+ +

+ Website: lidless.dev/soc-stack + CI status + Latest release + MIT License

@@ -17,10 +24,28 @@ MCP servers Docker Compose stacks Bash installers - MIT License

-Run one command on a Proxmox host, or have an agent do it, and ~30 minutes later you have Wazuh (SIEM), TheHive + Cortex (case management + SOAR), MISP (threat intel), Zeek + Suricata (NSM + IDS), custom dashboards, and 9 MCP servers wired up and talking to each other. Non-interactive by default. Idempotent. JSON output for agents. Built for replication. +SOC Stack is a one-command installer that stands up a full open-source Security Operations Center on a single Proxmox host: Wazuh, TheHive + Cortex, MISP, Zeek + Suricata, dashboards, and a row of MCP servers, all wired together. You want a realistic SOC to train on, test detections against, or run as a homelab, but assembling six tools by hand and integrating them eats days. It differs from a pile of per-tool guides by treating the whole stack as one declarative, idempotent, agent-friendly deploy: each tool is a self-contained LXC component, cross-component integrations wire automatically, and the entire run is non-interactive with JSON output so an AI agent can SSH in and one-shot it. + +**Website:** [lidless.dev/soc-stack](https://lidless.dev/soc-stack) + +> **Project status.** v1.0.0 is tagged and the full stack deploys and asserts green end-to-end on Proxmox VE 7.x/8.x/9.x. It is an actively developed, single-maintainer lab tool built for homelabs, training, and internal SOC replication, not a hardened multi-tenant production deployment. See [SECURITY.md](SECURITY.md) for the threat model and the [CHANGELOG](CHANGELOG.md) for what is in flight. + +## What it does + +SOC Stack is a self-hosted SOC lab builder for homelabs and security training. Run one command on a Proxmox VE host and roughly 30 minutes later you have a working Security Operations Center: + +- **Wazuh** for SIEM / XDR (alerting, FIM, vulnerability detection, agent management) +- **TheHive + Cortex** for case management and SOAR (analyzers, responders, observable enrichment) +- **MISP** for threat intelligence (IOC sharing, feeds, correlation) +- **Zeek + Suricata** for network security monitoring and intrusion detection (NSM + IDS/IPS) +- **Custom dashboards** (Bro Hunter + Playbook Forge) behind nginx +- **9 MCP servers** so an AI agent can query Wazuh, TheHive, Cortex, MISP, Zeek, Suricata, MITRE ATT&CK, Rapid7, and Sophos over a single MCP config + +Every tool runs in its own dedicated, unprivileged LXC. The orchestrator handles VMID allocation, network setup, idempotency, secret generation, and cross-component integration wiring. The whole run is non-interactive by default and emits structured JSON, so a person or an agent can replicate the same lab on demand. + +Keywords: SOC lab, security operations center, Proxmox homelab, Wazuh SIEM, TheHive, Cortex SOAR, MISP threat intelligence, Suricata IDS, Zeek NSM, blue team training, detection engineering, self-hosted security, MCP servers for security tooling. ## Quick start @@ -52,6 +77,14 @@ curl -sSL https://raw.githubusercontent.com/solomonneas/soc-stack/main/install.s --mcp-config-out /root/mcp-clients.json ``` +Prefer to read before you run? Clone the repo and execute the same orchestrator locally; the behavior is identical: + +```bash +git clone https://github.com/solomonneas/soc-stack.git +cd soc-stack +sudo bash install.sh --components all --dry-run # validate + plan, deploy nothing +``` + After install: - `/root/soc-stack.json` lists every component with its LXC VMID, IP, ports, endpoints, warnings, and secret file paths. Raw passwords and API tokens are redacted by default; pass `--include-secrets-json` only when an automation workflow explicitly needs them. - `/root/mcp-clients.json` is a paste-ready `mcpServers` config block for Claude Desktop, OpenClaw, or any MCP client. It contains bearer tokens and is written root-only. @@ -77,15 +110,46 @@ Each component runs in its own dedicated LXC. Components can be deployed indepen Configured automatically after all components deploy: -- **Wazuh → TheHive**: Wazuh alerts at level 8+ forward to TheHive as alerts via a custom Python integration (`/var/ossec/integrations/custom-thehive.py`). -- **TheHive ↔ Cortex**: TheHive's Cortex connector points at the local Cortex with an org-scoped API key. -- **MISP → Suricata**: hourly cron pulls Snort/Suricata rules from MISP's `restSearch` endpoint into Suricata's update.d. -- **Zeek → Wazuh**: Wazuh agent runs in the zeek-suricata LXC and forwards conn.log, dns.log, http.log, ssl.log, notice.log to the Wazuh manager. -- **MCP servers ← all peers**: each MCP server's env file is populated with its corresponding tool's URL + API key from peer state. - -## Status +- **Wazuh -> TheHive**: Wazuh alerts at level 8+ forward to TheHive as alerts via a custom Python integration (`/var/ossec/integrations/custom-thehive.py`). +- **TheHive <-> Cortex**: TheHive's Cortex connector points at the local Cortex with an org-scoped API key. +- **MISP -> Suricata**: hourly cron pulls Snort/Suricata rules from MISP's `restSearch` endpoint into Suricata's update.d. +- **Zeek -> Wazuh**: Wazuh agent runs in the zeek-suricata LXC and forwards conn.log, dns.log, http.log, ssl.log, notice.log to the Wazuh manager. +- **MCP servers <- all peers**: each MCP server's env file is populated with its corresponding tool's URL + API key from peer state. + +## What a finished install looks like + +`/root/soc-stack.json` is the source of truth for what got deployed. With secrets redacted, a full-stack run reports each component, its LXC, its endpoints, and any warnings (IPs and tokens below are placeholder values from the [RFC 5737](https://datatracker.ietf.org/doc/html/rfc5737) documentation range): + +```json +{ + "version": "1.0.0", + "preset": "minimal", + "status": "deployed", + "components": [ + { + "component": "wazuh", + "status": "deployed", + "vmid": 9001, + "ip": "192.0.2.11", + "endpoints": { "dashboard": "https://192.0.2.11:443", "api": "https://192.0.2.11:55000" }, + "credentials": { "user": "admin", "password": "REDACTED" } + }, + { + "component": "mcp", + "status": "deployed", + "vmid": 9006, + "ip": "192.0.2.16", + "endpoints": { "wazuh_sse": "http://127.0.0.1:3001/sse", "thehive_sse": "http://127.0.0.1:3002/sse" } + } + ], + "integrations": [ + { "from": "wazuh", "to": "thehive", "status": "wired" }, + { "from": "misp", "to": "suricata", "status": "wired" } + ] +} +``` -**v1.0.0** (current, 2026-05-16): All 6 components + 5 cross-component integrations verified end-to-end on Proxmox VE. Self-hosted CI runs on every PR (shellcheck, bats, manifest validation, per-component integration matrix) and on merge to main (full-stack integration). See the [CHANGELOG](CHANGELOG.md) for full history. +The exact result-JSON schema is documented in [`docs/design/specs/2026-05-15-soc-stack-unification-design.md`](docs/design/specs/2026-05-15-soc-stack-unification-design.md). ## Agent-friendly contract @@ -226,14 +290,33 @@ curl -sSL https://raw.githubusercontent.com/solomonneas/soc-stack/main/install.s ``` Re-running the installer from a newer checkout is the upgrade path: already-deployed components are left alone, new components deploy, and integration re-wires. To pick up a new version of one component, destroy it and re-run with `--components `. The installer never auto-updates a running component in place. +## Why not something else? + +- **Why not install each tool by hand?** You can, and the official docs for Wazuh, TheHive, MISP, and Suricata are good. But six installs plus the integrations between them (alert forwarding, analyzer wiring, IOC feeds, log shipping) is a multi-day project that breaks the next time you rebuild. SOC Stack makes the whole thing one reproducible command. +- **Why not a single all-in-one SIEM VM (SecurityOnion, Wazuh OVA, etc.)?** Those are excellent and purpose-built. SOC Stack is different on purpose: each tool lives in its own LXC you can scale, snapshot, or destroy independently, the components are the real upstream projects (not a fork), and the cross-tool integrations are explicit and inspectable rather than baked into one appliance. +- **Why not Ansible or Terraform?** Nothing stops you, and a config-management rewrite is a reasonable future direction. The current design favors plain, auditable bash you can read top to bottom and a `curl | sudo bash` path an agent can drive without extra tooling on the host. State files, not a state backend, drive idempotency. +- **Why not run it on Docker / Kubernetes directly?** Several components already use Docker Compose inside their LXC. The Proxmox LXC layer gives each tool isolation, its own IP, and snapshot/rollback at the container level, which matches how a homelab SOC is actually operated. + +## What soc-stack is not + +- **Not a hardened production SOC.** It is a lab and training tool. It assumes a trusted Proxmox host and a trusted internal bridge. Do not expose the component IPs to an untrusted network without a firewall, VLAN, and TLS termination in front. The full threat model is in [SECURITY.md](SECURITY.md). +- **Not a managed or hosted service.** There is no SaaS, no telemetry, and no phone-home. Everything runs on hardware you control. +- **Not a fork or a repackage of the upstream tools.** It deploys Wazuh, TheHive, Cortex, MISP, Zeek, and Suricata from their real sources at pinned versions; it does not modify them. +- **Not an auto-updater.** The installer pins versions and never silently upgrades a running component in place; updates happen on your schedule. +- **Not multi-host.** It targets a single Proxmox host. Multi-node is out of scope today. + ## Adding a new component See [docs/adding-a-component.md](docs/adding-a-component.md) for the component contract walk-through, and [docs/design/specs/2026-05-15-soc-stack-unification-design.md](docs/design/specs/2026-05-15-soc-stack-unification-design.md) for the full design. +## Contributing + +Contributions are welcome. New components follow a six-file contract, every lib function gets a bats test, and CI runs shellcheck plus bats on every PR. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). + ## Security -Default credentials are rotated and verified on deploy, secrets are root-only, result JSON is redacted by default, and MCP servers bind to localhost unless you say otherwise. The full threat model, what is hardened versus deliberately accepted, lives in [SECURITY.md](SECURITY.md). +Default credentials are rotated and verified on deploy, secrets are root-only, result JSON is redacted by default, and MCP servers bind to localhost unless you say otherwise. The full threat model, what is hardened versus deliberately accepted, lives in [SECURITY.md](SECURITY.md). Found a vulnerability? Use GitHub's private vulnerability reporting on this repository. ## License -MIT +[MIT](LICENSE). Copyright (c) 2026 Solomon Neas. From 3961765c34d00672e64d72c33373e2e230cf11ca Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Fri, 3 Jul 2026 12:14:48 -0400 Subject: [PATCH 02/13] chore: wire brigade work loop --- .gitignore | 88 ++++++++++++++++++++++++++ SAFETY_RULES.md | 164 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 SAFETY_RULES.md diff --git a/.gitignore b/.gitignore index 0c0965b..f026983 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,91 @@ docs/design/plans/ # Local brigade memory (cards are local-only, never committed) /memory/ .brigade/ + +# >>> brigade gitignore block >>> +# Managed by `brigade init`. Edit between the markers to customize. +# Re-running `brigade init` replaces only the content between markers. + +# claude: handoffs are session-local and may contain private context. +.claude/memory-handoffs/* +!.claude/memory-handoffs/TEMPLATE.md +!.claude/memory-handoffs/.gitkeep + +# Daily session logs are machine-local raw context. +memory/20[0-9][0-9]-[0-1][0-9]-[0-3][0-9].md + +# Review inbox: ambiguous handoffs awaiting human triage. +memory/handoff-inbox/ + +# brigade local state (logs, scrub cache, dogfood runs, work sessions). +.brigade/ +.brigade/backups/ +.brigade/backups.toml +.brigade/center/ +.brigade/context/ +.brigade/dogfood.toml +.brigade/handoffs/ +.brigade/handoff-sources.json +.brigade/learn/ +.brigade/projects.toml +.brigade/release/ +.brigade/repos.toml +.brigade/chat-surfaces.toml +.brigade/daily.toml +.brigade/memory-care.toml +.brigade/reviews.toml +.brigade/scanners.toml +.brigade/security.toml +.brigade/tools.toml +.brigade/logs/ +.brigade/runs/ +.brigade/scrub-cache/ +.brigade/scanners/ +.brigade/security/ +.brigade/tools/ +.brigade/chat-memory-sweeps/ +.brigade/work/ +.brigade/mcp/ +# .brigade/mcp.json is the shared canonical MCP server catalog: keep it tracked. +!.brigade/mcp.json + +# Generated tool projections are local harness state. +.claude/commands/ +.codex/skills/ +.opencode/commands/ +.opencode/superpowers/ +.antigravity/commands/ +.antigravity/superpowers/ +.pi/commands/ +.pi/superpowers/ +.cursor/rules/ +.cursor/skills/ +.aider/commands/ +.aider/skills/ +.goose/commands/ +.goose/skills/ +.continue/rules/ +.continue/skills/ +.copilot/instructions/ +.copilot/skills/ +.qwen/commands/ +.qwen/skills/ +.kimi/commands/ +.kimi/skills/ +.adal/commands/ +.adal/skills/ +.openhands/instructions/ +.openhands/skills/ +.grok/instructions/ +.grok/skills/ +.amp/instructions/ +.amp/skills/ +.crush/instructions/ +.crush/skills/ +.hermes/commands/ +.hermes/superpowers/ +.openclaw/commands/ +.openclaw/superpowers/ +.mcp/ +scripts/*.md +# <<< brigade gitignore block <<< diff --git a/SAFETY_RULES.md b/SAFETY_RULES.md new file mode 100644 index 0000000..8cbf262 --- /dev/null +++ b/SAFETY_RULES.md @@ -0,0 +1,164 @@ +# SAFETY_RULES.md + +Hard boundaries. These are not preferences. The content-guard pre-push hook and `brigade scrub` enforce some of these mechanically; the rest are agent-side rules. + +--- + +## Content Sanitization for Publishing + +**Never publish infrastructure details in blog posts, social media, or any public content.** + +Sanitize before publishing: + +- **IP addresses:** Replace real IPs with documented examples (e.g. `203.0.113.x`, `192.0.2.x`, `198.51.100.x` from RFC 5737). +- **Internal domain names:** Replace real domains with placeholders (e.g. `corp.local` -> `lab.local`). +- **OU names / paths:** Replace real OUs. +- **Service account names:** Replace real accounts with descriptive placeholders. +- **Hostnames:** Replace real hostnames with generic ones. +- **Credentials:** Remove entirely or use `` placeholder. +- **Combined identifiers:** Room numbers + IPs + domain + account name paint a full network map. Sanitize all of them together, not piecemeal. + +The pre-push hook runs content-guard with the `public-repo` policy. For publish-ready artifacts (blog posts, social drafts, docs), use the stricter `public-content` policy: `brigade scrub --policy public-content`. + +--- + +## External Communication + +**Never send emails, messages, or social posts on the user's behalf without explicit confirmation.** + +- Draft only. Save to file or display the draft. +- The user reviews and sends manually, or grants explicit permission. +- Exception: test messages to the user themselves are fine if explicitly requested. + +--- + +## Safe vs. ask-first + +**Safe to do freely:** + +- Reading files, research, web searches. +- Drafting content, code, documents. +- Organizing files and notes. +- Local file operations: create, edit, move. +- Checking calendars, weather, status APIs. + +**Always ask first:** + +- Sending emails, messages, or any external communication. +- Posting to social media. +- Making purchases or financial transactions. +- Deleting files or data. +- Running destructive commands (`rm`, `dd`, `git push --force`, `pct destroy`, etc.). + +--- + +## Preferred Tools + +- Use `trash` (or your platform equivalent) instead of `rm`. Recoverable beats gone forever. +- Use `git push --no-verify` only when the user has explicitly accepted the risk. Even then, log why. + +--- + +## Skill and Package Installation Safety + +**Never install any external skill, package, or dependency without explicit user approval.** + +Before installing anything (even with user approval): + +1. Search the exact package name in your registry's malware database before running any install command. +2. Check for typosquatting (similar names to popular packages). +3. Review the package source for: + - Suspicious "Prerequisites" sections asking to download external binaries. + - Reverse-shell code or outbound connections to unknown hosts. + - Any code that reads `.env`, API keys, or credential files. + - Obfuscated shell scripts or password-protected archives. +4. If a package appears in a malware database or shows red flags: **do not install** and alert the user immediately. + +**Default stance:** only use skills the user built themselves or has explicitly vetted and approved. Do not browse public skill registries autonomously. + +**Applies to:** npm, pip, cargo, go modules, gem, plugin registries, skill stores, and any package manager. + +--- + +## Git Commit Rules + +**Never add AI attribution to commits.** + +- No `Co-Authored-By` lines pointing at any AI/model/vendor. +- No `noreply@.com` (e.g. `noreply` addresses from AI vendors) or any AI-vendor email. +- No mentions of "Claude", "AI", "GPT", "Anthropic", "OpenAI", or the agent's own name in commit messages. + +**Commit style:** + +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `perf:`. +- Write as a human developer would. +- Focus on **what** changed and **why**. +- Keep messages concise and professional. + +**Sensitive data in git history:** + +- If sensitive data was committed, `git rm` does **not** remove it from history. +- Use `git filter-repo` (preferred) or `git filter-branch` plus force push. +- Verify with `git log -p -- ` after cleanup. +- Force-push only after coordinating with anyone else on the branch. + +--- + +## Memory Hygiene + +- Do not write durable memory entries directly; use the handoff flow. +- Do not promote unverified reflections into canonical memory. +- Stale memory is worse than missing memory. Update or remove entries when their basis changes. +- Do not load knowledge cards in shared / group contexts that include other people. + +--- + +## Production / Remote Safety + +If you have access to remote hosts, virtualization, or shared infrastructure, treat them as production unless the user has explicitly said otherwise. + +**Never without explicit confirmation:** + +- Destroy or stop VMs / containers. +- Modify network config on running containers. +- Recursive force-deletion inside production. +- Change firewall, DNS, or routing rules. + +**Safe to do freely on shared infra:** + +- Read-only inspection: `status`, `config`, `list`, `top`-like commands. +- Resource monitoring. +- Non-destructive snapshots and backups. + +--- + +## Data Stores Worth Protecting + +If the workspace touches irreplaceable data (family photos, archives, backups, phone exports), default that mount or path to **read-only**. + +Rules: + +- No `rm`, `trash`, `mv` on the protected path without explicit confirmation. +- No bulk operations (`rsync --delete`, `find -delete`) against the protected path. +- Copy **from** the path, rarely **to** it. + +Document the protected paths and what lives there. + +--- + +## Personal Workstation Safety + +If the workspace shares a network with the user's personal daily driver (different machine, same LAN), treat that machine as **off-limits without explicit confirmation**. Do not restart, kill processes, install software, or modify settings remotely. Read-only access is fine; mutation is not. + +--- + +## NEVER + +- Racist, political, anti-religious, or whiny output. +- Posting on behalf of the user without approval. +- Bypassing the content-guard publish gate without explicit acceptance. +- Disclosing the internal AI drafting workflow for the user's public-facing content unless they explicitly approved. + +--- + +*Add new rules here as the user corrects you. The point is to stop repeating the same mistakes, not to write a manifesto.* From ba2339f36652771451640ba8365f05e56d4e4672 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Wed, 8 Jul 2026 17:16:02 -0400 Subject: [PATCH 03/13] chore: switch pre-push hook to embedded brigade guard --- hooks/pre-push | 93 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/hooks/pre-push b/hooks/pre-push index bda2c30..e1f8751 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -1,39 +1,78 @@ #!/usr/bin/env bash -# pre-push: block push if content-guard finds blocking violations. -# Scans tracked files in the repo against policies/public-repo.json. -# (Tracked-only scan — untracked working-tree files are out of scope.) -# Bypass only if you know what you're doing: git push --no-verify +# pre-push: block push if brigade guard finds blocking violations. +# +# Two scans run: +# 1. tracked tip - all tracked files in the current working tree +# 2. push history - content INTRODUCED by the commits being pushed +# (closes the forward-scrub gap: a clean tip can still +# sit on top of commits that leak in their diffs) +# +# Requires brigade-cli (the guard is embedded): pipx install brigade-cli +# +# Optional: set CONTENT_GUARD_EXTRA_POLICY (or drop a file at +# ~/.config/content-guard/internal.json) with a private identifier denylist +# (hostnames, usernames, internal subnets). That file is NEVER committed to a +# public repo - it stays local so the denylist itself does not leak. +# +# Bypass only if you know what you are doing: git push --no-verify set -euo pipefail -CONTENT_GUARD_DIR="${CONTENT_GUARD_DIR:-$HOME/repos/content-guard}" -POLICY="${CONTENT_GUARD_POLICY:-$CONTENT_GUARD_DIR/policies/public-repo.json}" - -if [[ ! -d "$CONTENT_GUARD_DIR/src/content_guard" ]]; then - echo "pre-push: content-guard not found at $CONTENT_GUARD_DIR" >&2 - echo "pre-push: clone https://github.com/solomonneas/content-guard or set CONTENT_GUARD_DIR" >&2 +if ! command -v brigade >/dev/null 2>&1; then + echo "pre-push: brigade not found on PATH" >&2 + echo "pre-push: install with: pipx install brigade-cli" >&2 exit 1 fi -if [[ ! -f "$POLICY" ]]; then - echo "pre-push: policy file not found: $POLICY" >&2 - exit 1 -fi +EXTRA_POLICY="${CONTENT_GUARD_EXTRA_POLICY:-$HOME/.config/content-guard/internal.json}" REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" -echo "pre-push: scanning tracked files against $(basename "$POLICY")" +RUN() { brigade guard git "$@"; } +ZERO="0000000000000000000000000000000000000000" +FAIL=0 +STDIN_REFS="$(cat)" + +# Apply the private policy's allow_values (known-public literals such as a +# public author email or an example port) to EVERY scan, including the public +# one. This is how a history scan of an old commit clears those literals: no +# inline `content-guard: allow` marker can exist in a past diff, and the values +# stay in the private file rather than a shipped public policy. +ALLOW_FROM=() +[[ -f "$EXTRA_POLICY" ]] && ALLOW_FROM=(--allow-values-from "$EXTRA_POLICY") + +scan_with() { + # No args = the embedded public-repo policy. Otherwise: --policy . + local -a pol=("$@") + echo "pre-push: [tip] scanning tracked files (${pol[1]:-embedded public-repo})" + RUN --all-tracked "${pol[@]+"${pol[@]}"}" "${ALLOW_FROM[@]+"${ALLOW_FROM[@]}"}" || FAIL=1 + while read -r _lref lsha _rref rsha; do + [[ -z "${lsha:-}" || "$lsha" == "$ZERO" ]] && continue # branch deletion + if [[ "$rsha" == "$ZERO" ]]; then range="$lsha"; else range="$rsha..$lsha"; fi + echo "pre-push: [history] scanning introduced content in $range" + RUN --history --range "$range" "${pol[@]+"${pol[@]}"}" "${ALLOW_FROM[@]+"${ALLOW_FROM[@]}"}" || FAIL=1 + done < <(printf '%s\n' "$STDIN_REFS") +} + +if [[ -n "${CONTENT_GUARD_POLICY:-}" ]]; then + scan_with --policy "$CONTENT_GUARD_POLICY" +else + scan_with +fi +[[ -f "$EXTRA_POLICY" ]] && scan_with --policy "$EXTRA_POLICY" -if ! PYTHONPATH="$CONTENT_GUARD_DIR/src" python3 -m content_guard.git_scan --all-tracked --policy "$POLICY"; then - echo >&2 - echo "pre-push: BLOCKED. content-guard found violations in tracked files." >&2 - echo "pre-push:" >&2 - echo "pre-push: To resolve:" >&2 - echo "pre-push: 1. Fix the leak in the offending file, OR" >&2 - echo "pre-push: 2. Add an inline allow tag on the offending line:" >&2 - echo "pre-push: " >&2 - echo "pre-push: or for the whole file (add near the top):" >&2 - echo "pre-push: " >&2 - echo "pre-push:" >&2 - echo "pre-push: Bypass only if you know what you are doing: git push --no-verify" >&2 +if [[ "$FAIL" -ne 0 ]]; then + { + echo + echo "pre-push: BLOCKED. brigade guard found violations." + echo "pre-push: A [history] hit means a leak lives in a commit diff even if the tip is clean." + echo "pre-push: Forward-scrub commits do NOT fix history - rewrite it (git filter-repo) and" + echo "pre-push: re-verify with: brigade guard git --history --all" + echo "pre-push: Inline allow on the tip (only for genuinely public example data, never real infra):" + echo "pre-push: " + echo "pre-push: For a known-public literal that trips a [history] hit (no inline marker can" + echo "pre-push: reach an old diff), add the exact string to allow_values in your private policy" + echo "pre-push: ${EXTRA_POLICY}" + echo "pre-push: Bypass only if you know what you are doing: git push --no-verify" + } >&2 exit 1 fi From 9c56424784e3411e8170d834524a1667172f34aa Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Tue, 14 Jul 2026 11:58:35 -0400 Subject: [PATCH 04/13] harden(core): remove dead legacy installer; fix state, secret, and redaction bugs Remove the unreferenced legacy scripts/setup/** tree (Plan-3 leftover). It was excluded from the shellcheck gate and carried real smells that contradicted the security posture: verify=False forwarding alerts to TheHive, a MISP installer pulled from the mutable 2.4 branch and run as root, cleartext password printing, stale default-credential text, and an allow-all firewall. Nothing live sourced it (only design docs referenced it). Shared-lib correctness and secret-handling fixes, all covered by new unit tests: - secrets.sh: gen_password returned 141 on success under `set -o pipefail` because tr takes SIGPIPE when head closes the pipe. Capture, tolerate the expected failure, and assert the length so a genuine short read still fails. - json-out.sh state_set: `jq > tmp; mv tmp f` ran the mv even when jq failed, truncating good state to empty (broke idempotency). Only replace on success, reinitialize a corrupt file instead of destroying it, mktemp in the target dir for an atomic same-fs rename, and pass the key via --arg (no jq injection). - json-out.sh state_get: tolerate a corrupt state file (read as empty, do not abort a `set -e` caller); pass key via --arg. - json-out.sh redact_json: broaden the key heuristic (pwd/passwd/bearer/ credential/private_key) and scrub credentials embedded in URL values, while only collapsing a matching key when its value is scalar so nested shapes and their per-key redaction are preserved. - manifest.sh validate_manifest: exact-token component match; `grep -qw` had false-accepted "cortex" and treated names as regex ("m.p" matched "mcp"). - install.sh parse_args: accept the --flag=value form (was rejected as unknown). --- scripts/install.sh | 7 + scripts/lib/json-out.sh | 50 +- scripts/lib/manifest.sh | 8 +- scripts/lib/secrets.sh | 19 +- scripts/setup/components/cortex.sh | 120 --- scripts/setup/components/dashboards.sh | 243 ------ scripts/setup/components/misp.sh | 67 -- scripts/setup/components/suricata.sh | 81 -- scripts/setup/components/thehive.sh | 102 --- scripts/setup/components/wazuh.sh | 88 -- scripts/setup/components/zeek.sh | 92 -- scripts/setup/install.sh | 807 ------------------ scripts/setup/integrate.sh | 261 ------ tests/unit/test_json_out.bats | 39 + tests/unit/test_manifest.bats | 22 + .../unit/test_orchestrator_flag_parsing.bats | 8 + tests/unit/test_secrets.bats | 14 + 17 files changed, 153 insertions(+), 1875 deletions(-) delete mode 100644 scripts/setup/components/cortex.sh delete mode 100644 scripts/setup/components/dashboards.sh delete mode 100644 scripts/setup/components/misp.sh delete mode 100644 scripts/setup/components/suricata.sh delete mode 100644 scripts/setup/components/thehive.sh delete mode 100644 scripts/setup/components/wazuh.sh delete mode 100644 scripts/setup/components/zeek.sh delete mode 100644 scripts/setup/install.sh delete mode 100644 scripts/setup/integrate.sh diff --git a/scripts/install.sh b/scripts/install.sh index 34d26a0..c584ad6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -88,6 +88,13 @@ EOF parse_args() { local flag while [[ $# -gt 0 ]]; do + # Accept the --flag=value form by splitting it into --flag value. Many + # copy-pasted `curl | bash` one-liners use `=`; without this it fell through + # to the unknown-flag branch and exited 2. + if [[ "$1" == --*=* ]]; then + local _k="${1%%=*}" _v="${1#*=}" + set -- "${_k}" "${_v}" "${@:2}" + fi case "$1" in --components|--preset|--bridge|--storage|--ip-mode|--ip-range|--vlan|--vmid-start|--manifest|--state-dir|--json-out|--mcp-config-out|--log-file|--mcp-bind-host) flag="$1" diff --git a/scripts/lib/json-out.sh b/scripts/lib/json-out.sh index 46ec7ab..3da3032 100644 --- a/scripts/lib/json-out.sh +++ b/scripts/lib/json-out.sh @@ -35,6 +35,17 @@ state_set() { secure_dir "$(dirname "${f}")" [[ -f "${f}" ]] || { echo '{}' > "${f}"; chmod 600 "${f}" 2>/dev/null || true; } + # A corrupt existing state file (e.g. a partial write from an earlier crash) + # would make the jq edit below fail. Without a guard, the unconditional mv + # then truncated the file to empty and idempotency was lost forever. Re-seed + # from {} so the edit proceeds and only this one update is missing, not all + # of the recorded state. + if [[ -s "${f}" ]] && ! jq -e . "${f}" >/dev/null 2>&1; then + msg_warn "state file ${f} was not valid JSON; reinitializing" + echo '{}' > "${f}" + chmod 600 "${f}" 2>/dev/null || true + fi + # Try to parse value as JSON; if it fails, treat as string local jq_value if printf '%s' "${value}" | jq -e . >/dev/null 2>&1; then @@ -43,11 +54,23 @@ state_set() { jq_value="$(printf '%s' "${value}" | jq -R '.')" fi + # Write to a temp file in the SAME directory as the target so the final mv is + # an atomic same-filesystem rename (mktemp under $TMPDIR could land on a + # different filesystem, degrading mv to a non-atomic copy+unlink). Replace the + # target only if jq actually succeeded; otherwise keep the old file intact. + # Pass the key via --arg rather than interpolating it into the jq program, so + # a key containing jq metacharacters cannot alter the filter. local tmp - tmp="$(mktemp)" - jq --argjson v "${jq_value}" "setpath(\"${key}\" | split(\".\"); \$v)" "${f}" > "${tmp}" - mv "${tmp}" "${f}" - chmod 600 "${f}" 2>/dev/null || true + tmp="$(mktemp "${f}.XXXXXX")" + if jq --argjson v "${jq_value}" --arg k "${key}" \ + 'setpath($k | split("."); $v)' "${f}" > "${tmp}"; then + mv "${tmp}" "${f}" + chmod 600 "${f}" 2>/dev/null || true + else + rm -f "${tmp}" + msg_error "state_set: failed to update ${f} (key=${key})" + return 1 + fi } # state_get @@ -58,7 +81,10 @@ state_get() { local f f="$(state_file "${component}")" [[ -f "${f}" ]] || return 0 - jq -r "getpath(\"${key}\" | split(\".\")) // empty" "${f}" + # A corrupt state file must read as "empty" (and not abort a `set -e` caller + # such as is_completed), so swallow jq failures. Key passed via --arg to keep + # jq metacharacters in the key from altering the filter. + jq -r --arg k "${key}" 'getpath($k | split(".")) // empty' "${f}" 2>/dev/null || true } # component_secret_files_json @@ -90,10 +116,18 @@ component_secret_files_json() { redact_json() { jq ' + # Key-name match is a heuristic; it stays broad on purpose because a new + # component author who names a field pw/passwd/bearer/credential should not + # silently leak it into the default (redacted) result JSON. See + # docs/adding-a-component.md for the naming contract. + def secret_key: test("(pass(word|phrase|wd)?|pwd|api_?key|apikey|secret|token|authorization|bearer|credential|private_?key)"; "i"); def redact: if type == "object" then with_entries( - if (.key | test("(password|passphrase|api_?key|token|secret|authorization)"; "i")) then + # Redact a matching key only when its value is a scalar. If it holds an + # object/array (e.g. "credentials": {...}) keep recursing so the nested + # secrets are redacted by their own keys and the shape is preserved. + if (.key | secret_key) and ((.value | type) != "object") and ((.value | type) != "array") then .value = "REDACTED" else .value |= redact @@ -101,6 +135,10 @@ redact_json() { ) elif type == "array" then map(redact) + elif type == "string" then + # Scrub credentials embedded in a URL value (scheme://user:pass@host), + # which no key-name check would ever catch. + gsub("(?://[^:/@[:space:]]+:)(?

[^@/[:space:]]+)(?@)"; "\(.u)REDACTED\(.a)") else . end; diff --git a/scripts/lib/manifest.sh b/scripts/lib/manifest.sh index 7600b5d..e5b58b4 100644 --- a/scripts/lib/manifest.sh +++ b/scripts/lib/manifest.sh @@ -28,12 +28,14 @@ validate_manifest() { return 1 fi - # Each component must be a known name - local known="wazuh thehive-cortex misp zeek-suricata dashboards mcp" + # Each component must be a known name. Exact-token match via space-padding: + # `grep -qw` false-accepted "cortex" (word boundary inside "thehive-cortex") + # and treated the component name as a regex ("m.p" matched "mcp"). + local known=" wazuh thehive-cortex misp zeek-suricata dashboards mcp " local bad=() while IFS= read -r c; do [[ -n "${c}" ]] || continue - if ! grep -qw "${c}" <<< "${known}"; then + if [[ "${known}" != *" ${c} "* ]]; then bad+=("${c}") fi done < <(jq -r '.components[]' "${file}") diff --git a/scripts/lib/secrets.sh b/scripts/lib/secrets.sh index e2b65aa..ef22f51 100644 --- a/scripts/lib/secrets.sh +++ b/scripts/lib/secrets.sh @@ -9,14 +9,23 @@ # Emits an alnum + safe-special password of given length (default 24). # Safe chars only - no shell metacharacters that would need quoting. # -# Implementation note: head closes its stdin after reading bytes, -# which causes tr to get SIGPIPE and write a "Broken pipe" error to stderr. -# In some environments (CI in particular) that error stream interleaves with -# the stdout capture. We discard tr's stderr to keep the output clean. +# Implementation note: head closes its stdin after reading bytes, so tr +# takes SIGPIPE. That writes a "Broken pipe" to stderr (discarded here) AND, +# under `set -o pipefail` (which the orchestrator sets), makes the whole +# pipeline exit 141 even though the read succeeded. Capturing the output and +# swallowing that expected failure with `|| true` stops a successful call from +# aborting a `set -e` caller; the length assertion then still fails loudly if +# /dev/urandom genuinely returned short. gen_password() { local len="${1:-24}" local charset='A-Za-z0-9_+=.-' - LC_ALL=C tr -dc "${charset}" /dev/null | head -c "${len}" + local out="" + out="$(LC_ALL=C tr -dc "${charset}" /dev/null | head -c "${len}")" || true + if (( ${#out} < len )); then + msg_error "gen_password: produced ${#out}/${len} chars from /dev/urandom" + return 1 + fi + printf '%s' "${out}" } # store_secret diff --git a/scripts/setup/components/cortex.sh b/scripts/setup/components/cortex.sh deleted file mode 100644 index 92a0334..0000000 --- a/scripts/setup/components/cortex.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack - Cortex 3.x Installer -# Installs Cortex 3.x with common analyzers -# -# MIT License - Copyright (c) 2024 Solomon Neas -# ------------------------------------------------------------------------------ - -set -euo pipefail - -COMPONENT="Cortex" -LOG_TAG="[S³:${COMPONENT}]" - -GN="\033[1;92m" RD="\033[01;31m" CY="\033[36m" CL="\033[m" -CM="${GN}✓${CL}" CROSS="${RD}✗${CL}" INFO="${CY}ℹ${CL}" - -msg_info() { echo -e " ${INFO} ${LOG_TAG} ${1}..."; } -msg_ok() { echo -e " ${CM} ${LOG_TAG} ${1}"; } -msg_error(){ echo -e " ${CROSS} ${LOG_TAG} ${1}"; } - -# ── Idempotency ────────────────────────────────────────────────────────────── -if systemctl is-active --quiet cortex 2>/dev/null; then - msg_ok "Cortex already running (skipping install)" - exit 0 -fi - -# ── System Prep ─────────────────────────────────────────────────────────────── -msg_info "Updating system packages" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq &>/dev/null -apt-get upgrade -y -qq &>/dev/null -apt-get install -y -qq curl wget gnupg apt-transport-https ca-certificates \ - software-properties-common python3 python3-pip &>/dev/null -msg_ok "System updated" - -# ── Java 11 ─────────────────────────────────────────────────────────────────── -msg_info "Installing Java 11" -apt-get install -y -qq openjdk-11-jre-headless &>/dev/null -msg_ok "Java 11 installed" - -# ── Elasticsearch 7.x ──────────────────────────────────────────────────────── -if ! systemctl is-active --quiet elasticsearch 2>/dev/null; then - msg_info "Installing Elasticsearch 7.x" - wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | \ - gpg --dearmor -o /usr/share/keyrings/elasticsearch.gpg 2>/dev/null - echo "deb [signed-by=/usr/share/keyrings/elasticsearch.gpg] https://artifacts.elastic.co/packages/7.x/apt stable main" \ - > /etc/apt/sources.list.d/elasticsearch.list - apt-get update -qq &>/dev/null - apt-get install -y -qq elasticsearch &>/dev/null - - cat >> /etc/elasticsearch/elasticsearch.yml <<'EOF' -cluster.name: cortex -node.name: node-1 -network.host: 127.0.0.1 -http.port: 9200 -discovery.type: single-node -xpack.security.enabled: false -EOF - - systemctl enable --now elasticsearch &>/dev/null - msg_ok "Elasticsearch installed" -else - msg_ok "Elasticsearch already running" -fi - -# ── Cortex 3.x ─────────────────────────────────────────────────────────────── -msg_info "Installing Cortex 3.x" -curl -fsSL https://archives.strangebee.com/keys/strangebee.gpg | \ - gpg --dearmor -o /usr/share/keyrings/strangebee.gpg 2>/dev/null -echo "deb [signed-by=/usr/share/keyrings/strangebee.gpg] https://deb.strangebee.com cortex-3.x main" \ - > /etc/apt/sources.list.d/cortex.list -apt-get update -qq &>/dev/null -apt-get install -y -qq cortex &>/dev/null - -# Configure Cortex -mkdir -p /opt/cortex/analyzers /opt/cortex/responders -cat > /etc/cortex/application.conf <<'EOF' -search { - uri = "http://127.0.0.1:9200" - index = cortex -} - -analyzer { - urls = ["/opt/cortex/analyzers"] -} - -responder { - urls = ["/opt/cortex/responders"] -} -EOF - -systemctl enable --now cortex &>/dev/null -msg_ok "Cortex 3.x installed" - -# ── Install Analyzers ──────────────────────────────────────────────────────── -msg_info "Downloading Cortex analyzers" -if command -v git &>/dev/null || apt-get install -y -qq git &>/dev/null; then - git clone https://github.com/TheHive-Project/Cortex-Analyzers.git \ - /opt/cortex/Cortex-Analyzers &>/dev/null 2>&1 || true - ln -sf /opt/cortex/Cortex-Analyzers/analyzers/* /opt/cortex/analyzers/ 2>/dev/null || true - ln -sf /opt/cortex/Cortex-Analyzers/responders/* /opt/cortex/responders/ 2>/dev/null || true -fi - -# Install Python dependencies for common analyzers -pip3 install cortexutils requests &>/dev/null 2>&1 || true -msg_ok "Analyzers configured" - -# ── Verify ──────────────────────────────────────────────────────────────────── -sleep 5 -if systemctl is-active --quiet cortex; then - msg_ok "Cortex is running" -else - msg_error "Cortex failed to start (check journalctl -u cortex)" -fi - -echo "" -msg_ok "Cortex installation complete" -echo -e " ${CY}URL:${CL} http://$(hostname -I | awk '{print $1}'):9001" -echo -e " ${CY}Setup:${CL} Complete first-run wizard in browser" -echo "" diff --git a/scripts/setup/components/dashboards.sh b/scripts/setup/components/dashboards.sh deleted file mode 100644 index cf89b91..0000000 --- a/scripts/setup/components/dashboards.sh +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack - Custom Dashboards Installer -# Installs Bro Hunter + Playbook Forge in a shared LXC with nginx reverse proxy -# -# MIT License - Copyright (c) 2024 Solomon Neas -# ------------------------------------------------------------------------------ - -set -euo pipefail - -COMPONENT="Dashboards" -LOG_TAG="[S³:${COMPONENT}]" - -# Repo URLs -BROHUNTER_REPO="https://github.com/solomonneas/bro_hunter.git" -PLAYBOOKFORGE_REPO="https://github.com/solomonneas/playbook-forge.git" - -# Install paths -INSTALL_DIR="/opt/s3-dashboards" -BROHUNTER_DIR="${INSTALL_DIR}/bro-hunter" -PLAYBOOKFORGE_DIR="${INSTALL_DIR}/playbook-forge" - -# Ports -BROHUNTER_PORT=5174 -PLAYBOOKFORGE_PORT=5177 -NGINX_PORT=80 - -# ── Colors ──────────────────────────────────────────────────────────────────── -GN="\033[1;92m" RD="\033[01;31m" YW="\033[33m" CY="\033[36m" CL="\033[m" -CM="${GN}✓${CL}" CROSS="${RD}✗${CL}" INFO="${CY}ℹ${CL}" - -msg_info() { echo -e " ${INFO} ${LOG_TAG} ${1}..."; } -msg_ok() { echo -e " ${CM} ${LOG_TAG} ${1}"; } -msg_error(){ echo -e " ${CROSS} ${LOG_TAG} ${1}"; } - -# ── Idempotency Check ──────────────────────────────────────────────────────── -if [[ -d "$BROHUNTER_DIR" && -d "$PLAYBOOKFORGE_DIR" ]]; then - if systemctl is-active --quiet s3-bro-hunter 2>/dev/null && \ - systemctl is-active --quiet s3-playbook-forge 2>/dev/null; then - msg_ok "Dashboards already installed and running (skipping)" - exit 0 - fi -fi - -# ── System Prep ─────────────────────────────────────────────────────────────── -msg_info "Updating system packages" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq &>/dev/null -apt-get upgrade -y -qq &>/dev/null -msg_ok "System updated" - -msg_info "Installing prerequisites" -apt-get install -y -qq curl git nginx python3 python3-pip python3-venv &>/dev/null -msg_ok "Prerequisites installed" - -# ── Install Node.js 20 ─────────────────────────────────────────────────────── -if ! command -v node &>/dev/null || [[ "$(node -v | cut -d. -f1 | tr -d v)" -lt 20 ]]; then - msg_info "Installing Node.js 20" - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - &>/dev/null - apt-get install -y -qq nodejs &>/dev/null - msg_ok "Node.js $(node -v) installed" -else - msg_ok "Node.js $(node -v) already installed" -fi - -# ── Create install directory ────────────────────────────────────────────────── -mkdir -p "$INSTALL_DIR" - -# ── Install Bro Hunter ─────────────────────────────────────────────────────── -msg_info "Cloning Bro Hunter" -if [[ -d "$BROHUNTER_DIR" ]]; then - cd "$BROHUNTER_DIR" && git pull --quiet &>/dev/null -else - git clone --quiet "$BROHUNTER_REPO" "$BROHUNTER_DIR" &>/dev/null -fi -msg_ok "Bro Hunter cloned" - -msg_info "Building Bro Hunter" -cd "$BROHUNTER_DIR" -npm install --silent &>/dev/null -npm run build &>/dev/null -msg_ok "Bro Hunter built" - -# ── Install Playbook Forge ─────────────────────────────────────────────────── -msg_info "Cloning Playbook Forge" -if [[ -d "$PLAYBOOKFORGE_DIR" ]]; then - cd "$PLAYBOOKFORGE_DIR" && git pull --quiet &>/dev/null -else - git clone --quiet "$PLAYBOOKFORGE_REPO" "$PLAYBOOKFORGE_DIR" &>/dev/null -fi -msg_ok "Playbook Forge cloned" - -msg_info "Building Playbook Forge frontend" -cd "${PLAYBOOKFORGE_DIR}/web" -npm install --silent &>/dev/null -npm run build &>/dev/null -msg_ok "Playbook Forge frontend built" - -msg_info "Setting up Playbook Forge API" -cd "${PLAYBOOKFORGE_DIR}" -python3 -m venv "${PLAYBOOKFORGE_DIR}/venv" &>/dev/null -"${PLAYBOOKFORGE_DIR}/venv/bin/pip" install --quiet fastapi uvicorn &>/dev/null -msg_ok "Playbook Forge API configured" - -# ── Systemd Services ───────────────────────────────────────────────────────── -msg_info "Creating systemd services" - -# Bro Hunter: serve built frontend with vite preview -cat > /etc/systemd/system/s3-bro-hunter.service < /etc/systemd/system/s3-playbook-forge.service </dev/null -systemctl enable --now s3-playbook-forge &>/dev/null -msg_ok "Systemd services created and started" - -# ── Nginx Reverse Proxy ────────────────────────────────────────────────────── -msg_info "Configuring nginx reverse proxy" - -cat > /etc/nginx/sites-available/s3-dashboards < - -S³ Stack Dashboards - - - -

S³ Stack Dashboards

- - -'; - } - - # Bro Hunter (static build served by vite preview) - location /bro-hunter/ { - proxy_pass http://127.0.0.1:${BROHUNTER_PORT}/; - proxy_http_version 1.1; - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto \$scheme; - } - - # Playbook Forge (static frontend) - location /playbook-forge/ { - alias ${PLAYBOOKFORGE_DIR}/web/dist/; - try_files \$uri \$uri/ /playbook-forge/index.html; - } - - # Playbook Forge API - location /playbook-forge/api/ { - proxy_pass http://127.0.0.1:${PLAYBOOKFORGE_PORT}/api/; - proxy_http_version 1.1; - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto \$scheme; - } -} -EOF - -# Enable site, disable default -rm -f /etc/nginx/sites-enabled/default -ln -sf /etc/nginx/sites-available/s3-dashboards /etc/nginx/sites-enabled/s3-dashboards - -nginx -t &>/dev/null && systemctl restart nginx &>/dev/null -msg_ok "Nginx configured and restarted" - -# ── Zeek Log Mount Point ───────────────────────────────────────────────────── -# If Zeek logs are available via bind mount, Bro Hunter can read them -ZEEK_LOG_MOUNT="/opt/s3-dashboards/zeek-logs" -mkdir -p "$ZEEK_LOG_MOUNT" -msg_info "Zeek log mount point created at ${ZEEK_LOG_MOUNT}" -msg_ok "Configure bind mount from Zeek container for live log analysis" - -# ── Summary ─────────────────────────────────────────────────────────────────── -echo "" -msg_ok "S³ Stack Dashboards installed successfully" -echo "" -echo -e " ${CY}Bro Hunter:${CL} http:///bro-hunter/" -echo -e " ${CY}Playbook Forge:${CL} http:///playbook-forge/" -echo -e " ${CY}Landing Page:${CL} http:///" -echo "" -echo -e " ${CY}Services:${CL}" -echo -e " systemctl status s3-bro-hunter" -echo -e " systemctl status s3-playbook-forge" -echo "" -echo -e " ${CY}Update:${CL}" -echo -e " cd ${BROHUNTER_DIR} && git pull && npm run build && systemctl restart s3-bro-hunter" -echo -e " cd ${PLAYBOOKFORGE_DIR} && git pull && cd web && npm run build && systemctl restart s3-playbook-forge" -echo "" diff --git a/scripts/setup/components/misp.sh b/scripts/setup/components/misp.sh deleted file mode 100644 index 63bcae6..0000000 --- a/scripts/setup/components/misp.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack - MISP Installer -# Installs MISP using the official INSTALL.sh script -# -# MIT License - Copyright (c) 2024 Solomon Neas -# ------------------------------------------------------------------------------ - -set -euo pipefail - -COMPONENT="MISP" -LOG_TAG="[S³:${COMPONENT}]" - -GN="\033[1;92m" RD="\033[01;31m" CY="\033[36m" CL="\033[m" -CM="${GN}✓${CL}" CROSS="${RD}✗${CL}" INFO="${CY}ℹ${CL}" - -msg_info() { echo -e " ${INFO} ${LOG_TAG} ${1}..."; } -msg_ok() { echo -e " ${CM} ${LOG_TAG} ${1}"; } -msg_error(){ echo -e " ${CROSS} ${LOG_TAG} ${1}"; } - -# ── Idempotency ────────────────────────────────────────────────────────────── -if [[ -d "/var/www/MISP" ]] && systemctl is-active --quiet apache2 2>/dev/null; then - msg_ok "MISP already installed (skipping)" - exit 0 -fi - -# ── System Prep ─────────────────────────────────────────────────────────────── -msg_info "Updating system packages" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq &>/dev/null -apt-get upgrade -y -qq &>/dev/null -apt-get install -y -qq curl wget git &>/dev/null -msg_ok "System updated" - -# ── MISP Install ───────────────────────────────────────────────────────────── -msg_info "Downloading MISP installer (this will take a while)" -wget -qO /tmp/INSTALL.sh https://raw.githubusercontent.com/MISP/MISP/2.4/INSTALL/INSTALL.sh -chmod +x /tmp/INSTALL.sh -msg_ok "MISP installer downloaded" - -msg_info "Running MISP installation (this may take 15-30 minutes)" -bash /tmp/INSTALL.sh -A -u &>/dev/null || { - msg_error "MISP auto-install failed, trying core install" - bash /tmp/INSTALL.sh -c &>/dev/null || true -} -msg_ok "MISP installation complete" - -# ── Verify ──────────────────────────────────────────────────────────────────── -if [[ -d "/var/www/MISP" ]]; then - msg_ok "MISP directory exists" -else - msg_error "MISP directory not found" -fi - -if systemctl is-active --quiet apache2 2>/dev/null; then - msg_ok "Apache2 is running" -elif systemctl is-active --quiet nginx 2>/dev/null; then - msg_ok "Nginx is running" -fi - -echo "" -msg_ok "MISP installation complete" -echo -e " ${CY}URL:${CL} https://$(hostname -I | awk '{print $1}')" -echo -e " ${CY}Creds:${CL} admin@admin.test / admin" -echo "" - -rm -f /tmp/INSTALL.sh diff --git a/scripts/setup/components/suricata.sh b/scripts/setup/components/suricata.sh deleted file mode 100644 index 4f660cb..0000000 --- a/scripts/setup/components/suricata.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack - Suricata Installer -# Installs Suricata IDS/IPS from official PPA -# -# MIT License - Copyright (c) 2024 Solomon Neas -# ------------------------------------------------------------------------------ - -set -euo pipefail - -COMPONENT="Suricata" -LOG_TAG="[S³:${COMPONENT}]" - -GN="\033[1;92m" RD="\033[01;31m" CY="\033[36m" CL="\033[m" -CM="${GN}✓${CL}" CROSS="${RD}✗${CL}" INFO="${CY}ℹ${CL}" - -msg_info() { echo -e " ${INFO} ${LOG_TAG} ${1}..."; } -msg_ok() { echo -e " ${CM} ${LOG_TAG} ${1}"; } -msg_error(){ echo -e " ${CROSS} ${LOG_TAG} ${1}"; } - -# ── Idempotency ────────────────────────────────────────────────────────────── -if command -v suricata &>/dev/null; then - msg_ok "Suricata already installed ($(suricata --build-info | grep 'Suricata version' | head -1 || echo 'installed'))" - exit 0 -fi - -# ── System Prep ─────────────────────────────────────────────────────────────── -msg_info "Updating system packages" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq &>/dev/null -apt-get upgrade -y -qq &>/dev/null -apt-get install -y -qq curl wget gnupg software-properties-common &>/dev/null -msg_ok "System updated" - -# ── Suricata Installation ──────────────────────────────────────────────────── -msg_info "Adding Suricata PPA" -add-apt-repository -y ppa:oisf/suricata-stable &>/dev/null -apt-get update -qq &>/dev/null -msg_ok "PPA added" - -msg_info "Installing Suricata" -apt-get install -y -qq suricata suricata-update &>/dev/null -msg_ok "Suricata installed" - -# ── Configure ───────────────────────────────────────────────────────────────── -msg_info "Configuring Suricata" - -IFACE=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1) -IFACE="${IFACE:-eth0}" - -# Update interface in config -if [[ -f /etc/suricata/suricata.yaml ]]; then - sed -i "s/- interface: eth0/- interface: ${IFACE}/" /etc/suricata/suricata.yaml -fi - -msg_ok "Suricata configured on interface ${IFACE}" - -# ── Update Rules ────────────────────────────────────────────────────────────── -msg_info "Updating Suricata rules" -suricata-update &>/dev/null || true -msg_ok "Rules updated" - -# ── Enable and Start ───────────────────────────────────────────────────────── -msg_info "Starting Suricata" -systemctl enable --now suricata &>/dev/null || true -msg_ok "Suricata started" - -# ── Verify ──────────────────────────────────────────────────────────────────── -if systemctl is-active --quiet suricata 2>/dev/null; then - msg_ok "Suricata is running" -else - msg_error "Suricata failed to start (check journalctl -u suricata)" -fi - -echo "" -msg_ok "Suricata installation complete" -echo -e " ${CY}Logs:${CL} /var/log/suricata/" -echo -e " ${CY}Rules:${CL} /var/lib/suricata/rules/" -echo -e " ${CY}Config:${CL} /etc/suricata/suricata.yaml" -echo -e " ${CY}Interface:${CL} ${IFACE}" -echo "" diff --git a/scripts/setup/components/thehive.sh b/scripts/setup/components/thehive.sh deleted file mode 100644 index dc8b96e..0000000 --- a/scripts/setup/components/thehive.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack - TheHive 5.x Installer -# Installs TheHive 5.x with Elasticsearch 7.x and Cassandra -# -# MIT License - Copyright (c) 2024 Solomon Neas -# ------------------------------------------------------------------------------ - -set -euo pipefail - -COMPONENT="TheHive" -LOG_TAG="[S³:${COMPONENT}]" - -GN="\033[1;92m" RD="\033[01;31m" CY="\033[36m" CL="\033[m" -CM="${GN}✓${CL}" CROSS="${RD}✗${CL}" INFO="${CY}ℹ${CL}" - -msg_info() { echo -e " ${INFO} ${LOG_TAG} ${1}..."; } -msg_ok() { echo -e " ${CM} ${LOG_TAG} ${1}"; } -msg_error(){ echo -e " ${CROSS} ${LOG_TAG} ${1}"; } - -# ── Idempotency ────────────────────────────────────────────────────────────── -if systemctl is-active --quiet thehive 2>/dev/null; then - msg_ok "TheHive already running (skipping install)" - exit 0 -fi - -# ── System Prep ─────────────────────────────────────────────────────────────── -msg_info "Updating system packages" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq &>/dev/null -apt-get upgrade -y -qq &>/dev/null -apt-get install -y -qq curl wget gnupg apt-transport-https ca-certificates \ - software-properties-common &>/dev/null -msg_ok "System updated" - -# ── Java 11 ─────────────────────────────────────────────────────────────────── -msg_info "Installing Java 11" -apt-get install -y -qq openjdk-11-jre-headless &>/dev/null -msg_ok "Java 11 installed" - -# ── Cassandra ───────────────────────────────────────────────────────────────── -msg_info "Installing Apache Cassandra" -curl -fsSL https://downloads.apache.org/cassandra/KEYS | gpg --dearmor -o /usr/share/keyrings/cassandra.gpg 2>/dev/null -echo "deb [signed-by=/usr/share/keyrings/cassandra.gpg] https://debian.cassandra.apache.org 40x main" \ - > /etc/apt/sources.list.d/cassandra.sources.list -apt-get update -qq &>/dev/null -apt-get install -y -qq cassandra &>/dev/null -systemctl enable --now cassandra &>/dev/null -msg_ok "Cassandra installed and running" - -# ── Elasticsearch 7.x ──────────────────────────────────────────────────────── -msg_info "Installing Elasticsearch 7.x" -wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | \ - gpg --dearmor -o /usr/share/keyrings/elasticsearch.gpg 2>/dev/null -echo "deb [signed-by=/usr/share/keyrings/elasticsearch.gpg] https://artifacts.elastic.co/packages/7.x/apt stable main" \ - > /etc/apt/sources.list.d/elasticsearch.list -apt-get update -qq &>/dev/null -apt-get install -y -qq elasticsearch &>/dev/null - -# Configure for TheHive -cat >> /etc/elasticsearch/elasticsearch.yml <<'EOF' -cluster.name: thehive -node.name: node-1 -network.host: 127.0.0.1 -http.port: 9200 -discovery.type: single-node -xpack.security.enabled: false -EOF - -systemctl enable --now elasticsearch &>/dev/null -msg_ok "Elasticsearch 7.x installed" - -# ── TheHive 5.x ────────────────────────────────────────────────────────────── -msg_info "Installing TheHive 5.x" -curl -fsSL https://archives.strangebee.com/keys/strangebee.gpg | \ - gpg --dearmor -o /usr/share/keyrings/strangebee.gpg 2>/dev/null -echo "deb [signed-by=/usr/share/keyrings/strangebee.gpg] https://deb.strangebee.com thehive-5.x main" \ - > /etc/apt/sources.list.d/strangebee.list -apt-get update -qq &>/dev/null -apt-get install -y -qq thehive &>/dev/null - -# Set permissions -chown -R thehive:thehive /opt/thp/thehive -mkdir -p /opt/thp/thehive/index -chown -R thehive:thehive /opt/thp/thehive/index - -systemctl enable --now thehive &>/dev/null -msg_ok "TheHive 5.x installed and running" - -# ── Verify ──────────────────────────────────────────────────────────────────── -sleep 5 -if systemctl is-active --quiet thehive; then - msg_ok "TheHive is running" -else - msg_error "TheHive failed to start (check journalctl -u thehive)" -fi - -echo "" -msg_ok "TheHive installation complete" -echo -e " ${CY}URL:${CL} http://$(hostname -I | awk '{print $1}'):9000" -echo -e " ${CY}Creds:${CL} admin@thehive.local / secret" -echo "" diff --git a/scripts/setup/components/wazuh.sh b/scripts/setup/components/wazuh.sh deleted file mode 100644 index 6bbfd65..0000000 --- a/scripts/setup/components/wazuh.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack - Wazuh 4.x Installer -# Installs Wazuh Manager, Indexer, and Dashboard (single-node) -# -# MIT License - Copyright (c) 2024 Solomon Neas -# ------------------------------------------------------------------------------ - -set -euo pipefail - -COMPONENT="Wazuh" -LOG_TAG="[S³:${COMPONENT}]" - -# ── Colors ──────────────────────────────────────────────────────────────────── -GN="\033[1;92m" RD="\033[01;31m" YW="\033[33m" CY="\033[36m" CL="\033[m" -CM="${GN}✓${CL}" CROSS="${RD}✗${CL}" INFO="${CY}ℹ${CL}" - -msg_info() { echo -e " ${INFO} ${LOG_TAG} ${1}..."; } -msg_ok() { echo -e " ${CM} ${LOG_TAG} ${1}"; } -msg_error(){ echo -e " ${CROSS} ${LOG_TAG} ${1}"; } - -# ── Idempotency Check ──────────────────────────────────────────────────────── -if systemctl is-active --quiet wazuh-manager 2>/dev/null; then - msg_ok "Wazuh Manager already running (skipping install)" - exit 0 -fi - -# ── System Prep ─────────────────────────────────────────────────────────────── -msg_info "Updating system packages" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq &>/dev/null -apt-get upgrade -y -qq &>/dev/null -msg_ok "System updated" - -msg_info "Installing prerequisites" -apt-get install -y -qq curl apt-transport-https unzip wget libcap2-bin \ - software-properties-common gnupg lsb-release &>/dev/null -msg_ok "Prerequisites installed" - -# ── Wazuh Installation (all-in-one) ────────────────────────────────────────── -msg_info "Downloading Wazuh installation assistant" -curl -sO https://packages.wazuh.com/4.9/wazuh-install.sh -curl -sO https://packages.wazuh.com/4.9/config.yml - -# Generate config for single-node -cat > config.yml <<'EOF' -nodes: - indexer: - - name: node-1 - ip: "127.0.0.1" - server: - - name: wazuh-1 - ip: "127.0.0.1" - dashboard: - - name: dashboard - ip: "127.0.0.1" -EOF - -msg_ok "Downloaded Wazuh installer" - -msg_info "Running Wazuh installation (this may take several minutes)" -bash wazuh-install.sh --generate-config-files &>/dev/null || true -bash wazuh-install.sh --wazuh-indexer node-1 &>/dev/null -bash wazuh-install.sh --start-cluster &>/dev/null -bash wazuh-install.sh --wazuh-server wazuh-1 &>/dev/null -bash wazuh-install.sh --wazuh-dashboard dashboard &>/dev/null -msg_ok "Wazuh installed successfully" - -# ── Verify Services ────────────────────────────────────────────────────────── -msg_info "Verifying services" -for svc in wazuh-manager wazuh-indexer wazuh-dashboard; do - if systemctl is-active --quiet "$svc" 2>/dev/null; then - msg_ok "${svc} is running" - else - msg_error "${svc} failed to start" - fi -done - -# ── Output ──────────────────────────────────────────────────────────────────── -echo "" -msg_ok "Wazuh installation complete" -echo -e " ${CY}Dashboard:${CL} https://$(hostname -I | awk '{print $1}'):443" -echo -e " ${CY}API:${CL} https://$(hostname -I | awk '{print $1}'):55000" -echo -e " ${CY}Creds:${CL} admin / admin (change immediately)" -echo "" - -# Cleanup -rm -f wazuh-install.sh config.yml diff --git a/scripts/setup/components/zeek.sh b/scripts/setup/components/zeek.sh deleted file mode 100644 index 358e51c..0000000 --- a/scripts/setup/components/zeek.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack - Zeek Installer -# Installs Zeek network monitor from official packages -# -# MIT License - Copyright (c) 2024 Solomon Neas -# ------------------------------------------------------------------------------ - -set -euo pipefail - -COMPONENT="Zeek" -LOG_TAG="[S³:${COMPONENT}]" - -GN="\033[1;92m" RD="\033[01;31m" CY="\033[36m" CL="\033[m" -CM="${GN}✓${CL}" CROSS="${RD}✗${CL}" INFO="${CY}ℹ${CL}" - -msg_info() { echo -e " ${INFO} ${LOG_TAG} ${1}..."; } -msg_ok() { echo -e " ${CM} ${LOG_TAG} ${1}"; } -msg_error(){ echo -e " ${CROSS} ${LOG_TAG} ${1}"; } - -# ── Idempotency ────────────────────────────────────────────────────────────── -if command -v zeek &>/dev/null; then - msg_ok "Zeek already installed ($(zeek --version 2>/dev/null || echo 'unknown'))" - exit 0 -fi - -# ── System Prep ─────────────────────────────────────────────────────────────── -msg_info "Updating system packages" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq &>/dev/null -apt-get upgrade -y -qq &>/dev/null -apt-get install -y -qq curl wget gnupg software-properties-common &>/dev/null -msg_ok "System updated" - -# ── Zeek Installation ──────────────────────────────────────────────────────── -msg_info "Adding Zeek repository" -echo "deb http://download.opensuse.org/repositories/security:/zeek/xUbuntu_22.04/ /" \ - > /etc/apt/sources.list.d/zeek.list -curl -fsSL https://download.opensuse.org/repositories/security:zeek/xUbuntu_22.04/Release.key | \ - gpg --dearmor -o /usr/share/keyrings/zeek.gpg 2>/dev/null -# Fix: add signed-by -echo "deb [signed-by=/usr/share/keyrings/zeek.gpg] http://download.opensuse.org/repositories/security:/zeek/xUbuntu_22.04/ /" \ - > /etc/apt/sources.list.d/zeek.list - -apt-get update -qq &>/dev/null -msg_ok "Zeek repository added" - -msg_info "Installing Zeek" -apt-get install -y -qq zeek &>/dev/null -msg_ok "Zeek installed" - -# ── Configure ───────────────────────────────────────────────────────────────── -msg_info "Configuring Zeek" - -# Add to PATH -echo 'export PATH="/opt/zeek/bin:$PATH"' >> /etc/profile.d/zeek.sh -export PATH="/opt/zeek/bin:$PATH" - -# Detect primary interface -IFACE=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1) -IFACE="${IFACE:-eth0}" - -# Configure node.cfg -if [[ -f /opt/zeek/etc/node.cfg ]]; then - cat > /opt/zeek/etc/node.cfg </dev/null; then - zeekctl deploy &>/dev/null || true -fi - -msg_ok "Zeek configured on interface ${IFACE}" - -# ── Verify ──────────────────────────────────────────────────────────────────── -if command -v zeek &>/dev/null; then - msg_ok "Zeek $(zeek --version 2>/dev/null | head -1) installed" -else - msg_error "Zeek binary not found in PATH" -fi - -echo "" -msg_ok "Zeek installation complete" -echo -e " ${CY}Logs:${CL} /opt/zeek/logs/current/" -echo -e " ${CY}Config:${CL} /opt/zeek/etc/" -echo -e " ${CY}Interface:${CL} ${IFACE}" -echo "" diff --git a/scripts/setup/install.sh b/scripts/setup/install.sh deleted file mode 100644 index b7dcfa6..0000000 --- a/scripts/setup/install.sh +++ /dev/null @@ -1,807 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack Installer -# Proxmox VE Community Install Script -# -# MIT License -# Copyright (c) 2024 Solomon Neas -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# ------------------------------------------------------------------------------ - -set -euo pipefail - -# ── Globals ─────────────────────────────────────────────────────────────────── -SCRIPT_VERSION="1.0.0" -REPO_URL="https://raw.githubusercontent.com/solomonneas/soc-stack/main" -LOG_FILE="/var/log/s3-stack-install.log" -TEMP_DIR="" -SELECTED_COMPONENTS=() -COMPONENT_TYPES=() -BRIDGE="vmbr0" -VLAN_TAG="" -IP_MODE="dhcp" -IP_RANGE="" -STORAGE="local-lvm" -PRESET="standard" -INSTALL_MCP="no" -INSTALL_DASHBOARDS="no" -DASHBOARD_DEPLOY="lxc" - -# ── Component Definitions ───────────────────────────────────────────────────── -declare -A COMP_NAMES=( - [wazuh]="Wazuh" - [thehive]="TheHive" - [cortex]="Cortex" - [misp]="MISP" - [zeek]="Zeek" - [suricata]="Suricata" -) -declare -A COMP_DESC=( - [wazuh]="SIEM/XDR Platform" - [thehive]="Case Management" - [cortex]="SOAR/Analyzers" - [misp]="Threat Intel Platform" - [zeek]="Network Monitor" - [suricata]="IDS/IPS Engine" -) -declare -A COMP_PORTS=( - [wazuh]="443,1514,1515,55000" - [thehive]="9000" - [cortex]="9001" - [misp]="443" - [zeek]="47760" - [suricata]="N/A" -) - -# Resource presets: RAM(MB) DISK(GB) CORES -declare -A PRESET_MINIMAL=( - [wazuh]="2048 30 1" - [thehive]="2048 20 1" - [cortex]="1024 15 1" - [misp]="2048 20 1" - [zeek]="1024 15 1" - [suricata]="1024 15 1" -) -declare -A PRESET_STANDARD=( - [wazuh]="4096 50 2" - [thehive]="4096 30 2" - [cortex]="2048 20 2" - [misp]="4096 30 2" - [zeek]="2048 20 2" - [suricata]="2048 20 2" -) -declare -A PRESET_PRODUCTION=( - [wazuh]="8192 100 4" - [thehive]="8192 60 4" - [cortex]="4096 40 2" - [misp]="8192 60 4" - [zeek]="4096 40 4" - [suricata]="4096 40 4" -) - -# ── Colors ──────────────────────────────────────────────────────────────────── -RD="\033[01;31m" -GN="\033[1;92m" -YW="\033[33m" -CY="\033[36m" -CL="\033[m" -BFR="\\r\\033[K" -CM="${GN}✓${CL}" -CROSS="${RD}✗${CL}" -INFO="${CY}ℹ${CL}" -WARN="${YW}⚠${CL}" - -# ── Logging ─────────────────────────────────────────────────────────────────── -touch "$LOG_FILE" 2>/dev/null || LOG_FILE="/tmp/s3-stack-install.log" - -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" -} - -msg_info() { - echo -ne " ${INFO} ${1}..." - log "INFO: $1" -} - -msg_ok() { - echo -e "${BFR} ${CM} ${1}" - log "OK: $1" -} - -msg_error() { - echo -e "${BFR} ${CROSS} ${1}" - log "ERROR: $1" -} - -msg_warn() { - echo -e "${BFR} ${WARN} ${1}" - log "WARN: $1" -} - -# ── Cleanup ─────────────────────────────────────────────────────────────────── -cleanup() { - [[ -n "${TEMP_DIR:-}" && -d "${TEMP_DIR:-}" ]] && rm -rf "$TEMP_DIR" -} -trap cleanup EXIT - -# ── Pre-flight Checks ──────────────────────────────────────────────────────── -check_root() { - if [[ "$(id -u)" -ne 0 ]]; then - msg_error "This script must be run as root" - echo -e " Run: ${CY}sudo bash install.sh${CL}" - exit 1 - fi -} - -check_proxmox() { - if ! command -v pveversion &>/dev/null; then - msg_error "Proxmox VE not detected" - echo -e " This script must run on a Proxmox VE host (not inside a VM or container)." - exit 1 - fi - - local pve_ver - pve_ver="$(pveversion --verbose | head -1 | grep -oP '\d+\.\d+')" - local pve_major="${pve_ver%%.*}" - - if [[ "$pve_major" -lt 7 ]]; then - msg_error "Proxmox VE ${pve_ver} is not supported (requires 7.x or 8.x)" - exit 1 - fi - - msg_ok "Proxmox VE ${pve_ver} detected" -} - -check_dependencies() { - local deps=(whiptail wget curl jq openssl) - for dep in "${deps[@]}"; do - if ! command -v "$dep" &>/dev/null; then - msg_info "Installing ${dep}" - apt-get update -qq &>/dev/null - apt-get install -y -qq "$dep" &>/dev/null - msg_ok "Installed ${dep}" - fi - done -} - -# ── VMID Helper ─────────────────────────────────────────────────────────────── -get_next_vmid() { - pvesh get /cluster/nextid 2>/dev/null | tr -d '"' -} - -# ── Template Download ───────────────────────────────────────────────────────── -ensure_template() { - local storage="$1" - local template="ubuntu-22.04-standard_22.04-1_amd64.tar.zst" - - if ! pveam list "$storage" 2>/dev/null | grep -q "ubuntu-22.04"; then - msg_info "Downloading Ubuntu 22.04 LXC template" - pveam update &>/dev/null - pveam download "$storage" "$template" &>/dev/null - msg_ok "Downloaded Ubuntu 22.04 template" - else - msg_ok "Ubuntu 22.04 template already available" - fi -} - -# ── Whiptail Screens ───────────────────────────────────────────────────────── -show_splash() { - whiptail --title "S³ Stack Installer v${SCRIPT_VERSION}" --msgbox "\ - ____ _ _ ____ _____ - / ___| ___ | | ___ _ __ ___ ___ _ __ ( )___ / ___|___ / - \\___ \\ / _ \\| |/ _ \\| '_ \` _ \\ / _ \\| '_ \\|// __| | \\___ \\ |_ \\ - ___) | (_) | | (_) | | | | | | (_) | | | | \\__ \\ ___) |__) | - |____/ \\___/|_|\\___/|_| |_| |_|\\___/|_| |_| |___/ |____/____/ - - S³ Stack -- SOC in a Box - - Deploy a full Security Operations Center on Proxmox VE. - - Components: Wazuh, TheHive, Cortex, MISP, Zeek, Suricata - - Version: ${SCRIPT_VERSION} - License: MIT - GitHub: github.com/solomonneas/soc-stack -" 22 74 -} - -select_components() { - local choices - choices=$(whiptail --title "S³ Stack: Select Components" \ - --checklist "Choose which SOC components to deploy:\n(Space to select, Enter to confirm)" 20 74 6 \ - "wazuh" "Wazuh 4.x SIEM/XDR Platform" ON \ - "thehive" "TheHive 5.x Case Management" ON \ - "cortex" "Cortex 3.x SOAR/Analyzers" ON \ - "misp" "MISP Threat Intel Platform" ON \ - "zeek" "Zeek Network Monitor" OFF \ - "suricata" "Suricata IDS/IPS Engine" OFF \ - 3>&1 1>&2 2>&3) || { msg_error "Component selection cancelled"; exit 1; } - - if [[ -z "$choices" ]]; then - msg_error "No components selected" - exit 1 - fi - - SELECTED_COMPONENTS=() - for comp in $choices; do - comp="${comp//\"/}" - SELECTED_COMPONENTS+=("$comp") - done -} - -select_deploy_type() { - COMPONENT_TYPES=() - for comp in "${SELECTED_COMPONENTS[@]}"; do - local name="${COMP_NAMES[$comp]}" - local dtype - dtype=$(whiptail --title "S³ Stack: ${name} Deploy Type" \ - --radiolist "How should ${name} be deployed?" 12 64 2 \ - "lxc" "LXC Container (recommended, less resources)" ON \ - "vm" "Virtual Machine (full isolation)" OFF \ - 3>&1 1>&2 2>&3) || { msg_error "Cancelled"; exit 1; } - COMPONENT_TYPES+=("${dtype//\"/}") - done -} - -select_preset() { - PRESET=$(whiptail --title "S³ Stack: Resource Preset" \ - --radiolist "Select a resource allocation preset:" 14 68 3 \ - "minimal" "Lab/Testing (low resources, single core)" OFF \ - "standard" "Small SOC (balanced, recommended)" ON \ - "production" "Production (high resources, multi-core)" OFF \ - 3>&1 1>&2 2>&3) || { msg_error "Cancelled"; exit 1; } - PRESET="${PRESET//\"/}" -} - -select_network() { - BRIDGE=$(whiptail --title "S³ Stack: Network Bridge" \ - --inputbox "Enter the Proxmox bridge interface:" 10 60 "vmbr0" \ - 3>&1 1>&2 2>&3) || { msg_error "Cancelled"; exit 1; } - - VLAN_TAG=$(whiptail --title "S³ Stack: VLAN Tag" \ - --inputbox "Enter VLAN tag (leave empty for none):" 10 60 "" \ - 3>&1 1>&2 2>&3) || { msg_error "Cancelled"; exit 1; } - - IP_MODE=$(whiptail --title "S³ Stack: IP Configuration" \ - --radiolist "How should IPs be assigned?" 12 60 2 \ - "dhcp" "DHCP (automatic)" ON \ - "static" "Static IP range" OFF \ - 3>&1 1>&2 2>&3) || { msg_error "Cancelled"; exit 1; } - IP_MODE="${IP_MODE//\"/}" - - if [[ "$IP_MODE" == "static" ]]; then - IP_RANGE=$(whiptail --title "S³ Stack: Static IP Range" \ - --inputbox "Enter starting IP (CIDR notation, e.g. 198.51.100.10/24):" 10 64 "198.51.100.10/24" \ - 3>&1 1>&2 2>&3) || { msg_error "Cancelled"; exit 1; } - fi -} - -select_storage() { - local storages - storages=$(pvesm status 2>/dev/null | awk 'NR>1 {print $1}' | head -10) - - local items=() - local first="ON" - while IFS= read -r s; do - [[ -z "$s" ]] && continue - items+=("$s" "" "$first") - first="OFF" - done <<< "$storages" - - if [[ ${#items[@]} -eq 0 ]]; then - items=("local-lvm" "" "ON" "local" "" "OFF") - fi - - STORAGE=$(whiptail --title "S³ Stack: Storage Selection" \ - --radiolist "Select storage for containers/VMs:" 16 60 6 \ - "${items[@]}" \ - 3>&1 1>&2 2>&3) || { msg_error "Cancelled"; exit 1; } - STORAGE="${STORAGE//\"/}" -} - -ask_mcp() { - if whiptail --title "S³ Stack: MCP Servers" \ - --yesno "Install MCP (Model Context Protocol) servers for AI integration?\n\nThis adds MCP connectors for Wazuh, TheHive, MISP, Cortex, Suricata, and Zeek." 12 72; then - INSTALL_MCP="yes" - else - INSTALL_MCP="no" - fi -} - -ask_dashboards() { - if whiptail --title "S³ Stack: Custom Dashboards" \ - --yesno "Install custom analysis dashboards?\n\nThis deploys two tools built for the S³ Stack:\n\n - Bro Hunter: Zeek log analysis and threat hunting\n - Playbook Forge: IR playbook builder with visual flowcharts\n\nBoth are served from a single LXC container with nginx\nreverse proxy (recommended). Minimal resource overhead." 16 74; then - INSTALL_DASHBOARDS="yes" - - DASHBOARD_DEPLOY=$(whiptail --title "S³ Stack: Dashboard Deploy Type" \ - --radiolist "How should the dashboards be deployed?" 14 74 2 \ - "lxc" "Shared LXC Container (recommended, ~1GB RAM)" ON \ - "vm" "Virtual Machine (full isolation, more resources)" OFF \ - 3>&1 1>&2 2>&3) || { msg_error "Cancelled"; exit 1; } - DASHBOARD_DEPLOY="${DASHBOARD_DEPLOY//\"/}" - else - INSTALL_DASHBOARDS="no" - fi -} - -show_confirmation() { - local summary="S³ Stack Installation Summary\n\n" - summary+="Preset: ${PRESET}\n" - summary+="Storage: ${STORAGE}\n" - summary+="Bridge: ${BRIDGE}" - [[ -n "$VLAN_TAG" ]] && summary+=" (VLAN ${VLAN_TAG})" - summary+="\nIP Mode: ${IP_MODE}" - [[ "$IP_MODE" == "static" ]] && summary+=" (${IP_RANGE})" - summary+="\nMCP: ${INSTALL_MCP}" - summary+="\nDashboards: ${INSTALL_DASHBOARDS}" - [[ "$INSTALL_DASHBOARDS" == "yes" ]] && summary+=" (${DASHBOARD_DEPLOY})" - summary+="\n\n" - summary+="Components:\n" - - for i in "${!SELECTED_COMPONENTS[@]}"; do - local comp="${SELECTED_COMPONENTS[$i]}" - local dtype="${COMPONENT_TYPES[$i]}" - local name="${COMP_NAMES[$comp]}" - - local res - case "$PRESET" in - minimal) res="${PRESET_MINIMAL[$comp]}" ;; - production) res="${PRESET_PRODUCTION[$comp]}" ;; - *) res="${PRESET_STANDARD[$comp]}" ;; - esac - - local ram disk cores - read -r ram disk cores <<< "$res" - summary+=" ${name} (${dtype^^}) - $((ram/1024))GB RAM, ${disk}GB disk, ${cores} cores\n" - done - - if ! whiptail --title "S³ Stack: Confirm Installation" \ - --yesno "${summary}\nProceed with installation?" 26 74; then - msg_error "Installation cancelled by user" - exit 0 - fi -} - -# ── Resource Getter ─────────────────────────────────────────────────────────── -get_resources() { - local comp="$1" - case "$PRESET" in - minimal) echo "${PRESET_MINIMAL[$comp]}" ;; - production) echo "${PRESET_PRODUCTION[$comp]}" ;; - *) echo "${PRESET_STANDARD[$comp]}" ;; - esac -} - -# ── LXC Creation ────────────────────────────────────────────────────────────── -create_lxc() { - local comp="$1" - local vmid="$2" - - local ram disk cores - read -r ram disk cores <<< "$(get_resources "$comp")" - - local hostname="s3-${comp}" - - # Find template - local template - template=$(pveam list local 2>/dev/null | grep "ubuntu-22.04" | awk '{print $1}' | head -1) - if [[ -z "$template" ]]; then - template=$(pveam list "$STORAGE" 2>/dev/null | grep "ubuntu-22.04" | awk '{print $1}' | head -1) - fi - if [[ -z "$template" ]]; then - msg_error "No Ubuntu 22.04 template found" - return 1 - fi - - msg_info "Creating LXC ${hostname} (ID: ${vmid})" - - local net_config="name=eth0,bridge=${BRIDGE}" - [[ -n "$VLAN_TAG" ]] && net_config+=",tag=${VLAN_TAG}" - - if [[ "$IP_MODE" == "dhcp" ]]; then - net_config+=",ip=dhcp" - else - local base_ip="${IP_RANGE%/*}" - local cidr="${IP_RANGE#*/}" - local base_last="${base_ip##*.}" - local base_prefix="${base_ip%.*}" - local idx=0 - for c in "${SELECTED_COMPONENTS[@]}"; do - [[ "$c" == "$comp" ]] && break - ((idx++)) - done - local ip="${base_prefix}.$((base_last + idx))/${cidr}" - local gw="${base_prefix}.1" - net_config+=",ip=${ip},gw=${gw}" - fi - - local password - password="$(openssl rand -base64 16)" - - pct create "$vmid" "$template" \ - --hostname "$hostname" \ - --memory "$ram" \ - --cores "$cores" \ - --rootfs "${STORAGE}:${disk}" \ - --net0 "$net_config" \ - --password "$password" \ - --unprivileged 1 \ - --features nesting=1 \ - --onboot 1 \ - --start 0 \ - &>> "$LOG_FILE" - - msg_ok "Created LXC ${hostname} (ID: ${vmid})" - echo "$password" -} - -# ── VM Creation ─────────────────────────────────────────────────────────────── -create_vm() { - local comp="$1" - local vmid="$2" - local hostname="s3-${comp}" - - local ram disk cores - read -r ram disk cores <<< "$(get_resources "$comp")" - - msg_info "Creating VM ${hostname} (ID: ${vmid})" - - local net_config="virtio,bridge=${BRIDGE}" - [[ -n "$VLAN_TAG" ]] && net_config+=",tag=${VLAN_TAG}" - - qm create "$vmid" \ - --name "$hostname" \ - --memory "$ram" \ - --cores "$cores" \ - --net0 "$net_config" \ - --scsihw virtio-scsi-single \ - --scsi0 "${STORAGE}:${disk}" \ - --boot order=scsi0 \ - --onboot 1 \ - --start 0 \ - &>> "$LOG_FILE" 2>&1 || true - - msg_ok "Created VM ${hostname} (ID: ${vmid})" -} - -# ── Component Installation ──────────────────────────────────────────────────── -install_component() { - local comp="$1" - local vmid="$2" - local dtype="$3" - local name="${COMP_NAMES[$comp]}" - - msg_info "Installing ${name} inside container ${vmid}" - - local script_url="${REPO_URL}/scripts/setup/components/${comp}.sh" - local script_path="${TEMP_DIR}/${comp}.sh" - - wget -qO "$script_path" "$script_url" 2>/dev/null || true - - if [[ "$dtype" == "lxc" ]]; then - pct start "$vmid" &>/dev/null || true - sleep 5 - - # Wait for network - local retries=0 - while ! pct exec "$vmid" -- ping -c1 -W2 8.8.8.8 &>/dev/null; do - ((retries++)) - [[ $retries -ge 30 ]] && { msg_warn "Network timeout for ${vmid}"; break; } - sleep 2 - done - - if [[ -f "$script_path" && -s "$script_path" ]]; then - pct push "$vmid" "$script_path" "/tmp/${comp}.sh" &>/dev/null - pct exec "$vmid" -- bash "/tmp/${comp}.sh" &>> "$LOG_FILE" || true - fi - fi - - msg_ok "Installed ${name} (ID: ${vmid})" -} - -# ── Firewall Rules ──────────────────────────────────────────────────────────── -setup_firewall() { - msg_info "Configuring firewall rules between S³ Stack components" - - mkdir -p /etc/pve/firewall - - local fw_conf="/etc/pve/firewall/cluster.fw" - if [[ ! -f "$fw_conf" ]]; then - cat > "$fw_conf" <<'EOF' -[OPTIONS] -enable: 1 -policy_in: DROP -policy_out: ACCEPT - -[RULES] -# S³ Stack: allow inter-component traffic -EOF - fi - - for vmid_file in "${TEMP_DIR}"/vmid_*; do - [[ -f "$vmid_file" ]] || continue - local vmid - vmid="$(cat "$vmid_file")" - cat > "/etc/pve/firewall/${vmid}.fw" </dev/null | awk '{print $1}') || ip="DHCP" - fi - - echo -e " ${CY}${name}${CL} (${dtype^^} #${vmid})" - echo -e " IP: ${ip}" - echo -e " Ports: ${ports}" - echo -e " Pass: ${password}" - echo "" - done - - echo -e "${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" - echo -e " ${CY}Default Credentials:${CL}" - echo -e " Wazuh: admin / admin (change on first login)" - echo -e " TheHive: admin@thehive.local / secret" - echo -e " Cortex: (set during first-run wizard)" - echo -e " MISP: admin@admin.test / admin" - echo -e "${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" - echo "" - if [[ "$INSTALL_DASHBOARDS" == "yes" ]]; then - local dash_vmid="N/A" - [[ -f "${TEMP_DIR}/vmid_dashboards" ]] && dash_vmid="$(cat "${TEMP_DIR}/vmid_dashboards")" - local dash_pass="N/A" - [[ -f "${TEMP_DIR}/pass_dashboards" ]] && dash_pass="$(cat "${TEMP_DIR}/pass_dashboards")" - local dash_ip="DHCP" - if [[ "$dash_vmid" != "N/A" ]]; then - dash_ip=$(pct exec "$dash_vmid" -- hostname -I 2>/dev/null | awk '{print $1}') || dash_ip="DHCP" - fi - - echo -e "${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" - echo -e " ${CY}Custom Dashboards${CL} (${DASHBOARD_DEPLOY^^} #${dash_vmid})" - echo -e " IP: ${dash_ip}" - echo -e " Bro Hunter: http://${dash_ip}/bro-hunter/" - echo -e " Playbook Forge: http://${dash_ip}/playbook-forge/" - echo -e " Pass: ${dash_pass}" - echo "" - fi - - echo -e " Log file: ${CY}${LOG_FILE}${CL}" - echo -e " Integration: ${CY}bash /root/s3-integrate.sh${CL}" - echo "" - - { - echo "S³ Stack Installation Summary" - echo "Generated: $(date)" - echo "" - for i in "${!SELECTED_COMPONENTS[@]}"; do - local comp="${SELECTED_COMPONENTS[$i]}" - local vmid="N/A" - [[ -f "${TEMP_DIR}/vmid_${comp}" ]] && vmid="$(cat "${TEMP_DIR}/vmid_${comp}")" - echo "${COMP_NAMES[$comp]}: VMID=${vmid}" - done - if [[ "$INSTALL_DASHBOARDS" == "yes" ]]; then - local d_vmid="N/A" - [[ -f "${TEMP_DIR}/vmid_dashboards" ]] && d_vmid="$(cat "${TEMP_DIR}/vmid_dashboards")" - echo "Dashboards (Bro Hunter + Playbook Forge): VMID=${d_vmid}" - fi - } > /root/s3-stack-summary.txt - - msg_ok "Summary saved to /root/s3-stack-summary.txt" -} - -# ── Main ────────────────────────────────────────────────────────────────────── -main() { - clear - - echo -e "${CY}" - cat << 'BANNER' - ____ _ _ ____ _____ - / ___| ___ | | ___ _ __ ___ ___ _ __ ( )___ / ___|___ / - \___ \ / _ \| |/ _ \| '_ ` _ \ / _ \| '_ \|// __| | \___ \ |_ \ - ___) | (_) | | (_) | | | | | | (_) | | | | \__ \ ___) |__) | - |____/ \___/|_|\___/|_| |_| |_|\___/|_| |_| |___/ |____/____/ - -BANNER - echo -e "${CL}" - echo -e " ${GN}S³ Stack Installer v${SCRIPT_VERSION}${CL}" - echo -e " ${CY}SOC in a Box for Proxmox VE${CL}" - echo "" - - log "=== S³ Stack Installer v${SCRIPT_VERSION} started ===" - - check_root - check_proxmox - check_dependencies - - TEMP_DIR="$(mktemp -d)" - log "Temp directory: $TEMP_DIR" - - show_splash - select_components - select_deploy_type - select_preset - select_network - select_storage - ask_mcp - ask_dashboards - show_confirmation - - echo "" - msg_info "Starting S³ Stack deployment" - echo "" - - ensure_template "local" - - for i in "${!SELECTED_COMPONENTS[@]}"; do - local comp="${SELECTED_COMPONENTS[$i]}" - local dtype="${COMPONENT_TYPES[$i]}" - local vmid - vmid="$(get_next_vmid)" - - echo "$vmid" > "${TEMP_DIR}/vmid_${comp}" - - if [[ "$dtype" == "lxc" ]]; then - local password - password="$(create_lxc "$comp" "$vmid")" - echo "$password" > "${TEMP_DIR}/pass_${comp}" - install_component "$comp" "$vmid" "$dtype" - else - create_vm "$comp" "$vmid" - msg_warn "${COMP_NAMES[$comp]} VM created but requires manual OS install" - fi - done - - setup_firewall - - if [[ ${#SELECTED_COMPONENTS[@]} -gt 1 ]]; then - msg_info "Downloading integration script" - wget -qO "${TEMP_DIR}/integrate.sh" "${REPO_URL}/scripts/setup/integrate.sh" 2>/dev/null || true - if [[ -f "${TEMP_DIR}/integrate.sh" && -s "${TEMP_DIR}/integrate.sh" ]]; then - cp "${TEMP_DIR}/integrate.sh" /root/s3-integrate.sh - chmod +x /root/s3-integrate.sh - msg_ok "Integration script saved to /root/s3-integrate.sh" - fi - fi - - if [[ "$INSTALL_MCP" == "yes" ]]; then - msg_info "MCP server installation" - msg_warn "MCP servers require Node.js 18+. Install separately from the soc-stack repo." - fi - - # ── Deploy Custom Dashboards (Bro Hunter + Playbook Forge) ──────────────── - if [[ "$INSTALL_DASHBOARDS" == "yes" ]]; then - msg_info "Deploying S³ Stack Dashboards (Bro Hunter + Playbook Forge)" - - local dash_vmid - dash_vmid="$(get_next_vmid)" - echo "$dash_vmid" > "${TEMP_DIR}/vmid_dashboards" - - if [[ "$DASHBOARD_DEPLOY" == "lxc" ]]; then - # Create LXC with dashboard-appropriate resources (1GB RAM, 15GB disk, 2 cores) - local dash_hostname="s3-dashboards" - local dash_pass - dash_pass="$(openssl rand -base64 12 2>/dev/null || echo 's3dashboards')" - - msg_info "Creating LXC container for dashboards (ID: ${dash_vmid})" - - local template - template="$(ls /var/lib/vz/template/cache/debian-12-standard* 2>/dev/null | head -1)" - [[ -z "$template" ]] && template="$(ls /var/lib/vz/template/cache/ubuntu-24* 2>/dev/null | head -1)" - - local net_config="name=eth0,bridge=${BRIDGE}" - [[ -n "$VLAN_TAG" ]] && net_config+=",tag=${VLAN_TAG}" - [[ "$IP_MODE" == "dhcp" ]] && net_config+=",ip=dhcp" - - pct create "$dash_vmid" "$template" \ - --hostname "$dash_hostname" \ - --password "$dash_pass" \ - --storage "$STORAGE" \ - --rootfs "${STORAGE}:15" \ - --memory 1024 \ - --cores 2 \ - --net0 "$net_config" \ - --unprivileged 1 \ - --features nesting=1 \ - --onboot 1 \ - --start 0 &>/dev/null - - echo "$dash_pass" > "${TEMP_DIR}/pass_dashboards" - msg_ok "Created LXC ${dash_hostname} (ID: ${dash_vmid})" - - pct start "$dash_vmid" &>/dev/null || true - sleep 5 - - # Wait for network - local retries=0 - while ! pct exec "$dash_vmid" -- ping -c1 -W2 8.8.8.8 &>/dev/null; do - ((retries++)) - [[ $retries -ge 30 ]] && { msg_warn "Network timeout for dashboards container"; break; } - sleep 2 - done - - # Push and run install script - local dash_script="${TEMP_DIR}/dashboards.sh" - wget -qO "$dash_script" "${REPO_URL}/scripts/setup/components/dashboards.sh" 2>/dev/null || true - if [[ -f "$dash_script" && -s "$dash_script" ]]; then - pct push "$dash_vmid" "$dash_script" "/tmp/dashboards.sh" &>/dev/null - pct exec "$dash_vmid" -- bash "/tmp/dashboards.sh" &>> "$LOG_FILE" || true - fi - - # Wire Zeek log access if Zeek container exists - local zeek_vmid_file="${TEMP_DIR}/vmid_zeek" - if [[ -f "$zeek_vmid_file" ]]; then - local zeek_vmid - zeek_vmid="$(cat "$zeek_vmid_file")" - msg_info "Configuring Zeek log bind mount for dashboards container" - # Create mount point on host - local zeek_log_host="/var/lib/lxc/${zeek_vmid}/rootfs/opt/zeek/logs" - if [[ -d "$zeek_log_host" ]]; then - pct set "$dash_vmid" -mp0 "${zeek_log_host},mp=/opt/s3-dashboards/zeek-logs,ro=1" &>/dev/null || true - msg_ok "Zeek logs mounted read-only at /opt/s3-dashboards/zeek-logs" - else - msg_warn "Zeek log directory not found yet. Configure bind mount manually after Zeek generates logs." - fi - fi - - local dash_ip - dash_ip=$(pct exec "$dash_vmid" -- hostname -I 2>/dev/null | awk '{print $1}') || dash_ip="DHCP" - msg_ok "S³ Dashboards deployed at http://${dash_ip}/" - else - create_vm "dashboards" "$dash_vmid" - msg_warn "Dashboards VM created but requires manual OS install and script execution" - fi - fi - - generate_summary - - log "=== Installation complete ===" -} - -main "$@" diff --git a/scripts/setup/integrate.sh b/scripts/setup/integrate.sh deleted file mode 100644 index fc4b699..0000000 --- a/scripts/setup/integrate.sh +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env bash -# ------------------------------------------------------------------------------ -# S³ Stack - Integration Script -# Connects SOC components together for unified operations -# -# MIT License - Copyright (c) 2024 Solomon Neas -# ------------------------------------------------------------------------------ - -set -euo pipefail - -LOG_TAG="[S³:Integrate]" -SUMMARY_FILE="/root/s3-stack-summary.txt" - -# ── Colors ──────────────────────────────────────────────────────────────────── -GN="\033[1;92m" RD="\033[01;31m" YW="\033[33m" CY="\033[36m" CL="\033[m" -CM="${GN}✓${CL}" CROSS="${RD}✗${CL}" INFO="${CY}ℹ${CL}" WARN="${YW}⚠${CL}" - -msg_info() { echo -e " ${INFO} ${LOG_TAG} ${1}..."; } -msg_ok() { echo -e " ${CM} ${LOG_TAG} ${1}"; } -msg_error(){ echo -e " ${CROSS} ${LOG_TAG} ${1}"; } -msg_warn() { echo -e " ${WARN} ${LOG_TAG} ${1}"; } - -echo "" -echo -e "${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" -echo -e "${GN} S³ Stack -- Integration Setup${CL}" -echo -e "${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" -echo "" - -# ── Discover Components ─────────────────────────────────────────────────────── -get_container_ip() { - local name="$1" - local vmid - vmid=$(pct list 2>/dev/null | grep "s3-${name}" | awk '{print $1}') - if [[ -n "$vmid" ]]; then - pct exec "$vmid" -- hostname -I 2>/dev/null | awk '{print $1}' - fi -} - -WAZUH_IP=$(get_container_ip "wazuh" || echo "") -THEHIVE_IP=$(get_container_ip "thehive" || echo "") -CORTEX_IP=$(get_container_ip "cortex" || echo "") -MISP_IP=$(get_container_ip "misp" || echo "") -ZEEK_IP=$(get_container_ip "zeek" || echo "") -SURICATA_IP=$(get_container_ip "suricata" || echo "") - -# ── 1. Wazuh to TheHive (Webhook) ──────────────────────────────────────────── -if [[ -n "$WAZUH_IP" && -n "$THEHIVE_IP" ]]; then - msg_info "Configuring Wazuh alerts to TheHive webhook" - - wazuh_vmid="" - wazuh_vmid=$(pct list 2>/dev/null | grep "s3-wazuh" | awk '{print $1}') - - if [[ -n "$wazuh_vmid" ]]; then - pct exec "$wazuh_vmid" -- bash -c "cat > /var/ossec/integrations/custom-thehive.py << 'PYEOF' -#!/usr/bin/env python3 -import json -import sys -import requests - -THEHIVE_URL = \"http://${THEHIVE_IP}:9000\" -THEHIVE_API_KEY = \"YOUR_API_KEY_HERE\" - -def create_alert(alert_data): - headers = { - \"Authorization\": f\"Bearer {THEHIVE_API_KEY}\", - \"Content-Type\": \"application/json\" - } - alert = { - \"type\": \"wazuh\", - \"source\": \"Wazuh SIEM\", - \"sourceRef\": alert_data.get(\"id\", \"unknown\"), - \"title\": alert_data.get(\"rule\", {}).get(\"description\", \"Wazuh Alert\"), - \"description\": json.dumps(alert_data, indent=2), - \"severity\": min(int(alert_data.get(\"rule\", {}).get(\"level\", 3)) // 4 + 1, 4), - \"tlp\": 2, - \"tags\": [\"wazuh\", \"s3-stack\"] - } - requests.post(f\"{THEHIVE_URL}/api/v1/alert\", headers=headers, json=alert, verify=False) - -if __name__ == \"__main__\": - alert_file = sys.argv[1] - with open(alert_file) as f: - alert_data = json.load(f) - create_alert(alert_data) -PYEOF -chmod +x /var/ossec/integrations/custom-thehive.py" &>/dev/null || true - - # Add integration to ossec.conf - pct exec "$wazuh_vmid" -- bash -c " - if ! grep -q 'custom-thehive' /var/ossec/etc/ossec.conf 2>/dev/null; then - sed -i '/<\/ossec_config>/i\\ - \\ - custom-thehive\\ - http://${THEHIVE_IP}:9000\\ - 8\\ - json\\ - ' /var/ossec/etc/ossec.conf - fi" &>/dev/null || true - - msg_ok "Wazuh to TheHive webhook configured" - msg_warn "Update THEHIVE_API_KEY in /var/ossec/integrations/custom-thehive.py" - fi -else - msg_warn "Skipping Wazuh to TheHive (one or both not found)" -fi - -# ── 2. Cortex Analyzers Configuration ──────────────────────────────────────── -if [[ -n "$CORTEX_IP" ]]; then - msg_info "Preparing Cortex analyzer configuration" - - echo "" - echo -e " ${CY}Cortex Analyzers to configure:${CL}" - echo -e " 1. VirusTotal_GetReport (requires API key)" - echo -e " 2. AbuseIPDB (requires API key)" - echo -e " 3. OTXQuery (requires API key)" - echo -e " 4. Shodan_Host (requires API key)" - echo -e " 5. MISP_2_1 (auto-configured if MISP present)" - echo "" - msg_warn "Configure analyzer API keys via Cortex web UI at http://${CORTEX_IP}:9001" - - # Auto-configure MISP analyzer if MISP is present - if [[ -n "$MISP_IP" ]]; then - msg_ok "MISP analyzer can be configured with URL: https://${MISP_IP}" - fi - - msg_ok "Cortex analyzer list prepared" -else - msg_warn "Skipping Cortex configuration (not found)" -fi - -# ── 3. TheHive to Cortex Connection ────────────────────────────────────────── -if [[ -n "$THEHIVE_IP" && -n "$CORTEX_IP" ]]; then - msg_info "Connecting TheHive to Cortex" - - thehive_vmid="" - thehive_vmid=$(pct list 2>/dev/null | grep "s3-thehive" | awk '{print $1}') - - if [[ -n "$thehive_vmid" ]]; then - pct exec "$thehive_vmid" -- bash -c " - if ! grep -q 'cortex' /etc/thehive/application.conf 2>/dev/null; then - cat >> /etc/thehive/application.conf << 'EOF' - -# Cortex integration (S³ Stack) -play.modules.enabled += org.thp.thehive.connector.cortex.CortexModule -cortex { - servers = [ - { - name = \"S3-Cortex\" - url = \"http://${CORTEX_IP}:9001\" - auth { - type = \"bearer\" - key = \"YOUR_CORTEX_API_KEY\" - } - } - ] -} -EOF - systemctl restart thehive - fi" &>/dev/null || true - msg_ok "TheHive connected to Cortex" - msg_warn "Update Cortex API key in /etc/thehive/application.conf" - fi -else - msg_warn "Skipping TheHive to Cortex (one or both not found)" -fi - -# ── 4. MISP to Suricata Rules Feed ─────────────────────────────────────────── -if [[ -n "$MISP_IP" && -n "$SURICATA_IP" ]]; then - msg_info "Setting up MISP to Suricata rule feed" - - suricata_vmid="" - suricata_vmid=$(pct list 2>/dev/null | grep "s3-suricata" | awk '{print $1}') - - if [[ -n "$suricata_vmid" ]]; then - # Add MISP as a rule source in suricata-update - pct exec "$suricata_vmid" -- bash -c " - mkdir -p /etc/suricata/update.d - cat > /etc/suricata/update.d/misp.conf << EOF -# MISP threat intel feed (S³ Stack) -# Update this with your MISP automation key -url = https://${MISP_IP}/attributes/restSearch/returnFormat:snort/type:snort -secret-code = YOUR_MISP_API_KEY -EOF - - # Add cron job for hourly rule updates - cat > /etc/cron.d/s3-misp-rules << 'CRON' -# S³ Stack: MISP rule sync -0 * * * * root suricata-update && systemctl reload suricata -CRON - " &>/dev/null || true - msg_ok "MISP to Suricata rule feed configured" - msg_warn "Update MISP API key in /etc/suricata/update.d/misp.conf" - fi -else - msg_warn "Skipping MISP to Suricata (one or both not found)" -fi - -# ── 5. Zeek Log Forwarding to Wazuh ────────────────────────────────────────── -if [[ -n "$ZEEK_IP" && -n "$WAZUH_IP" ]]; then - msg_info "Setting up Zeek log forwarding to Wazuh" - - zeek_vmid="" - zeek_vmid=$(pct list 2>/dev/null | grep "s3-zeek" | awk '{print $1}') - - if [[ -n "$zeek_vmid" ]]; then - # Install Wazuh agent on Zeek container - pct exec "$zeek_vmid" -- bash -c " - if ! command -v /var/ossec/bin/wazuh-control &>/dev/null; then - curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH | gpg --dearmor -o /usr/share/keyrings/wazuh.gpg - echo 'deb [signed-by=/usr/share/keyrings/wazuh.gpg] https://packages.wazuh.com/4.x/apt/ stable main' > /etc/apt/sources.list.d/wazuh.list - DEBIAN_FRONTEND=noninteractive apt-get update -qq - WAZUH_MANAGER='${WAZUH_IP}' DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wazuh-agent - systemctl enable --now wazuh-agent - fi - - # Configure Zeek log monitoring - if ! grep -q 'zeek' /var/ossec/etc/ossec.conf 2>/dev/null; then - sed -i '/<\/ossec_config>/i\\ - \\ - json\\ - /opt/zeek/logs/current/conn.log\\ - \\ - \\ - json\\ - /opt/zeek/logs/current/dns.log\\ - \\ - \\ - json\\ - /opt/zeek/logs/current/http.log\\ - \\ - \\ - json\\ - /opt/zeek/logs/current/ssl.log\\ - \\ - \\ - json\\ - /opt/zeek/logs/current/notice.log\\ - ' /var/ossec/etc/ossec.conf - systemctl restart wazuh-agent - fi - " &>/dev/null || true - msg_ok "Zeek log forwarding to Wazuh configured" - fi -else - msg_warn "Skipping Zeek to Wazuh (one or both not found)" -fi - -# ── Summary ─────────────────────────────────────────────────────────────────── -echo "" -echo -e "${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" -echo -e "${GN} S³ Stack -- Integration Complete${CL}" -echo -e "${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" -echo "" -echo -e " ${CY}Remaining manual steps:${CL}" -echo -e " 1. Set TheHive API key in Wazuh webhook script" -echo -e " 2. Set Cortex API key in TheHive config" -echo -e " 3. Configure Cortex analyzer API keys via web UI" -echo -e " 4. Set MISP automation key in Suricata rule feed" -echo "" -echo -e " ${CY}All API keys should be generated from each tool's web interface.${CL}" -echo "" diff --git a/tests/unit/test_json_out.bats b/tests/unit/test_json_out.bats index 4cef343..aa6455d 100644 --- a/tests/unit/test_json_out.bats +++ b/tests/unit/test_json_out.bats @@ -99,3 +99,42 @@ setup() { mode="$(stat -c "%a" "${out}")" [[ "${mode}" == "600" ]] } + +@test "state_set does not destroy state when the existing file is corrupt" { + local f="${SOC_STATE_DIR}/state/wazuh.json" + printf '{ this is not valid json' > "${f}" + run state_set wazuh status "deployed" + assert_success + jq -e . "${f}" # parses again + jq -e '.status == "deployed"' "${f}" # the update landed +} + +@test "state_get returns empty on a corrupt state file without aborting" { + printf '{ broken' > "${SOC_STATE_DIR}/state/wazuh.json" + run state_get wazuh status + assert_success + assert_output "" +} + +@test "redact_json redacts broadened key names and url-embedded credentials" { + local input='{"pwd":"a","bearer":"b","credential":"c","passwd":"d","endpoint":"https://user:secretpass@host:9000/x","ok":"plain"}' + local out + out="$(printf '%s' "${input}" | redact_json)" + jq -e '.pwd == "REDACTED"' <<< "${out}" + jq -e '.bearer == "REDACTED"' <<< "${out}" + jq -e '.credential == "REDACTED"' <<< "${out}" + jq -e '.passwd == "REDACTED"' <<< "${out}" + jq -e '.ok == "plain"' <<< "${out}" + jq -e '.endpoint | contains("REDACTED@host")' <<< "${out}" + jq -e '.endpoint | contains("secretpass") | not' <<< "${out}" +} + +@test "emit_final_json redacts mcp endpoint bearer tokens by default" { + state_set mcp status "deployed" + state_set mcp mcp_endpoints '[{"name":"wazuh","url":"http://127.0.0.1:9101/sse","token":"deadbeeftoken"}]' + local out="${BATS_TEST_TMPDIR}/result.json" + emit_final_json "${out}" + jq -e '.components[] | select(.name=="mcp") | .mcp_endpoints[0].token == "REDACTED"' "${out}" + run grep -q "deadbeeftoken" "${out}" + assert_failure +} diff --git a/tests/unit/test_manifest.bats b/tests/unit/test_manifest.bats index 216e459..3ec23ae 100644 --- a/tests/unit/test_manifest.bats +++ b/tests/unit/test_manifest.bats @@ -54,3 +54,25 @@ setup() { jq -e '.components[0] == "wazuh"' "${out}" jq -e '.preset == "standard"' "${out}" } + +@test "validate_manifest rejects a name that only substring-matches a known one" { + local m="${BATS_TEST_TMPDIR}/bad-cortex.json" + printf '%s' '{ "components": ["cortex"], "preset": "standard" }' > "${m}" + run validate_manifest "${m}" + [[ "$status" -ne 0 ]] + [[ "${output}${stderr:-}" == *"unknown components"* ]] +} + +@test "validate_manifest rejects a regex-style component name" { + local m="${BATS_TEST_TMPDIR}/bad-regex.json" + printf '%s' '{ "components": ["m.p"], "preset": "standard" }' > "${m}" + run validate_manifest "${m}" + [[ "$status" -ne 0 ]] +} + +@test "validate_manifest accepts all six known components" { + local m="${BATS_TEST_TMPDIR}/all.json" + printf '%s' '{ "components": ["wazuh","thehive-cortex","misp","zeek-suricata","dashboards","mcp"], "preset": "standard" }' > "${m}" + run validate_manifest "${m}" + assert_success +} diff --git a/tests/unit/test_orchestrator_flag_parsing.bats b/tests/unit/test_orchestrator_flag_parsing.bats index c255bd0..7211863 100644 --- a/tests/unit/test_orchestrator_flag_parsing.bats +++ b/tests/unit/test_orchestrator_flag_parsing.bats @@ -78,6 +78,14 @@ setup() { [[ "${output}${stderr:-}" == *"missing value"* ]] } +@test "parse_args accepts the --flag=value form" { + parse_args --components=wazuh,misp --preset=minimal --mcp-bind-host=0.0.0.0 + [[ "${OPT_COMPONENTS}" == "wazuh,misp" ]] + [[ "${OPT_COMPONENTS_SET}" == "1" ]] + [[ "${OPT_PRESET}" == "minimal" ]] + [[ "${OPT_MCP_BIND_HOST}" == "0.0.0.0" ]] +} + @test "validate_options rejects invalid preset" { parse_args --preset tiny run validate_options diff --git a/tests/unit/test_secrets.bats b/tests/unit/test_secrets.bats index b77b37b..4b35f56 100644 --- a/tests/unit/test_secrets.bats +++ b/tests/unit/test_secrets.bats @@ -61,3 +61,17 @@ setup() { run get_secret "key1" assert_output "new" } + +@test "gen_password succeeds under set -euo pipefail (no SIGPIPE 141 regression)" { + # A bare assignment from gen_password under pipefail used to abort with 141 + # because tr takes SIGPIPE when head closes the pipe. Guard that directly. + run bash -c ' + set -euo pipefail + source "'"${REPO_ROOT}"'/scripts/lib/logging.sh" + source "'"${REPO_ROOT}"'/scripts/lib/secrets.sh" + pw="$(gen_password 24)" + printf "%s" "${#pw}" + ' + assert_success + assert_output "24" +} From 2545885a592f8ca13694657baee8562d1b56eea3 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Tue, 14 Jul 2026 12:05:12 -0400 Subject: [PATCH 05/13] harden(deploy): apply VLAN tag, fix static-mode routing and idempotent status The orchestrator validated and stored several network settings but never applied them, and a successful deploy could fail to record itself. All covered by new dry-run unit tests. - --vlan was parsed, validated, and written to the manifest but the tag never reached the container net config, so it silently did nothing. Append ,tag= to --net0. - --ip-mode static built ip= with no gw=, so containers came up with an address but no default route; lxc_wait_network then timed out pinging 8.8.8.8 and every static deploy failed. Derive the gateway (new --gateway flag, else first host of the range) and append ,gw=. Adds default_gateway() to lib/network.sh. - Static IPs were allocated by the component's position in the selected subset, so `--components misp` (index 0) collided with an already-deployed wazuh (also index 0). Allocate by the fixed canonical ordinal via component_ordinal(). - A successful deploy only got status=deployed if the component's in-LXC deploy.sh wrote it and the pct pull merged it; otherwise the component was re-deployed on every rerun. Record status=deployed authoritatively after verify passes. --- scripts/install.sh | 69 +++++++++++++++++-- scripts/lib/network.sh | 8 +++ tests/unit/test_network.bats | 6 ++ .../test_orchestrator_manifest_building.bats | 43 ++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index c584ad6..474f223 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -29,6 +29,8 @@ OPT_IP_RANGE="" # shellcheck disable=SC2034 OPT_VLAN="" # shellcheck disable=SC2034 +OPT_GATEWAY="" +# shellcheck disable=SC2034 OPT_VMID_START="0" # shellcheck disable=SC2034 OPT_MANIFEST="" @@ -66,6 +68,7 @@ Flags: --storage NAME Storage pool (default: auto-detect) --ip-mode MODE dhcp|static (default: dhcp) --ip-range CIDR Required if --ip-mode=static + --gateway IP Default route for static mode (default: first host, x.x.x.1) --vlan TAG Optional VLAN tag --vmid-start N First VMID to allocate (default: next free) --manifest PATH JSON manifest (alternative to flags) @@ -96,7 +99,7 @@ parse_args() { set -- "${_k}" "${_v}" "${@:2}" fi case "$1" in - --components|--preset|--bridge|--storage|--ip-mode|--ip-range|--vlan|--vmid-start|--manifest|--state-dir|--json-out|--mcp-config-out|--log-file|--mcp-bind-host) + --components|--preset|--bridge|--storage|--ip-mode|--ip-range|--gateway|--vlan|--vmid-start|--manifest|--state-dir|--json-out|--mcp-config-out|--log-file|--mcp-bind-host) flag="$1" if [[ $# -lt 2 || "$2" == --* ]]; then printf 'missing value for %s\n' "${flag}" >&2 @@ -110,6 +113,7 @@ parse_args() { --storage) OPT_STORAGE="$2" ;; --ip-mode) OPT_IP_MODE="$2" ;; --ip-range) OPT_IP_RANGE="$2" ;; + --gateway) OPT_GATEWAY="$2" ;; --vlan) OPT_VLAN="$2" ;; --vmid-start) OPT_VMID_START="$2" ;; --manifest) OPT_MANIFEST="$2" ;; @@ -164,6 +168,11 @@ validate_options() { fi fi + if [[ -n "${OPT_GATEWAY}" && ! "${OPT_GATEWAY}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + validation_error "invalid gateway: ${OPT_GATEWAY}" + return 1 + fi + if [[ ! "${OPT_VMID_START}" =~ ^[0-9]+$ ]]; then validation_error "invalid vmid start: ${OPT_VMID_START}" return 1 @@ -266,6 +275,21 @@ source_libs() { # Known components in canonical order COMPONENTS_KNOWN=("wazuh" "thehive-cortex" "misp" "zeek-suricata" "dashboards" "mcp") +# component_ordinal +# Prints the component's fixed position in COMPONENTS_KNOWN (wazuh=0 .. mcp=5), +# or 0 if unknown. Static IP allocation keys off this stable ordinal rather than +# the position within the selected subset, so a given component always lands on +# the same address regardless of which other components are selected. Keying off +# the selection index instead made `--components misp` (index 0) collide with a +# previously deployed wazuh (also index 0) at the same IP. +component_ordinal() { + local target="$1" i + for i in "${!COMPONENTS_KNOWN[@]}"; do + [[ "${COMPONENTS_KNOWN[$i]}" == "${target}" ]] && { printf '%s' "${i}"; return 0; } + done + printf '0' +} + # expand_components # Echoes space-separated component names in canonical order. expand_components() { @@ -312,11 +336,12 @@ validate_components_json() { validate_manifest_values() { local manifest="$1" - local preset ip_mode vmid_start ip_range vlan + local preset ip_mode vmid_start ip_range gateway vlan preset="$(jq -r '.preset // empty' <<< "${manifest}")" ip_mode="$(jq -r '.network.ip_mode // empty' <<< "${manifest}")" vmid_start="$(jq -r '.vmid_start // 0' <<< "${manifest}")" ip_range="$(jq -r '.network.ip_range // empty' <<< "${manifest}")" + gateway="$(jq -r '.network.gateway // empty' <<< "${manifest}")" vlan="$(jq -r '.network.vlan // empty' <<< "${manifest}")" case "${preset}" in @@ -335,6 +360,10 @@ validate_manifest_values() { printf 'invalid vmid_start: %s\n' "${vmid_start}" >&2 return 1 fi + if [[ -n "${gateway}" && ! "${gateway}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + printf 'invalid gateway: %s\n' "${gateway}" >&2 + return 1 + fi if [[ -n "${vlan}" ]]; then if [[ ! "${vlan}" =~ ^[0-9]+$ ]] || (( vlan < 1 || vlan > 4094 )); then printf 'invalid vlan tag: %s\n' "${vlan}" >&2 @@ -386,6 +415,9 @@ build_manifest() { if [[ -n "${OPT_IP_RANGE}" ]]; then manifest="$(jq --arg v "${OPT_IP_RANGE}" '.network.ip_range = $v' <<< "${manifest}")" fi + if [[ -n "${OPT_GATEWAY}" ]]; then + manifest="$(jq --arg v "${OPT_GATEWAY}" '.network.gateway = $v' <<< "${manifest}")" + fi if [[ -n "${OPT_VLAN}" ]]; then manifest="$(jq --arg v "${OPT_VLAN}" '.network.vlan = $v' <<< "${manifest}")" fi @@ -413,6 +445,7 @@ build_manifest() { --arg storage "${OPT_STORAGE}" \ --arg ip_mode "${OPT_IP_MODE}" \ --arg ip_range "${OPT_IP_RANGE}" \ + --arg gateway "${OPT_GATEWAY}" \ --arg vlan "${OPT_VLAN}" \ --argjson vmid_start "${OPT_VMID_START}" \ '{ @@ -423,6 +456,7 @@ build_manifest() { storage: (if $storage == "" then null else $storage end), ip_mode: $ip_mode, ip_range: (if $ip_range == "" then null else $ip_range end), + gateway: (if $gateway == "" then null else $gateway end), vlan: (if $vlan == "" then null else $vlan end) }, vmid_start: $vmid_start @@ -506,11 +540,13 @@ deploy_one() { return 0 fi - local preset bridge storage ip_mode + local preset bridge storage ip_mode vlan gateway preset="$(jq -r '.preset' <<< "${manifest}")" bridge="$(jq -r '.network.bridge' <<< "${manifest}")" storage="$(jq -r '.network.storage // "local-lvm"' <<< "${manifest}")" ip_mode="$(jq -r '.network.ip_mode' <<< "${manifest}")" + vlan="$(jq -r '.network.vlan // empty' <<< "${manifest}")" + gateway="$(jq -r '.network.gateway // empty' <<< "${manifest}")" # Get a VMID local vmid_start vmid @@ -526,7 +562,7 @@ deploy_one() { case "${ip_mode}" in dhcp) net_config+=",ip=dhcp" ;; static) - local ip_range ip + local ip_range ip gw ip_range="$(jq -r '.network.ip_range' <<< "${manifest}")" if ! ip="$(allocate_ip "${ip_range}" "${index}")"; then msg_error "${component}: static IP allocation failed for ${ip_range} index ${index}" @@ -534,8 +570,19 @@ deploy_one() { return 1 fi net_config+=",ip=${ip}" + # Static containers need an explicit default route or they come up with an + # address but no path off-subnet; lxc_wait_network then times out pinging + # 8.8.8.8 and every static deploy fails. Use --gateway if given, else the + # first host of the range. + gw="${gateway:-$(default_gateway "${ip_range}")}" + net_config+=",gw=${gw}" ;; esac + # Apply the VLAN tag when set. It was validated and stored in the manifest but + # never reached the container config, so --vlan silently did nothing. + if [[ -n "${vlan}" && "${vlan}" != "null" ]]; then + net_config+=",tag=${vlan}" + fi # Get template local template @@ -648,6 +695,12 @@ deploy_one() { state_set "${component}" "lxc.ip" "${ip}" fi + # Authoritatively record success on the host. Previously "deployed" came only + # from the component's in-LXC deploy.sh writing a status field that then had to + # survive the pct pull; if that file was missing or the pull raced, a + # successful deploy left no status=deployed and was re-deployed on every rerun. + state_set "${component}" status "deployed" + msg_ok "${component} deployed successfully" return 0 } @@ -739,16 +792,18 @@ main() { plan_dependency_warnings "${components_arr[@]}" local component - local component_index=0 local deploy_failures=0 local deploy_successes=0 for component in "${components_arr[@]}"; do - if deploy_one "${component}" "${manifest}" "${component_index}"; then + # Allocate static IPs by the component's canonical ordinal, not its position + # in the selected subset, so subsets and reruns are collision-free. + local ordinal + ordinal="$(component_ordinal "${component}")" + if deploy_one "${component}" "${manifest}" "${ordinal}"; then deploy_successes=$((deploy_successes + 1)) else deploy_failures=$((deploy_failures + 1)) fi - component_index=$((component_index + 1)) done exit_status="$(deploy_exit_status "${deploy_failures}" "${deploy_successes}")" diff --git a/scripts/lib/network.sh b/scripts/lib/network.sh index 20ace9b..b7db708 100644 --- a/scripts/lib/network.sh +++ b/scripts/lib/network.sh @@ -43,6 +43,14 @@ allocate_ip() { printf '%s.%d/%s\n' "${base_prefix}" "${last}" "${cidr}" } +# default_gateway +# Given "198.51.100.10/24" returns "198.51.100.1" (first host of the /24). +# Used as the default route for static-mode containers when --gateway is unset. +default_gateway() { + local base_ip="${1%/*}" + printf '%s.1\n' "${base_ip%.*}" +} + # validate_bridge # Exit 0 if the bridge exists on the host; non-zero otherwise. validate_bridge() { diff --git a/tests/unit/test_network.bats b/tests/unit/test_network.bats index cc204b7..6ce2fdd 100644 --- a/tests/unit/test_network.bats +++ b/tests/unit/test_network.bats @@ -8,6 +8,12 @@ setup() { source_lib network } +@test "default_gateway returns the first host of the range" { + run default_gateway "198.51.100.10/24" + assert_success + assert_output "198.51.100.1" +} + @test "next_vmid returns sequential starting from vmid_start" { MOCK_PCT_LIST=$'VMID Status Lock Name\n100 running - existing1\n101 running - existing2' export MOCK_PCT_LIST diff --git a/tests/unit/test_orchestrator_manifest_building.bats b/tests/unit/test_orchestrator_manifest_building.bats index 90633ee..248e7ed 100644 --- a/tests/unit/test_orchestrator_manifest_building.bats +++ b/tests/unit/test_orchestrator_manifest_building.bats @@ -76,6 +76,49 @@ setup() { grep -q "ip=198.51.100.11/24" "${SOC_LOG_FILE}" } +@test "deploy_one adds a default gateway for static mode" { + parse_args --components wazuh --ip-mode static --ip-range 198.51.100.10/24 \ + --dry-run --state-dir "${SOC_STATE_DIR}" --log-file "${SOC_LOG_FILE}" + source_libs + local manifest; manifest="$(build_manifest)" + deploy_one wazuh "${manifest}" 0 + grep -q "gw=198.51.100.1" "${SOC_LOG_FILE}" +} + +@test "deploy_one honors an explicit --gateway for static mode" { + parse_args --components wazuh --ip-mode static --ip-range 198.51.100.20/24 \ + --gateway 198.51.100.254 --dry-run --state-dir "${SOC_STATE_DIR}" --log-file "${SOC_LOG_FILE}" + source_libs + local manifest; manifest="$(build_manifest)" + deploy_one wazuh "${manifest}" 0 + grep -q "gw=198.51.100.254" "${SOC_LOG_FILE}" +} + +@test "deploy_one applies the VLAN tag to the container net config" { + parse_args --components wazuh --vlan 20 --dry-run \ + --state-dir "${SOC_STATE_DIR}" --log-file "${SOC_LOG_FILE}" + source_libs + local manifest; manifest="$(build_manifest)" + deploy_one wazuh "${manifest}" 0 + grep -q "tag=20" "${SOC_LOG_FILE}" +} + +@test "static IP keys off the canonical ordinal, not the selection index" { + # mcp is ordinal 5, so base .10 + 5 = .15 even when it is the only selection. + parse_args --components mcp --ip-mode static --ip-range 198.51.100.10/24 \ + --dry-run --state-dir "${SOC_STATE_DIR}" --log-file "${SOC_LOG_FILE}" + source_libs + local manifest; manifest="$(build_manifest)" + deploy_one mcp "${manifest}" "$(component_ordinal mcp)" + grep -q "ip=198.51.100.15/24" "${SOC_LOG_FILE}" +} + +@test "component_ordinal returns fixed canonical positions" { + [[ "$(component_ordinal wazuh)" == "0" ]] + [[ "$(component_ordinal thehive-cortex)" == "1" ]] + [[ "$(component_ordinal mcp)" == "5" ]] +} + @test "dependency warnings are recorded for selected-only degraded mode" { parse_args --components mcp --state-dir "${SOC_STATE_DIR}" --log-file "${SOC_LOG_FILE}" source_libs From ce5af54cc48dc2f5ee370441d304195fed066706 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Tue, 14 Jul 2026 12:08:45 -0400 Subject: [PATCH 06/13] harden(secrets): keep credentials off process argv during provisioning Secrets passed on a command line are readable via /proc//cmdline by any local UID for the life of the process. Two provisioning paths did this; move both to stdin, matching the pattern misp/deploy.sh already uses. - install.sh: set the LXC root password with `chpasswd` over stdin after start instead of `pct create --password ` (which exposed it on the Proxmox host process list). Dry-run message updated to match. - thehive-cortex/deploy.sh: the four TheHive/Cortex admin-credential requests used `curl -d "{...password...}"`, exposing the password inside an LXC that also runs untrusted container workloads. Feed the JSON body via `printf | curl --data @-`; printf is a shell builtin so the secret never reaches an argv. --- scripts/components/thehive-cortex/deploy.sh | 32 ++++++++++++--------- scripts/install.sh | 9 ++++-- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/scripts/components/thehive-cortex/deploy.sh b/scripts/components/thehive-cortex/deploy.sh index f2372de..be473a2 100755 --- a/scripts/components/thehive-cortex/deploy.sh +++ b/scripts/components/thehive-cortex/deploy.sh @@ -255,11 +255,15 @@ while (( es_elapsed < 180 )); do done (( es_patch_ok )) || write_failed "cortex_6/admin ES doc did not appear within 180s" -docker compose -f "${STACK_DIR}/docker-compose.yml" exec -T elasticsearch \ - curl -sf -X POST \ - "http://localhost:9200/cortex_6/_update/admin?routing=admin" \ - -H "Content-Type: application/json" \ - -d "{\"doc\":{\"password\":\"${CORTEX_PW_STORED}\",\"updatedBy\":\"wizard\",\"updatedAt\":$(date +%s)000}}" \ +# Feed the request body over stdin (printf is a shell builtin, so the password +# never lands on any process argv / /proc cmdline) rather than curl -d "...". +printf '{"doc":{"password":"%s","updatedBy":"wizard","updatedAt":%s000}}' \ + "${CORTEX_PW_STORED}" "$(date +%s)" \ + | docker compose -f "${STACK_DIR}/docker-compose.yml" exec -T elasticsearch \ + curl -sf -X POST \ + "http://localhost:9200/cortex_6/_update/admin?routing=admin" \ + -H "Content-Type: application/json" \ + --data @- \ >/dev/null || write_failed "failed to patch Cortex admin password in Elasticsearch" log "Cortex admin password patched" @@ -267,9 +271,9 @@ log "running Cortex first-run wizard" # Login, then fetch the root page to obtain the CORTEX-XSRF-TOKEN cookie. # Use X-CORTEX-XSRF-TOKEN header for subsequent mutating requests (per reference.conf). CJAR="$(mktemp)" -curl -sf -c "${CJAR}" -H "Content-Type: application/json" \ - -X POST "http://localhost:9001/api/login" \ - -d "{\"user\":\"admin\",\"password\":\"${CORTEX_ADMIN_PASS}\"}" >/dev/null \ +printf '{"user":"admin","password":"%s"}' "${CORTEX_ADMIN_PASS}" \ + | curl -sf -c "${CJAR}" -H "Content-Type: application/json" \ + -X POST "http://localhost:9001/api/login" --data @- >/dev/null \ || write_failed "Cortex admin login failed after password patch" curl -sf -c "${CJAR}" -b "${CJAR}" "http://localhost:9001/" >/dev/null 2>&1 CSRF="$(awk '/CORTEX-XSRF-TOKEN/ {print $7}' "${CJAR}" | head -1)" @@ -304,9 +308,9 @@ THEHIVE_ADMIN_PASS="$(get_or_create_secret thehive-admin)" thehive_login() { local pass="$1" local jar="$2" - curl -sf -c "${jar}" -H "Content-Type: application/json" \ - -X POST "http://localhost:9000/api/v1/session" \ - -d "{\"login\":\"admin@thehive.local\",\"password\":\"${pass}\"}" >/dev/null 2>&1 + printf '{"login":"admin@thehive.local","password":"%s"}' "${pass}" \ + | curl -sf -c "${jar}" -H "Content-Type: application/json" \ + -X POST "http://localhost:9000/api/v1/session" --data @- >/dev/null 2>&1 } TCJAR="$(mktemp)" @@ -314,9 +318,9 @@ if thehive_login "${THEHIVE_ADMIN_PASS}" "${TCJAR}"; then log "TheHive admin password already rotated (previous run)" elif thehive_login "${THEHIVE_DEFAULT_PASS}" "${TCJAR}"; then # Change password (uses /password/change endpoint, NOT /user) - curl -sf -b "${TCJAR}" -H "Content-Type: application/json" \ - -X POST "http://localhost:9000/api/v1/user/admin%40thehive.local/password/change" \ - -d "{\"currentPassword\":\"${THEHIVE_DEFAULT_PASS}\",\"password\":\"${THEHIVE_ADMIN_PASS}\"}" >/dev/null \ + printf '{"currentPassword":"%s","password":"%s"}' "${THEHIVE_DEFAULT_PASS}" "${THEHIVE_ADMIN_PASS}" \ + | curl -sf -b "${TCJAR}" -H "Content-Type: application/json" \ + -X POST "http://localhost:9000/api/v1/user/admin%40thehive.local/password/change" --data @- >/dev/null \ || write_failed "TheHive admin password rotation request failed" # Verify: the new password must log in. The component is not deployed diff --git a/scripts/install.sh b/scripts/install.sh index 474f223..269e2eb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -615,7 +615,7 @@ deploy_one() { done <<< "${spec_lines}" if [[ "${OPT_DRY_RUN}" == "1" ]]; then - msg_info "[dry-run] would: pct create ${vmid} ${template} --hostname s3-${component} ${pct_args[*]} --password ***" + msg_info "[dry-run] would: pct create ${vmid} ${template} --hostname s3-${component} ${pct_args[*]} (root password set via chpasswd after start)" return 0 fi @@ -624,8 +624,13 @@ deploy_one() { rootpw="$(gen_password 24)" store_secret "${component}-lxc-root" "${rootpw}" - lxc_create "${vmid}" "s3-${component}" "${template}" "${pct_args[@]}" --password "${rootpw}" + lxc_create "${vmid}" "s3-${component}" "${template}" "${pct_args[@]}" lxc_start "${vmid}" + # Set the container root password over stdin instead of `pct create + # --password`, which would expose it in the host process list + # (/proc//cmdline) for the duration of the create. chpasswd reads + # user:password from stdin. + printf 'root:%s\n' "${rootpw}" | pct exec "${vmid}" -- chpasswd msg_info "waiting for LXC ${vmid} network" lxc_wait_network "${vmid}" From c338c30fec98a934968d861891f6321a3d6be5a0 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Tue, 14 Jul 2026 12:11:01 -0400 Subject: [PATCH 07/13] harden(ci): gate self-hosted jobs to non-fork, pin actions, least privilege The integration-component job runs on the self-hosted Proxmox runner (which can create/destroy LXCs, and whose gh-runner account has broad sudo including bash) on every pull_request with no fork guard. A fork PR editing the CI helper or a deploy script could run as root on the host. - Add `if: github.event_name == 'push' || pull_request.head.repo.full_name == github.repository` so only same-repo PRs and pushes reach the self-hosted runner; fork PRs run only the sandboxed ubuntu-latest jobs. (integration-full was already push+main guarded.) - Pin actions/checkout@v4 to its commit SHA (v4.2.2) across all jobs. - Add top-level `permissions: contents: read` (the workflow only reads the repo). - Correct the setup-ci-runner.sh header comment, which claimed the sudoers was scoped to pct/qm/pvesm/pveam only; it also grants bash. Document that the fork guard is what makes that acceptable. --- .github/workflows/ci.yml | 19 ++++++++++++++----- tools/setup-ci-runner.sh | 6 +++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9a131e..d5046ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,12 +10,16 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Least privilege: this workflow only reads the repo. No job needs write scopes. +permissions: + contents: read + jobs: shellcheck: name: Shellcheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install shellcheck run: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - name: Shellcheck all bash @@ -31,7 +35,7 @@ jobs: name: Bats unit tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: submodules: recursive - name: Install jq @@ -43,7 +47,7 @@ jobs: name: Component manifest validation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install jq run: sudo apt-get install -y -qq jq - name: Validate every component manifest is parseable JSON (after stripping comments) @@ -58,6 +62,11 @@ jobs: integration-component: name: Integration (per-component) needs: [shellcheck, unit-tests, manifest-schema] + # Never run untrusted fork-PR code on the self-hosted Proxmox runner: that + # runner can create/destroy LXCs and its account has broad sudo on the host. + # Only same-repo PRs (and pushes to main) reach it; fork PRs run just the + # sandboxed ubuntu-latest jobs above. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository runs-on: [self-hosted, soc-stack] concurrency: group: soc-stack-integration @@ -75,7 +84,7 @@ jobs: - zeek-suricata - mcp steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Deploy + assert ${{ matrix.component }} run: bash tests/integration/ci-helpers/boot-test-env.sh "${{ matrix.component }}" - name: Tear down @@ -91,7 +100,7 @@ jobs: group: soc-stack-integration cancel-in-progress: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Deploy full stack + 5 cross-component integrations run: bash tests/integration/ci-helpers/boot-test-env.sh all - name: Tear down diff --git a/tools/setup-ci-runner.sh b/tools/setup-ci-runner.sh index a2a0b9d..d3c7885 100755 --- a/tools/setup-ci-runner.sh +++ b/tools/setup-ci-runner.sh @@ -7,7 +7,11 @@ # with Ubuntu 22.04, 4 GB RAM, 30 GB disk, 2 cores # 2. Generates an ed25519 SSH key inside the LXC # 3. Creates a `gh-runner` user on the Proxmox host with the LXC's public key -# authorized, and a sudoers entry scoped to pct, qm, pvesm, pveam only +# authorized, and a sudoers entry for pct/qm/pvesm/pveam PLUS bash (the CI +# helpers run `sudo bash