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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 {}
21 changes: 21 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
*.log
__pycache__/
__pycache__/
dist/
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 モジュールおよびスクリプトの概要
13 changes: 13 additions & 0 deletions azazel_core/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
16 changes: 16 additions & 0 deletions azazel_core/actions/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
22 changes: 22 additions & 0 deletions azazel_core/actions/base.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions azazel_core/actions/block.py
Original file line number Diff line number Diff line change
@@ -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},
)
21 changes: 21 additions & 0 deletions azazel_core/actions/delay.py
Original file line number Diff line number Diff line change
@@ -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"},
)
25 changes: 25 additions & 0 deletions azazel_core/actions/redirect.py
Original file line number Diff line number Diff line change
@@ -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,
},
)
21 changes: 21 additions & 0 deletions azazel_core/actions/shape.py
Original file line number Diff line number Diff line change
@@ -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"},
)
6 changes: 6 additions & 0 deletions azazel_core/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""API layer for Azazel."""

from .server import APIServer
from .schemas import HealthResponse

__all__ = ["APIServer", "HealthResponse"]
14 changes: 14 additions & 0 deletions azazel_core/api/schemas.py
Original file line number Diff line number Diff line change
@@ -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}
25 changes: 25 additions & 0 deletions azazel_core/api/server.py
Original file line number Diff line number Diff line change
@@ -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]()
30 changes: 30 additions & 0 deletions azazel_core/config.py
Original file line number Diff line number Diff line change
@@ -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]
6 changes: 6 additions & 0 deletions azazel_core/ingest/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Log ingestion helpers."""

from .suricata_tail import SuricataTail
from .canary_tail import CanaryTail

__all__ = ["SuricataTail", "CanaryTail"]
26 changes: 26 additions & 0 deletions azazel_core/ingest/canary_tail.py
Original file line number Diff line number Diff line change
@@ -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()
32 changes: 32 additions & 0 deletions azazel_core/ingest/suricata_tail.py
Original file line number Diff line number Diff line change
@@ -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()
Loading