From a9d608d8d16c3addbf314bd70dffff600e065502 Mon Sep 17 00:00:00 2001 From: "Mr.Rabbit" Date: Wed, 30 Jul 2025 07:50:45 +0900 Subject: [PATCH 1/2] docs: add special thanks section --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2688054..da5e118 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,10 @@ As cyber attacks become faster and more automated, traditional honeypots fall sh --- +## 特別謝辞 / Special Thanks + +- @k + ## ライセンス / License MIT License From e688fbce2b57125cab52bf3c911664681a80bb20 Mon Sep 17 00:00:00 2001 From: "Mr.Rabbit" Date: Sun, 21 Sep 2025 11:49:35 +0900 Subject: [PATCH 2/2] chore: add pytest configuration and fix pc monitor script --- .github/workflows/ci.yml | 34 ++++++++ .github/workflows/release.yml | 21 +++++ .gitignore | 3 +- Makefile | 15 ++++ README.md | 25 ++++++ azazel_core/__init__.py | 13 ++++ azazel_core/actions/__init__.py | 16 ++++ azazel_core/actions/base.py | 22 ++++++ azazel_core/actions/block.py | 20 +++++ azazel_core/actions/delay.py | 21 +++++ azazel_core/actions/redirect.py | 25 ++++++ azazel_core/actions/shape.py | 21 +++++ azazel_core/api/__init__.py | 6 ++ azazel_core/api/schemas.py | 14 ++++ azazel_core/api/server.py | 25 ++++++ azazel_core/config.py | 30 ++++++++ azazel_core/ingest/__init__.py | 6 ++ azazel_core/ingest/canary_tail.py | 26 +++++++ azazel_core/ingest/suricata_tail.py | 32 ++++++++ azazel_core/notify/mattermost.py | 29 +++++++ azazel_core/qos/__init__.py | 6 ++ azazel_core/qos/apply.py | 34 ++++++++ azazel_core/qos/classifier.py | 41 ++++++++++ azazel_core/scorer.py | 33 ++++++++ azazel_core/state_machine.py | 77 +++++++++++++++++++ azctl/cli.py | 54 +++++++++++++ azctl/daemon.py | 22 ++++++ configs/azazel.schema.json | 54 +++++++++++++ configs/azazel.yaml | 21 +++++ configs/nftables/azazel.nft | 16 ++++ configs/nftables/lockdown.nft | 7 ++ configs/opencanary/opencanary.conf | 19 +++++ configs/suricata/suricata.yaml.tmpl | 16 ++++ configs/tc/classes.htb | 5 ++ configs/vector/vector.toml | 9 +++ docs/API_REFERENCE.md | 51 ++++++++++++ docs/ARCHITECTURE.md | 46 +++++++++++ docs/OPERATIONS.md | 60 +++++++++++++++ .../1_install_raspap.sh | 0 .../2_install_azazel.sh | 0 legacy/README.md | 5 ++ .../installer_old}/1_install_azazel.sh | 0 .../installer_old}/2_setup_containers.sh | 0 .../installer_old}/3_configure_services.sh | 0 .../installer_old}/install_azazel.sh | 0 .../installer_old}/install_set.sh | 0 .../installer_old}/raspap-setup.sh | 0 pytest.ini | 2 + scripts/__init__.py | 1 + scripts/bootstrap_mvp.sh | 29 +++++++ scripts/nft_apply.sh | 11 +++ scripts/pc_monitor.sh | 11 ++- scripts/rollback.sh | 10 +++ scripts/sanity_check.sh | 13 ++++ scripts/suricata_generate.py | 39 ++++++++++ scripts/tc_reset.sh | 7 ++ systemd/azctl.service | 12 +++ systemd/azctl.target | 7 ++ systemd/opencanary.service | 11 +++ systemd/suricata.service | 11 +++ systemd/vector.service | 11 +++ tests/integ/.gitkeep | 0 tests/unit/.gitkeep | 0 tests/unit/test_actions.py | 25 ++++++ tests/unit/test_api.py | 9 +++ tests/unit/test_cli.py | 16 ++++ tests/unit/test_config.py | 15 ++++ tests/unit/test_ingest.py | 19 +++++ tests/unit/test_qos.py | 17 ++++ tests/unit/test_state_machine.py | 21 +++++ tests/unit/test_suricata_generate.py | 7 ++ 71 files changed, 1280 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Makefile create mode 100644 azazel_core/__init__.py create mode 100644 azazel_core/actions/__init__.py create mode 100644 azazel_core/actions/base.py create mode 100644 azazel_core/actions/block.py create mode 100644 azazel_core/actions/delay.py create mode 100644 azazel_core/actions/redirect.py create mode 100644 azazel_core/actions/shape.py create mode 100644 azazel_core/api/__init__.py create mode 100644 azazel_core/api/schemas.py create mode 100644 azazel_core/api/server.py create mode 100644 azazel_core/config.py create mode 100644 azazel_core/ingest/__init__.py create mode 100644 azazel_core/ingest/canary_tail.py create mode 100644 azazel_core/ingest/suricata_tail.py create mode 100644 azazel_core/notify/mattermost.py create mode 100644 azazel_core/qos/__init__.py create mode 100644 azazel_core/qos/apply.py create mode 100644 azazel_core/qos/classifier.py create mode 100644 azazel_core/scorer.py create mode 100644 azazel_core/state_machine.py create mode 100644 azctl/cli.py create mode 100644 azctl/daemon.py create mode 100644 configs/azazel.schema.json create mode 100644 configs/azazel.yaml create mode 100644 configs/nftables/azazel.nft create mode 100644 configs/nftables/lockdown.nft create mode 100644 configs/opencanary/opencanary.conf create mode 100644 configs/suricata/suricata.yaml.tmpl create mode 100644 configs/tc/classes.htb create mode 100644 configs/vector/vector.toml create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/OPERATIONS.md rename 1_install_raspap.sh => legacy/1_install_raspap.sh (100%) mode change 100755 => 100644 rename 2_install_azazel.sh => legacy/2_install_azazel.sh (100%) mode change 100755 => 100644 create mode 100644 legacy/README.md rename {installer_old => legacy/installer_old}/1_install_azazel.sh (100%) mode change 100755 => 100644 rename {installer_old => legacy/installer_old}/2_setup_containers.sh (100%) mode change 100755 => 100644 rename {installer_old => legacy/installer_old}/3_configure_services.sh (100%) mode change 100755 => 100644 rename {installer_old => legacy/installer_old}/install_azazel.sh (100%) rename {installer_old => legacy/installer_old}/install_set.sh (100%) rename {installer_old => legacy/installer_old}/raspap-setup.sh (100%) mode change 100755 => 100644 create mode 100644 pytest.ini create mode 100644 scripts/__init__.py create mode 100755 scripts/bootstrap_mvp.sh create mode 100755 scripts/nft_apply.sh create mode 100755 scripts/rollback.sh create mode 100755 scripts/sanity_check.sh create mode 100755 scripts/suricata_generate.py create mode 100755 scripts/tc_reset.sh create mode 100644 systemd/azctl.service create mode 100644 systemd/azctl.target create mode 100644 systemd/opencanary.service create mode 100644 systemd/suricata.service create mode 100644 systemd/vector.service create mode 100644 tests/integ/.gitkeep create mode 100644 tests/unit/.gitkeep create mode 100644 tests/unit/test_actions.py create mode 100644 tests/unit/test_api.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_ingest.py create mode 100644 tests/unit/test_qos.py create mode 100644 tests/unit/test_state_machine.py create mode 100644 tests/unit/test_suricata_generate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f6cbdf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI +on: [push, pull_request] +jobs: + lint-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install pytest jsonschema pyyaml + - name: Schema validate + run: | + python - <<'PY' + import json + from pathlib import Path + + import jsonschema + import yaml + + schema = json.loads(Path('configs/azazel.schema.json').read_text()) + document = yaml.safe_load(Path('configs/azazel.yaml').read_text()) + jsonschema.validate(document, schema) + print('schema: OK') + PY + - name: Pytest + run: pytest tests/unit -q + - name: Shell lint + run: | + sudo apt-get update && sudo apt-get install -y shellcheck + find scripts -name "*.sh" -print0 | xargs -0 -I{} shellcheck {} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..864d870 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Release +on: + push: + tags: ["v*.*.*"] +jobs: + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build artifact + run: | + mkdir -p dist/azazel-installer + cp -r scripts systemd configs azazel_core azctl docs Makefile dist/azazel-installer/ + tar -C dist -czf dist/azazel-installer-${GITHUB_REF_NAME}.tar.gz azazel-installer + sha256sum dist/azazel-installer-${GITHUB_REF_NAME}.tar.gz > dist/SHA256SUMS + - name: Upload Release + uses: softprops/action-gh-release@v2 + with: + files: | + dist/azazel-installer-${{ github.ref_name }}.tar.gz + dist/SHA256SUMS diff --git a/.gitignore b/.gitignore index 2b9dbde..6a030bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.log -__pycache__/ \ No newline at end of file +__pycache__/ +dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..319ca8f --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: lint test package +lint: + python -m json.tool < configs/azazel.schema.json > /dev/null + shellcheck scripts/*.sh + test -f scripts/bootstrap_mvp.sh + test -f scripts/nft_apply.sh + test -f scripts/rollback.sh + test -f scripts/sanity_check.sh + test -f scripts/tc_reset.sh +test: + pytest tests/unit -q +package: + mkdir -p dist/azazel-installer && \ + cp -r scripts systemd configs azazel_core azctl docs Makefile dist/azazel-installer/ && \ + tar -C dist -czf dist/azazel-installer.tar.gz azazel-installer diff --git a/README.md b/README.md index da5e118..34258be 100644 --- a/README.md +++ b/README.md @@ -163,3 +163,28 @@ As cyber attacks become faster and more automated, traditional honeypots fall sh ## ライセンス / License MIT License + +--- + +## 新しいデプロイフロー / Modern deployment flow + +旧来の `1_install_raspap.sh` と `2_install_azazel.sh` はメンテナンス対象外となり、 +`legacy/` ディレクトリに退避されました。今後はタグ付きリリースに含まれる +インストーラを利用してください。 + +### Install on Raspberry Pi (clean image) +```bash +# 固定タグを使うこと(例: v1.0.0) +TAG=v1.0.0 +curl -fsSL https://github.com/01rabbit/Azazel/releases/download/${TAG}/azazel-installer-${TAG}.tar.gz \ + | tar xz -C /tmp +cd /tmp/azazel-installer && sudo bash scripts/bootstrap_mvp.sh +``` + +ブートストラップ後は `/etc/azazel/azazel.yaml` を編集し、必要に応じて +`docs/OPERATIONS.md` の手順に従って Suricata や OpenCanary を再設定します。 + +### Documentation +- `docs/ARCHITECTURE.md` — コントロールプレーンの構成図と役割 +- `docs/OPERATIONS.md` — タグ付きリリースの取得からローリング更新まで +- `docs/API_REFERENCE.md` — Python モジュールおよびスクリプトの概要 diff --git a/azazel_core/__init__.py b/azazel_core/__init__.py new file mode 100644 index 0000000..1dfd874 --- /dev/null +++ b/azazel_core/__init__.py @@ -0,0 +1,13 @@ +"""Core modules for the Azazel SOC/NOC controller.""" + +from .state_machine import StateMachine, State, Transition +from .scorer import ScoreEvaluator +from .config import AzazelConfig + +__all__ = [ + "StateMachine", + "State", + "Transition", + "ScoreEvaluator", + "AzazelConfig", +] diff --git a/azazel_core/actions/__init__.py b/azazel_core/actions/__init__.py new file mode 100644 index 0000000..e8ceb0b --- /dev/null +++ b/azazel_core/actions/__init__.py @@ -0,0 +1,16 @@ +"""Traffic shaping actions used by the state machine.""" + +from .base import Action, ActionResult +from .delay import DelayAction +from .shape import ShapeAction +from .block import BlockAction +from .redirect import RedirectAction + +__all__ = [ + "Action", + "ActionResult", + "DelayAction", + "ShapeAction", + "BlockAction", + "RedirectAction", +] diff --git a/azazel_core/actions/base.py b/azazel_core/actions/base.py new file mode 100644 index 0000000..2c01326 --- /dev/null +++ b/azazel_core/actions/base.py @@ -0,0 +1,22 @@ +"""Base classes for traffic control actions.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable + + +@dataclass +class ActionResult: + """Represents an idempotent command for network enforcement.""" + + command: str + parameters: Dict[str, str] + + +class Action: + """Interface for all concrete actions.""" + + name: str = "action" + + def plan(self, target: str) -> Iterable[ActionResult]: # pragma: no cover - interface + raise NotImplementedError diff --git a/azazel_core/actions/block.py b/azazel_core/actions/block.py new file mode 100644 index 0000000..110db18 --- /dev/null +++ b/azazel_core/actions/block.py @@ -0,0 +1,20 @@ +"""Blocking action.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from .base import Action, ActionResult + + +@dataclass +class BlockAction(Action): + """Deny traffic for a target.""" + + name: str = "block" + + def plan(self, target: str) -> Iterable[ActionResult]: + yield ActionResult( + command="nft add element", + parameters={"set": "blocked_hosts", "value": target}, + ) diff --git a/azazel_core/actions/delay.py b/azazel_core/actions/delay.py new file mode 100644 index 0000000..6fcf89d --- /dev/null +++ b/azazel_core/actions/delay.py @@ -0,0 +1,21 @@ +"""Traffic delay action.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from .base import Action, ActionResult + + +@dataclass +class DelayAction(Action): + """Introduce latency to traffic.""" + + delay_ms: int + name: str = "delay" + + def plan(self, target: str) -> Iterable[ActionResult]: + yield ActionResult( + command="tc qdisc replace", + parameters={"target": target, "delay": f"{self.delay_ms}ms"}, + ) diff --git a/azazel_core/actions/redirect.py b/azazel_core/actions/redirect.py new file mode 100644 index 0000000..6af3fa5 --- /dev/null +++ b/azazel_core/actions/redirect.py @@ -0,0 +1,25 @@ +"""Traffic redirection action.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from .base import Action, ActionResult + + +@dataclass +class RedirectAction(Action): + """Redirect hostile traffic to the canary network.""" + + target_host: str + name: str = "redirect" + + def plan(self, target: str) -> Iterable[ActionResult]: + yield ActionResult( + command="nft add rule", + parameters={ + "chain": "azazel_redirect", + "match": target, + "redirect": self.target_host, + }, + ) diff --git a/azazel_core/actions/shape.py b/azazel_core/actions/shape.py new file mode 100644 index 0000000..91b3729 --- /dev/null +++ b/azazel_core/actions/shape.py @@ -0,0 +1,21 @@ +"""Traffic shaping action.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from .base import Action, ActionResult + + +@dataclass +class ShapeAction(Action): + """Throttle bandwidth for a target.""" + + rate_kbps: int + name: str = "shape" + + def plan(self, target: str) -> Iterable[ActionResult]: + yield ActionResult( + command="tc class replace", + parameters={"target": target, "rate": f"{self.rate_kbps}kbps"}, + ) diff --git a/azazel_core/api/__init__.py b/azazel_core/api/__init__.py new file mode 100644 index 0000000..cbf280e --- /dev/null +++ b/azazel_core/api/__init__.py @@ -0,0 +1,6 @@ +"""API layer for Azazel.""" + +from .server import APIServer +from .schemas import HealthResponse + +__all__ = ["APIServer", "HealthResponse"] diff --git a/azazel_core/api/schemas.py b/azazel_core/api/schemas.py new file mode 100644 index 0000000..2cb7682 --- /dev/null +++ b/azazel_core/api/schemas.py @@ -0,0 +1,14 @@ +"""Pydantic-free lightweight schemas for API responses.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict + + +@dataclass +class HealthResponse: + status: str + version: str + + def as_dict(self) -> Dict[str, str]: + return {"status": self.status, "version": self.version} diff --git a/azazel_core/api/server.py b/azazel_core/api/server.py new file mode 100644 index 0000000..a2c6945 --- /dev/null +++ b/azazel_core/api/server.py @@ -0,0 +1,25 @@ +"""A minimal API dispatcher for Azazel.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Dict + +from .schemas import HealthResponse + +Handler = Callable[[], Dict[str, str]] + + +@dataclass +class APIServer: + routes: Dict[str, Handler] = field(default_factory=dict) + + def add_health_route(self, version: str) -> None: + def handler() -> Dict[str, str]: + return HealthResponse(status="ok", version=version).as_dict() + + self.routes["/health"] = handler + + def dispatch(self, path: str) -> Dict[str, str]: + if path not in self.routes: + raise KeyError(f"No handler for path {path}") + return self.routes[path]() diff --git a/azazel_core/config.py b/azazel_core/config.py new file mode 100644 index 0000000..faf5536 --- /dev/null +++ b/azazel_core/config.py @@ -0,0 +1,30 @@ +"""Configuration helpers for Azazel.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict + +import yaml + + +@dataclass +class AzazelConfig: + """Represents the high level configuration used by the controller.""" + + raw: Dict[str, Any] + + @classmethod + def from_file(cls, path: str | Path) -> "AzazelConfig": + data = yaml.safe_load(Path(path).read_text()) + if not isinstance(data, dict): + raise ValueError("Configuration root must be a mapping") + return cls(raw=data) + + def get(self, key: str, default: Any = None) -> Any: + return self.raw.get(key, default) + + def require(self, key: str) -> Any: + if key not in self.raw: + raise KeyError(f"Missing configuration key: {key}") + return self.raw[key] diff --git a/azazel_core/ingest/__init__.py b/azazel_core/ingest/__init__.py new file mode 100644 index 0000000..69713b9 --- /dev/null +++ b/azazel_core/ingest/__init__.py @@ -0,0 +1,6 @@ +"""Log ingestion helpers.""" + +from .suricata_tail import SuricataTail +from .canary_tail import CanaryTail + +__all__ = ["SuricataTail", "CanaryTail"] diff --git a/azazel_core/ingest/canary_tail.py b/azazel_core/ingest/canary_tail.py new file mode 100644 index 0000000..3e39128 --- /dev/null +++ b/azazel_core/ingest/canary_tail.py @@ -0,0 +1,26 @@ +"""Tail OpenCanary log files.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Generator, Iterable + +import json + +from ..state_machine import Event + + +@dataclass +class CanaryTail: + path: Path + + def stream(self) -> Generator[Event, None, None]: + for line in self._read_lines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + yield Event(name=record.get("logtype", "canary"), severity=10) + + def _read_lines(self) -> Iterable[str]: + return self.path.read_text().splitlines() diff --git a/azazel_core/ingest/suricata_tail.py b/azazel_core/ingest/suricata_tail.py new file mode 100644 index 0000000..bd71beb --- /dev/null +++ b/azazel_core/ingest/suricata_tail.py @@ -0,0 +1,32 @@ +"""Streaming helper for Suricata EVE logs.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Generator, Iterable + +import json + +from ..state_machine import Event + + +@dataclass +class SuricataTail: + path: Path + + def stream(self) -> Generator[Event, None, None]: + """Yield events from an EVE JSON log.""" + + for line in self._read_lines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + severity = int(record.get("alert", {}).get("severity", 0)) + yield Event( + name=record.get("event_type", "alert"), + severity=severity, + ) + + def _read_lines(self) -> Iterable[str]: + return self.path.read_text().splitlines() diff --git a/azazel_core/notify/mattermost.py b/azazel_core/notify/mattermost.py new file mode 100644 index 0000000..73a3bbf --- /dev/null +++ b/azazel_core/notify/mattermost.py @@ -0,0 +1,29 @@ +"""Simplified Mattermost webhook client.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict + +import json +import urllib.request + + +@dataclass +class MattermostNotifier: + webhook_url: str + + def format_payload(self, message: str, level: str = "info") -> Dict[str, Any]: + return { + "text": message, + "props": {"severity": level}, + } + + def send(self, message: str, level: str = "info") -> None: + data = json.dumps(self.format_payload(message, level)).encode() + request = urllib.request.Request( + self.webhook_url, + data=data, + headers={"Content-Type": "application/json"}, + ) + # Network operations are avoided during tests; errors bubble up. + urllib.request.urlopen(request, timeout=5) diff --git a/azazel_core/qos/__init__.py b/azazel_core/qos/__init__.py new file mode 100644 index 0000000..416e445 --- /dev/null +++ b/azazel_core/qos/__init__.py @@ -0,0 +1,6 @@ +"""QoS helpers for Azazel.""" + +from .classifier import TrafficClassifier +from .apply import QoSPlan + +__all__ = ["TrafficClassifier", "QoSPlan"] diff --git a/azazel_core/qos/apply.py b/azazel_core/qos/apply.py new file mode 100644 index 0000000..4d35299 --- /dev/null +++ b/azazel_core/qos/apply.py @@ -0,0 +1,34 @@ +"""Render QoS classifier results to actionable plans.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable, List + +from ..actions import ActionResult + + +@dataclass +class QoSPlan: + """Container for actions derived from QoS policy.""" + + commands: List[ActionResult] + + @classmethod + def from_matches(cls, matches: Iterable[str]) -> "QoSPlan": + commands: List[ActionResult] = [] + for match in matches: + commands.append( + ActionResult( + command="tc class add", + parameters={"class": match}, + ) + ) + return cls(commands=commands) + + def as_dict(self) -> Dict[str, List[Dict[str, str]]]: + return { + "commands": [ + {"command": result.command, **result.parameters} + for result in self.commands + ] + } diff --git a/azazel_core/qos/classifier.py b/azazel_core/qos/classifier.py new file mode 100644 index 0000000..8f4b3fa --- /dev/null +++ b/azazel_core/qos/classifier.py @@ -0,0 +1,41 @@ +"""Classify network flows into QoS buckets.""" +from __future__ import annotations + +from dataclasses import dataclass +from ipaddress import ip_address, ip_network +from typing import Dict, Iterable, List + + +@dataclass +class QoSBucket: + name: str + cidrs: List[str] + ports: List[int] + + +@dataclass +class TrafficClassifier: + """Very small helper used by unit tests to bucket flows.""" + + buckets: Dict[str, QoSBucket] + + def match(self, source_ip: str, dest_port: int) -> str: + address = ip_address(source_ip) + for bucket in self.buckets.values(): + if any(address in ip_network(cidr) for cidr in bucket.cidrs): + return bucket.name + if dest_port in bucket.ports: + return bucket.name + return "best-effort" + + @classmethod + def from_config(cls, config: Dict[str, Dict[str, Iterable]]) -> "TrafficClassifier": + buckets = { + name: QoSBucket( + name=name, + cidrs=[str(c) for c in cfg.get("dest_cidrs", [])], + ports=[int(p) for p in cfg.get("ports", [])], + ) + for name, cfg in config.items() + } + return cls(buckets=buckets) diff --git a/azazel_core/scorer.py b/azazel_core/scorer.py new file mode 100644 index 0000000..f2c977e --- /dev/null +++ b/azazel_core/scorer.py @@ -0,0 +1,33 @@ +"""Threat scoring utilities for Azazel.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from .state_machine import Event + + +@dataclass +class ScoreEvaluator: + """Aggregate scores from multiple events.""" + + baseline: int = 0 + + def evaluate(self, events: Iterable[Event]) -> int: + """Compute a cumulative severity score.""" + + score = self.baseline + for event in events: + score += max(event.severity, 0) + return score + + def classify(self, score: int) -> str: + """Return a textual classification for a score.""" + + if score >= 80: + return "critical" + if score >= 50: + return "elevated" + if score >= 20: + return "guarded" + return "normal" diff --git a/azazel_core/state_machine.py b/azazel_core/state_machine.py new file mode 100644 index 0000000..1925fcc --- /dev/null +++ b/azazel_core/state_machine.py @@ -0,0 +1,77 @@ +"""Light-weight state machine driving Azazel defensive posture changes.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional + + +@dataclass(frozen=True) +class State: + """Represents a named state of the defensive system.""" + + name: str + description: str = "" + + +@dataclass(frozen=True) +class Event: + """An external event that may trigger a transition.""" + + name: str + severity: int = 0 + + +@dataclass +class Transition: + """Transition from one state to another triggered by an event.""" + + source: State + target: State + condition: Callable[[Event], bool] + action: Optional[Callable[[State, State, Event], None]] = None + + +@dataclass +class StateMachine: + """Simple but testable state machine implementation.""" + + initial_state: State + transitions: List[Transition] = field(default_factory=list) + current_state: State = field(init=False) + + def __post_init__(self) -> None: + self.current_state = self.initial_state + self._transition_map: Dict[str, List[Transition]] = {} + for transition in self.transitions: + self.add_transition(transition) + + def add_transition(self, transition: Transition) -> None: + """Register a new transition.""" + + bucket = self._transition_map.setdefault(transition.source.name, []) + bucket.append(transition) + + def dispatch(self, event: Event) -> State: + """Process an event and advance the state machine if applicable.""" + + for transition in self._transition_map.get(self.current_state.name, []): + if transition.condition(event): + previous = self.current_state + self.current_state = transition.target + if transition.action: + transition.action(previous, self.current_state, event) + return self.current_state + return self.current_state + + def reset(self) -> None: + """Reset the state machine to its initial state.""" + + self.current_state = self.initial_state + + def summary(self) -> Dict[str, str]: + """Return a serializable summary of the state machine.""" + + return { + "state": self.current_state.name, + "description": self.current_state.description, + } diff --git a/azctl/cli.py b/azctl/cli.py new file mode 100644 index 0000000..9309c66 --- /dev/null +++ b/azctl/cli.py @@ -0,0 +1,54 @@ +"""Tiny CLI facade for the Azazel daemon.""" +from __future__ import annotations + +import argparse +from typing import Iterable + +from azazel_core import AzazelConfig, ScoreEvaluator, State, StateMachine +from azazel_core.state_machine import Event, Transition + +from .daemon import AzazelDaemon + + +def build_machine() -> StateMachine: + idle = State(name="idle", description="Nominal operations") + shield = State(name="shield", description="Heightened monitoring") + + machine = StateMachine(initial_state=idle) + machine.add_transition( + Transition( + source=idle, + target=shield, + condition=lambda event: event.name == "escalate", + ) + ) + machine.add_transition( + Transition( + source=shield, + target=idle, + condition=lambda event: event.name == "recover", + ) + ) + return machine + + +def load_events(path: str) -> Iterable[Event]: + config = AzazelConfig.from_file(path) + events = config.get("events", []) + for item in events: + yield Event(name=item.get("name", "escalate"), severity=int(item.get("severity", 0))) + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Azazel control CLI") + parser.add_argument("--config", required=True, help="Path to configuration YAML") + args = parser.parse_args(list(argv) if argv is not None else None) + + machine = build_machine() + daemon = AzazelDaemon(machine=machine, scorer=ScoreEvaluator()) + daemon.process_events(load_events(args.config)) + return 0 + + +if __name__ == "__main__": # pragma: no cover - CLI entry point + raise SystemExit(main()) diff --git a/azctl/daemon.py b/azctl/daemon.py new file mode 100644 index 0000000..0269210 --- /dev/null +++ b/azctl/daemon.py @@ -0,0 +1,22 @@ +"""Runtime daemon glue for Azazel.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from azazel_core import ScoreEvaluator, StateMachine +from azazel_core.state_machine import Event + + +@dataclass +class AzazelDaemon: + machine: StateMachine + scorer: ScoreEvaluator + + def process_events(self, events: Iterable[Event]) -> None: + score = self.scorer.evaluate(events) + classification = self.scorer.classify(score) + if classification in {"elevated", "critical"}: + self.machine.dispatch(Event(name="escalate", severity=score)) + else: + self.machine.dispatch(Event(name="recover", severity=0)) diff --git a/configs/azazel.schema.json b/configs/azazel.schema.json new file mode 100644 index 0000000..c2e591a --- /dev/null +++ b/configs/azazel.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "node", + "interfaces", + "profiles", + "qos", + "soc", + "thresholds", + "notify", + "storage", + "privacy" + ], + "properties": { + "node": {"type": "string"}, + "interfaces": { + "type": "object", + "required": ["lan", "wan"], + "properties": { + "lan": {"type": "string"}, + "wan": {"type": "string"} + } + }, + "profiles": { + "type": "object", + "required": ["active", "sat", "lte", "fiber"] + }, + "qos": { + "type": "object", + "required": ["medical", "ops", "public"] + }, + "soc": { + "type": "object", + "required": ["suricata_ruleset", "canary_services"] + }, + "thresholds": { + "type": "object", + "required": ["t1_shield", "t2_lockdown", "unlock_wait_secs"] + }, + "notify": { + "type": "object", + "required": ["level"] + }, + "storage": { + "type": "object", + "required": ["log_dir", "retain_days"] + }, + "privacy": { + "type": "object", + "required": ["pii_minimize"] + } + } +} diff --git a/configs/azazel.yaml b/configs/azazel.yaml new file mode 100644 index 0000000..86ed260 --- /dev/null +++ b/configs/azazel.yaml @@ -0,0 +1,21 @@ +node: azazel-pi-01 +interfaces: { lan: lan0, wan: wan0 } +profiles: + active: lte + sat: { uplink_kbps: 2000, rtt_ms: 700 } + lte: { uplink_kbps: 5000, rtt_ms: 80 } + fiber: { uplink_kbps: 50000, rtt_ms: 10 } +qos: + medical: { dest_fqdns: ["emis.example.org"], dest_cidrs: ["203.0.113.0/24"] } + ops: { ports: [22,443,853] } + public: {} +soc: + suricata_ruleset: balanced + canary_services: ["ssh", "http", "pgsql"] +thresholds: + t1_shield: 50 + t2_lockdown: 80 + unlock_wait_secs: { shield: 600, portal: 1800 } +notify: { level: warn } +storage: { log_dir: "/var/log/azazel", retain_days: 14 } +privacy: { pii_minimize: true, hash_fields: ["src.ip", "dst.ip", "username"] } diff --git a/configs/nftables/azazel.nft b/configs/nftables/azazel.nft new file mode 100644 index 0000000..48921ab --- /dev/null +++ b/configs/nftables/azazel.nft @@ -0,0 +1,16 @@ +table inet azazel { + set blocked_hosts { + type ipv4_addr + flags timeout + timeout 1h + } + + chain input { + type filter hook input priority 0; + ip saddr @blocked_hosts drop + } + + chain azazel_redirect { + type nat hook prerouting priority 0; + } +} diff --git a/configs/nftables/lockdown.nft b/configs/nftables/lockdown.nft new file mode 100644 index 0000000..7cf4370 --- /dev/null +++ b/configs/nftables/lockdown.nft @@ -0,0 +1,7 @@ +table inet azazel_lockdown { + chain input { + type filter hook input priority 0; + policy drop; + ip saddr 192.168.0.0/16 accept + } +} diff --git a/configs/opencanary/opencanary.conf b/configs/opencanary/opencanary.conf new file mode 100644 index 0000000..4142641 --- /dev/null +++ b/configs/opencanary/opencanary.conf @@ -0,0 +1,19 @@ +{ + "device.node_id": "azazel-canary", + "listeners": { + "ssh": {"enabled": true}, + "http": {"enabled": true}, + "pgsql": {"enabled": true} + }, + "logger": { + "class": "PyLogger", + "kwargs": { + "handlers": { + "console": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout" + } + } + } + } +} diff --git a/configs/suricata/suricata.yaml.tmpl b/configs/suricata/suricata.yaml.tmpl new file mode 100644 index 0000000..984d136 --- /dev/null +++ b/configs/suricata/suricata.yaml.tmpl @@ -0,0 +1,16 @@ +%YAML 1.1 +--- +vars: + address-groups: + HOME_NET: "[192.168.0.0/16]" + port-groups: + HTTP_PORTS: "80" + +default-log-dir: /var/log/suricata +detection-engine: + - ruleset: {{ ruleset | default('balanced') }} +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json diff --git a/configs/tc/classes.htb b/configs/tc/classes.htb new file mode 100644 index 0000000..95a70db --- /dev/null +++ b/configs/tc/classes.htb @@ -0,0 +1,5 @@ +# HTB classes for Azazel QoS +class htb 1:1 root rate 50mbit ceil 50mbit +class htb 1:10 parent 1:1 rate 1mbit ceil 5mbit prio 0 +class htb 1:20 parent 1:1 rate 512kbit ceil 2mbit prio 1 +class htb 1:30 parent 1:1 rate 256kbit ceil 1mbit prio 2 diff --git a/configs/vector/vector.toml b/configs/vector/vector.toml new file mode 100644 index 0000000..28f71fc --- /dev/null +++ b/configs/vector/vector.toml @@ -0,0 +1,9 @@ +[sources.azazel] +type = "file" +include = ["/var/log/azazel/*.log"] +ignore_older_secs = 86400 + +[sinks.console] +type = "console" +inputs = ["azazel"] +encoding.codec = "json" diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..a5f6e83 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,51 @@ +# API reference + +This reference documents the Python modules that make up the Azazel control +plane. The intent is to provide enough context for operators to extend or mock +the behaviour during testing. + +## `azazel_core.state_machine` + +- `State(name: str, description: str = "")` +- `Event(name: str, severity: int = 0)` +- `Transition(source, target, condition, action=None)` +- `StateMachine(initial_state)` provides: + - `add_transition(transition)` – register a new transition. + - `dispatch(event)` – evaluate transitions from the current state. + - `reset()` – return to the initial state. + - `summary()` – dictionary suitable for API responses. + +## `azazel_core.scorer` + +`ScoreEvaluator` computes cumulative severity and provides `classify(score)` +which returns `normal`, `guarded`, `elevated`, or `critical`. + +## `azazel_core.actions` + +`DelayAction`, `ShapeAction`, `BlockAction`, and `RedirectAction` derive from the +common `Action` interface and expose `plan(target)` iterators. Each yields +`ActionResult` objects that describe tc/nftables commands without executing +side-effects. + +## `azazel_core.ingest` + +`SuricataTail` and `CanaryTail` read JSON logs from disk and emit `Event` +instances. They are intentionally deterministic, easing unit test coverage. + +## `azazel_core.api` + +`APIServer` is a minimal dispatcher used by future HTTP front-ends. The bundled +handler `add_health_route(version)` returns a `HealthResponse` dataclass. + +## `azctl.cli` + +`build_machine()` wires the idle and shield states. `load_events(path)` loads +YAML describing synthetic events. `main(argv)` powers the systemd service by +feeding events into `AzazelDaemon`, which applies score-based decisions. + +## Scripts + +- `scripts/suricata_generate.py` renders the Suricata YAML template. +- `scripts/nft_apply.sh` and `scripts/tc_reset.sh` manage enforcement tools. +- `scripts/sanity_check.sh` prints warnings if dependent services are inactive. +- `scripts/rollback.sh` removes installed assets. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..fe32dcb --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,46 @@ +# Azazel Architecture + +Azazel packages the SOC/NOC control plane into a self-contained repository. The +solution is designed so a clean Raspberry Pi image can pull a tagged release and +become operational without ad-hoc configuration. + +## Core services + +| Component | Purpose | +|-----------|---------| +| `azazel_core/state_machine.py` | Governs transitions between posture states. | +| `azazel_core/actions/` | Models tc/nftables operations as idempotent plans. | +| `azazel_core/ingest/` | Parses Suricata EVE logs and OpenCanary events. | +| `azazel_core/qos/` | Maps profiles to QoS enforcement classes. | +| `azctl/` | Thin CLI/daemon interface used by systemd. | +| `configs/` | Declarative configuration set including schema validation. | +| `scripts/bootstrap_mvp.sh` | Installer that stages the runtime on target nodes. | +| `systemd/` | Units and targets that compose the Azazel service stack. | + +## State machine overview + +The state machine promotes or demotes the defensive posture based on the score +calculated from incoming alerts. Three stages are modelled: + +1. **Idle** – default, minimal restrictions. +2. **Shield** – elevated monitoring, tc shaping applied. +3. **Lockdown** – optional stage triggered by high scores where nftables rules + restrict ingress to trusted ranges. + +The scoring logic lives in `azazel_core/scorer.py` and is exercised by the unit +tests under `tests/unit`. + +## Configuration + +All runtime parameters are stored inside `configs/azazel.yaml`. A JSON Schema is +published in `configs/azazel.schema.json` and enforced in CI. Vendor +applications—Suricata, Vector, OpenCanary, nftables and tc—are provided with +opinionated defaults that can be adapted per deployment. + +## Packaging goal + +`bootstrap_mvp.sh` installs Azazel onto `/opt/azazel` and copies configuration +and systemd units into place. The repository layout mirrors the staged +filesystem, ensuring releases are reproducible. Tagging a commit triggers the +release workflow that builds `azazel-installer-.tar.gz` containing the +entire payload required for air-gapped installs. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..8d4b2f4 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,60 @@ +# Operations guide + +This document captures the procedures for staging, operating, and maintaining +Azazel in the field. The workflow assumes deployment on Raspberry Pi OS but can +be adapted to other Debian derivatives. + +## 1. Acquire a release + +Pick a signed Git tag (for example `v1.0.0`) and download the installer bundle: + +```bash +TAG=v1.0.0 +curl -fsSL https://github.com/01rabbit/Azazel/releases/download/${TAG}/azazel-installer-${TAG}.tar.gz \ + | tar xz -C /tmp +``` + +The archive includes configuration templates, scripts, and systemd units. + +## 2. Bootstrap the node + +Run the installer on the target host: + +```bash +cd /tmp/azazel-installer +sudo bash scripts/bootstrap_mvp.sh +``` + +The script copies the repository payload to `/opt/azazel`, pushes configuration +into `/etc/azazel`, installs systemd units, and enables the aggregate +`azctl.target`. + +## 3. Configure services + +1. Adjust `/etc/azazel/azazel.yaml` to reflect interface names, QoS policies, and + alert thresholds. +2. Regenerate the Suricata configuration if a non-default ruleset is required: + + ```bash + sudo scripts/suricata_generate.py \ + /etc/azazel/azazel.yaml \ + /etc/azazel/suricata/suricata.yaml.tmpl \ + --output /etc/suricata/suricata.yaml + ``` +3. Reload services: `sudo systemctl restart azctl.target`. + +## 4. Health checks + +Use `scripts/sanity_check.sh` to confirm Suricata, Vector, and OpenCanary are +enabled and running. Systemd journal entries from the `azctl` service expose +state transitions and scoring decisions. + +## 5. Rollback + +To remove Azazel from a host, execute `sudo /opt/azazel/rollback.sh`. The script +deletes `/opt/azazel`, removes `/etc/azazel`, and disables the `azctl.target`. + +## 6. Legacy installers + +Historical provisioning scripts are stored under `legacy/` and are not supported +for new deployments. diff --git a/1_install_raspap.sh b/legacy/1_install_raspap.sh old mode 100755 new mode 100644 similarity index 100% rename from 1_install_raspap.sh rename to legacy/1_install_raspap.sh diff --git a/2_install_azazel.sh b/legacy/2_install_azazel.sh old mode 100755 new mode 100644 similarity index 100% rename from 2_install_azazel.sh rename to legacy/2_install_azazel.sh diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..e947d93 --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,5 @@ +# Legacy installers + +The scripts in this directory are retained for reference only and are not +maintained. Use `scripts/bootstrap_mvp.sh` from the repository root for all new +installations. diff --git a/installer_old/1_install_azazel.sh b/legacy/installer_old/1_install_azazel.sh old mode 100755 new mode 100644 similarity index 100% rename from installer_old/1_install_azazel.sh rename to legacy/installer_old/1_install_azazel.sh diff --git a/installer_old/2_setup_containers.sh b/legacy/installer_old/2_setup_containers.sh old mode 100755 new mode 100644 similarity index 100% rename from installer_old/2_setup_containers.sh rename to legacy/installer_old/2_setup_containers.sh diff --git a/installer_old/3_configure_services.sh b/legacy/installer_old/3_configure_services.sh old mode 100755 new mode 100644 similarity index 100% rename from installer_old/3_configure_services.sh rename to legacy/installer_old/3_configure_services.sh diff --git a/installer_old/install_azazel.sh b/legacy/installer_old/install_azazel.sh similarity index 100% rename from installer_old/install_azazel.sh rename to legacy/installer_old/install_azazel.sh diff --git a/installer_old/install_set.sh b/legacy/installer_old/install_set.sh similarity index 100% rename from installer_old/install_set.sh rename to legacy/installer_old/install_set.sh diff --git a/installer_old/raspap-setup.sh b/legacy/installer_old/raspap-setup.sh old mode 100755 new mode 100644 similarity index 100% rename from installer_old/raspap-setup.sh rename to legacy/installer_old/raspap-setup.sh diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..275da95 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Helper utilities for Azazel scripts.""" diff --git a/scripts/bootstrap_mvp.sh b/scripts/bootstrap_mvp.sh new file mode 100755 index 0000000..d8de7f9 --- /dev/null +++ b/scripts/bootstrap_mvp.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID:-$(id -u)} -ne 0 ]]; then + echo "[azazel] bootstrap requires root" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TARGET_ROOT="/opt/azazel" +CONFIG_ROOT="/etc/azazel" + +mkdir -p "$TARGET_ROOT" "$CONFIG_ROOT" +rsync -a --delete "$REPO_ROOT/azazel_core" "$REPO_ROOT/azctl" "$TARGET_ROOT/" +rsync -a "$REPO_ROOT/configs/" "$CONFIG_ROOT/" +rsync -a "$REPO_ROOT/systemd/" /etc/systemd/system/ + +install -m 755 "$REPO_ROOT/scripts/nft_apply.sh" "$TARGET_ROOT/nft_apply.sh" +install -m 755 "$REPO_ROOT/scripts/tc_reset.sh" "$TARGET_ROOT/tc_reset.sh" +install -m 755 "$REPO_ROOT/scripts/sanity_check.sh" "$TARGET_ROOT/sanity_check.sh" +install -m 755 "$REPO_ROOT/scripts/rollback.sh" "$TARGET_ROOT/rollback.sh" + +systemctl daemon-reload +systemctl enable azctl.target + +cat <&2 + exit 1 +fi + +nft -f "$RULESET" diff --git a/scripts/pc_monitor.sh b/scripts/pc_monitor.sh index eab074b..fc749fd 100644 --- a/scripts/pc_monitor.sh +++ b/scripts/pc_monitor.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash +set -euo pipefail + PC_IP="192.168.40.82" -COMPOSE="docker compose -f /opt/azazel/docker-compose.yml" +COMPOSE=(docker compose -f /opt/azazel/docker-compose.yml) -if ping -c1 -W1 $MAC_IP >/dev/null 2>&1; then - $COMPOSE up -d vector # Vectorを確実に起動 +if ping -c1 -W1 "$PC_IP" >/dev/null 2>&1; then + "${COMPOSE[@]}" up -d vector +else + "${COMPOSE[@]}" stop vector +fi diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 0000000..4fe4faa --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_ROOT="/opt/azazel" +CONFIG_ROOT="/etc/azazel" + +rm -rf "$TARGET_ROOT" +rm -rf "$CONFIG_ROOT" + +systemctl disable --now azctl.target || true diff --git a/scripts/sanity_check.sh b/scripts/sanity_check.sh new file mode 100755 index 0000000..3d8235d --- /dev/null +++ b/scripts/sanity_check.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +REQUIRED=(suricata vector opencanary) + +for svc in "${REQUIRED[@]}"; do + if ! systemctl is-enabled --quiet "$svc"; then + echo "[azazel] warning: service $svc is not enabled" >&2 + fi + if ! systemctl is-active --quiet "$svc"; then + echo "[azazel] warning: service $svc is not running" >&2 + fi +done diff --git a/scripts/suricata_generate.py b/scripts/suricata_generate.py new file mode 100755 index 0000000..a04596c --- /dev/null +++ b/scripts/suricata_generate.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Render the Suricata configuration template from azazel.yaml.""" +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +import yaml + + +PLACEHOLDER = re.compile(r"\{\{\s*ruleset\s*\|\s*default\('balanced'\)\s*\}\}") + + +def render(template: str, ruleset: str) -> str: + return PLACEHOLDER.sub(ruleset, template) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("config", help="Path to azazel.yaml") + parser.add_argument("template", help="Path to Suricata template") + parser.add_argument("--output", default="-", help="Output file or '-' for stdout") + args = parser.parse_args(argv) + + cfg = yaml.safe_load(Path(args.config).read_text()) + ruleset = cfg.get("soc", {}).get("suricata_ruleset", "balanced") + template = Path(args.template).read_text() + rendered = render(template, ruleset) + + if args.output == "-": + print(rendered) + else: + Path(args.output).write_text(rendered) + return 0 + + +if __name__ == "__main__": # pragma: no cover - CLI entry point + raise SystemExit(main()) diff --git a/scripts/tc_reset.sh b/scripts/tc_reset.sh new file mode 100755 index 0000000..edc4444 --- /dev/null +++ b/scripts/tc_reset.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +IFACE=${1:-wan0} + +tc qdisc del dev "$IFACE" root 2>/dev/null || true +tc qdisc add dev "$IFACE" root handle 1: htb default 30 diff --git a/systemd/azctl.service b/systemd/azctl.service new file mode 100644 index 0000000..10b5e7e --- /dev/null +++ b/systemd/azctl.service @@ -0,0 +1,12 @@ +[Unit] +Description=Azazel control daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/env python3 -m azctl.cli --config /etc/azazel/azazel.yaml +Restart=on-failure + +[Install] +WantedBy=azctl.target diff --git a/systemd/azctl.target b/systemd/azctl.target new file mode 100644 index 0000000..273e29f --- /dev/null +++ b/systemd/azctl.target @@ -0,0 +1,7 @@ +[Unit] +Description=Azazel service stack +Requires=suricata.service vector.service opencanary.service +After=suricata.service vector.service opencanary.service + +[Install] +WantedBy=multi-user.target diff --git a/systemd/opencanary.service b/systemd/opencanary.service new file mode 100644 index 0000000..c75bb5c --- /dev/null +++ b/systemd/opencanary.service @@ -0,0 +1,11 @@ +[Unit] +Description=OpenCanary honeypot +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/opencanaryd --config=/etc/azazel/opencanary/opencanary.conf +Restart=on-failure + +[Install] +WantedBy=azctl.target diff --git a/systemd/suricata.service b/systemd/suricata.service new file mode 100644 index 0000000..bf0836e --- /dev/null +++ b/systemd/suricata.service @@ -0,0 +1,11 @@ +[Unit] +Description=Suricata IDS +After=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/suricata -c /etc/suricata/suricata.yaml -i wan0 +Restart=on-failure + +[Install] +WantedBy=azctl.target diff --git a/systemd/vector.service b/systemd/vector.service new file mode 100644 index 0000000..8634c63 --- /dev/null +++ b/systemd/vector.service @@ -0,0 +1,11 @@ +[Unit] +Description=Vector log shipper +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/vector --config /etc/azazel/vector/vector.toml +Restart=on-failure + +[Install] +WantedBy=azctl.target diff --git a/tests/integ/.gitkeep b/tests/integ/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py new file mode 100644 index 0000000..e21ddb6 --- /dev/null +++ b/tests/unit/test_actions.py @@ -0,0 +1,25 @@ +from azazel_core.actions import BlockAction, DelayAction, RedirectAction, ShapeAction + + +def test_delay_action_plan(): + action = DelayAction(delay_ms=150) + plan = list(action.plan("192.0.2.10")) + assert plan[0].parameters["delay"] == "150ms" + + +def test_block_action_plan(): + action = BlockAction() + plan = list(action.plan("198.51.100.2")) + assert plan[0].parameters["value"] == "198.51.100.2" + + +def test_redirect_action_plan(): + action = RedirectAction(target_host="192.0.2.1") + plan = list(action.plan("203.0.113.50")) + assert plan[0].parameters["redirect"] == "192.0.2.1" + + +def test_shape_action_plan(): + action = ShapeAction(rate_kbps=512) + plan = list(action.plan("wan0")) + assert plan[0].parameters["rate"] == "512kbps" diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000..0871cbf --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,9 @@ +from azazel_core.api import APIServer + + +def test_api_health_route(): + server = APIServer() + server.add_health_route(version="1.2.3") + payload = server.dispatch("/health") + assert payload["status"] == "ok" + assert payload["version"] == "1.2.3" diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..d3027b9 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,16 @@ +from pathlib import Path + +import yaml + +from azctl import cli + + +def test_cli_build_machine(tmp_path: Path): + data = {"events": [{"name": "escalate", "severity": 100}]} + config = tmp_path / "events.yaml" + config.write_text(yaml.safe_dump(data)) + + machine = cli.build_machine() + daemon = cli.AzazelDaemon(machine=machine, scorer=cli.ScoreEvaluator()) + daemon.process_events(cli.load_events(str(config))) + assert machine.current_state.name == "shield" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..885e70d --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,15 @@ +from pathlib import Path + +import yaml + +from azazel_core.config import AzazelConfig + + +def test_config_from_file(tmp_path: Path): + data = {"node": "azazel"} + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump(data)) + + cfg = AzazelConfig.from_file(path) + assert cfg.require("node") == "azazel" + assert cfg.get("missing", "default") == "default" diff --git a/tests/unit/test_ingest.py b/tests/unit/test_ingest.py new file mode 100644 index 0000000..7d2238f --- /dev/null +++ b/tests/unit/test_ingest.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from azazel_core.ingest import CanaryTail, SuricataTail + + +def test_suricata_tail(tmp_path: Path): + path = tmp_path / "eve.json" + path.write_text('{"event_type": "alert", "alert": {"severity": 2}}\n') + tail = SuricataTail(path=path) + events = list(tail.stream()) + assert events[0].severity == 2 + + +def test_canary_tail(tmp_path: Path): + path = tmp_path / "canary.log" + path.write_text('{"logtype": "login"}\n') + tail = CanaryTail(path=path) + events = list(tail.stream()) + assert events[0].name == "login" diff --git a/tests/unit/test_qos.py b/tests/unit/test_qos.py new file mode 100644 index 0000000..977c2f2 --- /dev/null +++ b/tests/unit/test_qos.py @@ -0,0 +1,17 @@ +from azazel_core.qos import QoSPlan, TrafficClassifier + + +def test_classifier_and_plan(): + classifier = TrafficClassifier.from_config( + { + "medical": {"dest_cidrs": ["203.0.113.0/24"]}, + "ops": {"ports": [22]}, + } + ) + bucket = classifier.match("203.0.113.10", 80) + assert bucket == "medical" + + plan = QoSPlan.from_matches([bucket, classifier.match("198.51.100.5", 22)]) + commands = plan.as_dict()["commands"] + assert commands[0]["class"] == "medical" + assert commands[1]["class"] == "ops" diff --git a/tests/unit/test_state_machine.py b/tests/unit/test_state_machine.py new file mode 100644 index 0000000..2a50d1b --- /dev/null +++ b/tests/unit/test_state_machine.py @@ -0,0 +1,21 @@ +from azazel_core.state_machine import Event, State, StateMachine, Transition + + +def test_state_machine_transitions(): + idle = State(name="idle") + shield = State(name="shield") + machine = StateMachine(initial_state=idle) + machine.add_transition( + Transition(source=idle, target=shield, condition=lambda event: event.name == "escalate") + ) + machine.add_transition( + Transition(source=shield, target=idle, condition=lambda event: event.name == "recover") + ) + + assert machine.current_state == idle + machine.dispatch(Event(name="noop")) + assert machine.current_state == idle + machine.dispatch(Event(name="escalate")) + assert machine.current_state == shield + machine.dispatch(Event(name="recover")) + assert machine.current_state == idle diff --git a/tests/unit/test_suricata_generate.py b/tests/unit/test_suricata_generate.py new file mode 100644 index 0000000..248f77c --- /dev/null +++ b/tests/unit/test_suricata_generate.py @@ -0,0 +1,7 @@ +from scripts import suricata_generate + + +def test_render_substitutes_ruleset(): + template = "ruleset: {{ ruleset | default('balanced') }}" + rendered = suricata_generate.render(template, "max-performance") + assert rendered == "ruleset: max-performance"