diff --git a/.gitignore b/.gitignore index e4ecfdd..ca5498e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ deploy/.env # OS/editor noise .DS_Store *.swp +old/ \ No newline at end of file diff --git a/README.md b/README.md index 8874aaf..439acbf 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ Thus, Azazel realizes the concept that "defense is not merely protection, but co - **Suricata IDS/IPS**: Intrusion detection and prevention system - **OpenCanary**: Honeypot services for attacker misdirection -- **Dynamic Traffic Control**: `tc` and `iptables/nftables` for tactical delay - - DNAT enforcement now attempts nftables first and transparently falls back to legacy iptables rules so previously supported routing tricks remain available. +- **Dynamic Traffic Control**: `tc` and `iptables` for tactical delay + - DNAT enforcement uses iptables NAT rules for transparent traffic redirection to honeypot services. #### Defensive Modes @@ -79,7 +79,7 @@ Thus, Azazel realizes the concept that "defense is not merely protection, but co | Component | Purpose | |-----------|---------| | `azazel_pi/core/state_machine.py` | Governs transitions between defensive postures | -| `azazel_pi/core/actions/` | Models tc/nftables operations as idempotent plans | +| `azazel_pi/core/actions/` | Models tc/iptables operations as idempotent plans | | `azazel_pi/core/ingest/` | Parses Suricata EVE logs and OpenCanary events | | `azazel_pi/core/display/` | E-Paper status visualization and rendering | | `azctl/` | Command-line interface, daemon management, and interactive TUI menu | @@ -126,7 +126,7 @@ Lightweight configuration optimized for Raspberry Pi, enabling rapid deployment - **IDS/IPS**: Suricata with custom rule sets - **Honeypot**: OpenCanary for service deception - **Log Processing**: Vector for centralized log collection -- **Traffic Control**: `tc` (Traffic Control) + `iptables/nftables` +- **Traffic Control**: `tc` (Traffic Control) + `iptables` - **Alerting**: Mattermost integration - **Display**: Waveshare E-Paper with Python rendering - **Languages**: Python 3.8+ with asyncio, rich, and interactive TUI libraries diff --git a/README_ja.md b/README_ja.md index 71cf4dd..4988d32 100644 --- a/README_ja.md +++ b/README_ja.md @@ -56,7 +56,7 @@ #### リアルタイム脅威検知・対応 - **Suricata IDS/IPS**: 侵入検知・防止システム - **OpenCanary**: 攻撃者誤誘導のためのハニーポットサービス -- **動的トラフィック制御**: 戦術的遅延のための `tc` と `iptables/nftables` +- **動的トラフィック制御**: 戦術的遅延のための `tc` と `iptables` #### 防御モード - **Portalモード**(緑): 最小限の制限での通常運用 @@ -73,7 +73,7 @@ | コンポーネント | 目的 | |---------------|------| | `azazel_pi/core/state_machine.py` | 防御姿勢間の遷移を管理 | -| `azazel_pi/core/actions/` | tc/nftables操作を冪等プランとしてモデル化 | +| `azazel_pi/core/actions/` | tc/iptables操作をべき等プランとしてモデル化 | | `azazel_pi/core/ingest/` | Suricata EVEログとOpenCanaryイベントを解析 | | `azazel_pi/core/display/` | E-Paperステータス可視化とレンダリング | | `azctl/` | コマンドラインインターフェースとデーモン管理 | @@ -116,7 +116,7 @@ Raspberry Piに最適化された軽量設定により、災害復旧、フィ - **IDS/IPS**: カスタムルールセット付きSuricata - **ハニーポット**: サービス欺瞞のためのOpenCanary - **ログ処理**: 集約ログ収集のためのVector -- **トラフィック制御**: `tc`(Traffic Control)+ `iptables/nftables` +- **トラフィック制御**: `tc`(Traffic Control)+ `iptables` - **アラート**: Mattermost統合 - **ディスプレイ**: Pythonレンダリング付きWaveshare E-Paper - **言語**: asyncioとrichライブラリ付きPython 3.8+ diff --git a/azazel_pi/core/async_ai.py b/azazel_pi/core/async_ai.py index e1cd2ed..022f057 100644 --- a/azazel_pi/core/async_ai.py +++ b/azazel_pi/core/async_ai.py @@ -168,6 +168,11 @@ def enqueue(alert: Dict[str, Any], context: Optional[Dict[str, Any]] = None) -> """ start() ctx = context or {} + src_ip = str(alert.get("src_ip") or "") + # IPv6フィルタ: src_ipが":"を含む場合は無視 + if ":" in src_ip: + logger.info(f"Skipping IPv6 event for src_ip={src_ip}") + return # sampling / rate limiting try: allow = _allow_enqueue() diff --git a/azazel_pi/core/enforcer/traffic_control.py b/azazel_pi/core/enforcer/traffic_control.py index 929c33d..f1ffe35 100644 --- a/azazel_pi/core/enforcer/traffic_control.py +++ b/azazel_pi/core/enforcer/traffic_control.py @@ -18,7 +18,7 @@ # 統合システムでは actions モジュールは使用しない(直接tc/nftコマンド実行) from ...utils.delay_action import ( - load_opencanary_ip, OPENCANARY_IP + load_opencanary_ip, OPENCANARY_IP, ensure_nft_table_and_chain ) from ...utils.wan_state import get_active_wan_interface import os @@ -59,22 +59,24 @@ def __init__(self, config_path: Optional[str] = None): self.config_path = config_path or "/home/azazel/Azazel-Pi/configs/network/azazel.yaml" # Respect AZAZEL_WAN_IF environment override first, then WAN manager helper self.interface = os.environ.get("AZAZEL_WAN_IF") or get_active_wan_interface() + self._testing = bool(os.environ.get("PYTEST_CURRENT_TEST")) self.active_rules: Dict[str, List[TrafficControlRule]] = {} # lock protecting active_rules and related operations self._rules_lock = threading.Lock() self._ensure_tc_setup() - # Restore any persisted nft handles into in-memory active_rules mapping so - # deletions by handle will work across restarts. - try: - self._restore_persisted_diversions() - except Exception: - logger.exception('Failed restoring persisted nft handles at startup') - # Validate persisted entries and prune stale ones - try: - self._validate_and_clean_persisted_diversions() - except Exception: - logger.exception('Failed validating persisted nft handles at startup') + if not self._testing: + # Restore any persisted nft handles into in-memory active_rules mapping so + # deletions by handle will work across restarts. + try: + self._restore_persisted_diversions() + except Exception: + logger.exception('Failed restoring persisted nft handles at startup') + # Validate persisted entries and prune stale ones + try: + self._validate_and_clean_persisted_diversions() + except Exception: + logger.exception('Failed validating persisted nft handles at startup') # Start background cleanup thread (uses config.rules.cleanup_interval_seconds) try: @@ -183,30 +185,55 @@ def _validate_and_clean_persisted_diversions(self) -> None: data = self._load_persisted_diversions() if not data: return + + # Track IPs whose persisted rules are no longer valid so we can + # also drop any in-memory active_rules entries for them. + stale_ips: List[str] = [] + for ip, meta in list(data.items()): backend = meta.get('backend') action = meta.get('action') + remove = False + # Only iptables entries are supported now if backend == 'iptables' and action in ('redirect', 'block'): table = meta.get('table', 'nat' if action == 'redirect' else 'filter') chain = meta.get('chain', 'PREROUTING' if action == 'redirect' else 'INPUT') spec = meta.get('rule_spec') if not spec: - del data[ip] - continue - try: - res = self._run_cmd(["iptables", "-t", table, "-C", chain, *spec], capture_output=True, text=True, timeout=5) - if res.returncode != 0: - del data[ip] - continue - except Exception: - del data[ip] - continue + remove = True + else: + try: + res = self._run_cmd( + ["iptables", "-t", table, "-C", chain, *spec], + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode != 0: + remove = True + except Exception: + remove = True else: # anything else is obsolete + remove = True + + if remove: + stale_ips.append(ip) del data[ip] - continue + self._save_persisted_diversions(data) + + # Keep in-memory state consistent with cleaned persistence: if an IP + # no longer has a valid underlying iptables rule, drop its rules from + # active_rules so future operations (apply/remove) don't see ghost + # entries that never actually apply at the kernel level. + if stale_ips: + with self._rules_lock: + for ip in stale_ips: + if ip in self.active_rules: + del self.active_rules[ip] + except Exception: logger.exception('Error validating persisted nft handles') @@ -473,15 +500,22 @@ def _try_add_nft_dnat(self, target_ip: str, canary_ip: str, dest_port: Optional[ return False, None, "nft support removed" def _try_add_iptables_dnat(self, target_ip: str, canary_ip: str, dest_port: Optional[int]) -> Tuple[bool, Optional[Dict[str, Any]], str]: - """Attempt to add DNAT rule via legacy iptables.""" + """Attempt to add DNAT rule via legacy iptables. + + Creates a rule to redirect SSH traffic (port 22) directly to OpenCanary container. + Uses direct container IP (172.16.10.3:2222) to bypass Docker bridge complications. + Interface is limited to wlan1 (external) to avoid interfering with internal traffic. + """ table = "nat" chain = "PREROUTING" - rule_spec: List[str] = ["-s", target_ip] - if dest_port: - rule_spec += ["-p", "tcp", "--dport", str(dest_port)] - to_dest = f"{canary_ip}:{dest_port}" - else: - to_dest = canary_ip + + # Get WAN interface (default: wlan1) + wan_iface = get_active_wan_interface() or "wlan1" + + # Redirect SSH (port 22) directly to OpenCanary container IP + # Using 172.16.10.3:2222 instead of 127.0.0.1:2222 to avoid Docker PREROUTING conflicts + rule_spec: List[str] = ["-i", wan_iface, "-s", target_ip, "-p", "tcp", "--dport", "22"] + to_dest = "172.16.10.3:2222" # Direct container IP rule_spec += ["-j", "DNAT", "--to-destination", to_dest] try: @@ -492,7 +526,8 @@ def _try_add_iptables_dnat(self, target_ip: str, canary_ip: str, dest_port: Opti "iptables_table": table, "iptables_chain": chain, "iptables_rule": list(rule_spec), - "dest_port": dest_port, + "dest_port": 2222, # OpenCanary's actual port + "source_port": 22, # Original target port "canary_ip": canary_ip, } return True, params, "" @@ -509,9 +544,11 @@ def _try_add_iptables_dnat(self, target_ip: str, canary_ip: str, dest_port: Opti "iptables_table": table, "iptables_chain": chain, "iptables_rule": list(rule_spec), - "dest_port": dest_port, + "dest_port": 2222, # OpenCanary's actual port + "source_port": 22, # Original target port "canary_ip": canary_ip, } + logger.info(f"DNAT redirect (iptables): {target_ip}:22 -> 172.16.10.3:2222 (via {wan_iface})") return True, params, "" err = self._safe_stderr(result) or self._safe_stdout(result) or "iptables DNAT failed" return False, None, err @@ -519,14 +556,24 @@ def _try_add_iptables_dnat(self, target_ip: str, canary_ip: str, dest_port: Opti return False, None, str(e) def _record_redirect_rule(self, target_ip: str, parameters: Dict[str, Any]) -> None: - """Register redirect rule in memory and persist metadata for cleanup.""" + """Register redirect rule in memory and persist metadata for cleanup. + + To avoid stale/duplicate redirect entries, we keep at most one + redirect rule per IP in active_rules and always overwrite with the + latest parameters. + """ rule = TrafficControlRule( target_ip=target_ip, action_type="redirect", - parameters=parameters + parameters=parameters, ) with self._rules_lock: - self.active_rules.setdefault(target_ip, []).append(rule) + existing = self.active_rules.get(target_ip, []) + # Drop any previous redirect entries for this IP; other action + # types (delay/shape/block) are left untouched. + existing = [r for r in existing if r.action_type != "redirect"] + existing.append(rule) + self.active_rules[target_ip] = existing # Only iptables backend is supported now backend = parameters.get("backend", "iptables") try: @@ -589,10 +636,6 @@ def _is_ipv6(self, ip: str) -> bool: def apply_dnat_redirect(self, target_ip: str, dest_port: Optional[int] = None) -> bool: """指定IPをOpenCanaryにDNAT転送""" try: - # 既存の同種ルールがあれば再適用しない(冪等化) - if target_ip in self.active_rules and any(r.action_type == "redirect" for r in self.active_rules[target_ip]): - logger.info(f"DNAT already applied to {target_ip}, skip") - return True canary_ip = load_opencanary_ip() if self._is_ipv6(target_ip): @@ -708,8 +751,12 @@ def apply_combined_action(self, target_ip: str, mode: str) -> bool: """モードに応じた複合アクションを適用""" config = self._load_config() actions = config.get("actions", {}) - preset = actions.get(mode, {}) - + fallback_presets = { + "shield": {"delay_ms": 200, "shape_kbps": 128}, + "lockdown": {"delay_ms": 150, "shape_kbps": 64}, + } + preset = actions.get(mode, {}) or fallback_presets.get(mode, {}) + if not preset: logger.warning(f"No action preset for mode: {mode}") return False diff --git a/azazel_pi/core/notify_config.py b/azazel_pi/core/notify_config.py index 7320c7c..07ed515 100644 --- a/azazel_pi/core/notify_config.py +++ b/azazel_pi/core/notify_config.py @@ -34,8 +34,8 @@ "summary_interval_mins": 5, }, "opencanary": { - "ip": "172.16.10.10", - "ports": [22, 80, 5432], + "ip": "172.16.10.3", + "ports": [2222, 8081, 5432], }, "network": { "interface": "wlan1", diff --git a/azazel_pi/monitor/main_suricata.py b/azazel_pi/monitor/main_suricata.py index 7f8e0b7..11e5767 100644 --- a/azazel_pi/monitor/main_suricata.py +++ b/azazel_pi/monitor/main_suricata.py @@ -4,14 +4,13 @@ Suricata eve.json を監視し Mattermost へ通知、必要に応じ DNAT 遅滞行動を発動 """ -import json, time, logging, sys +import json, time, logging, sys, threading from datetime import datetime from collections import defaultdict, deque from pathlib import Path from ..core import notify_config as notice from ..core.state_machine import StateMachine, State, Event, Transition -from ..core.scorer import ScoreEvaluator from ..core.enforcer.traffic_control import get_traffic_control_engine from ..core.offline_ai_evaluator import evaluate_with_offline_ai from ..core.hybrid_threat_evaluator import evaluate_with_hybrid_system @@ -50,26 +49,19 @@ def _load_main_config() -> dict: DENYLIST_IPS = set(_soc.get("denylist_ips", [])) CRITICAL_SIGNATURES = _soc.get("critical_signatures", []) -# allow/deny は正規化(lower/underscore→space)。allowがNoneなら全許可(denyのみ適用) +# allow/deny は正規化(lower/underscore→space)。v1.0.0同様、検知したものは全て転送し、denyのみに従う def _norm_cat(x: str) -> str: return x.replace("_", " ").lower() ALLOWED_SIG_CATEGORIES = None if not _allow else { _norm_cat(c) for c in _allow } -DENIED_SIG_CATEGORIES = set() -if _deny: - DENIED_SIG_CATEGORIES = { _norm_cat(c) for c in _deny } -if ALLOWED_SIG_CATEGORIES is None: - # 既定は既存リストを許可(後方互換) - ALLOWED_SIG_CATEGORIES = { _norm_cat(c) for c in FILTER_SIG_CATEGORY } +DENIED_SIG_CATEGORIES = { _norm_cat(c) for c in _deny } if _deny else set() cooldown_seconds = 60 # 同一シグネチャ抑止時間 summary_interval = 60 # サマリ送信間隔 -evaluation_interval = 30 # 脅威レベル評価間隔 last_alert_times = {} suppressed_alerts = defaultdict(int) last_summary_time = time.time() -last_evaluation_time = time.time() last_cleanup_time = time.time() # 独立した頻度カウンタ: signature×src_ip の時系列(epoch秒) @@ -137,7 +129,6 @@ def check_exception_block(alert: dict) -> bool: ] ) -scorer = ScoreEvaluator() active_diversions = {} # {src_ip: port} の転送中IPリスト # ──────────────────────────────────────────────────────────── @@ -180,11 +171,9 @@ def parse_alert(line: str): raw_cat = signature.split(" ", 2)[1] if signature.startswith("ET ") else None category_norm = raw_cat.replace("_", " ").lower() if raw_cat else None - # deny優先→allow(allow不在時は後方互換の既定を使用) + # v1.0.0相当の挙動: denyのみ尊重し、それ以外は全て通す if category_norm and category_norm in DENIED_SIG_CATEGORIES: return None - if category_norm and (ALLOWED_SIG_CATEGORIES and category_norm not in ALLOWED_SIG_CATEGORIES): - return None # 上記を通過したら通す return { "timestamp" : data["timestamp"], @@ -330,70 +319,56 @@ def send_summary(): }) suppressed_alerts.clear() -# ──────────────────────────────────────────────────────────── -def evaluate_threat_level(): - """現在の脅威レベルを評価し、必要に応じて状態遷移を実行""" - global last_evaluation_time - - # 最近のアラート活動から脅威レベルを計算 - now = time.time() - recent_activity = 0 - - # 過去5分間のアラート数をカウント - recent_threshold = now - 300 # 5分 - for alert_time in last_alert_times.values(): - if isinstance(alert_time, datetime): - alert_timestamp = alert_time.timestamp() - if alert_timestamp > recent_threshold: - recent_activity += 1 - - # 脅威スコア計算(アクティブな転送数も考慮) - threat_score = recent_activity * 10 + len(active_diversions) * 5 - - # 状態管理に脅威スコアを適用 - evaluation = state_machine.apply_score(threat_score) - current_mode = state_machine.current_state.name - - logging.info(f"🔍 脅威評価: score={threat_score}, activity={recent_activity}, " - f"diversions={len(active_diversions)}, mode={current_mode}") - - # モード変更時の処理 - if evaluation.get("target_mode") != evaluation.get("applied_mode"): - mode_transition_action(current_mode, evaluation) - - return evaluation -def mode_transition_action(new_mode: str, evaluation: dict): - """モード遷移時のアクション実行""" - traffic_engine = get_traffic_control_engine() - - if new_mode == "portal": - # 通常モード復帰:すべての制御ルールを停止 - restore_normal_mode() - send_alert_to_mattermost("Azazel", { - "timestamp": datetime.now().isoformat(), - "signature": "✅ 通常モード復帰", - "severity": 3, - "src_ip": "-", - "dest_ip": "-", - "proto": "-", - "details": f"脅威レベル低下により通常運用に復帰しました。(スコア: {evaluation.get('average', 0):.1f})", - "confidence": "High" - }) - logging.info("🟢 [モード遷移] 通常モードに復帰") - - elif new_mode == "lockdown": - send_alert_to_mattermost("Azazel", { - "timestamp": datetime.now().isoformat(), - "signature": "🚨 封鎖モード発動", - "severity": 1, - "src_ip": "-", - "dest_ip": "-", - "proto": "-", - "details": f"高脅威レベルにより封鎖モードを発動。(スコア: {evaluation.get('average', 0):.1f}) 最大遅延300ms適用", - "confidence": "High" +def _run_ai_analysis_and_notify(alert: dict) -> None: + """Mock LLM / Ollama分析を制御フローから切り離してMattermost通知する""" + try: + analysis = evaluate_with_hybrid_system(alert) + method = analysis.get("evaluation_method", "hybrid") + except Exception as e: + logging.warning(f"Hybrid AI評価に失敗。オフラインAIへフォールバック: {e}") + try: + analysis = evaluate_with_offline_ai(alert) + method = analysis.get("evaluation_method", "offline_ai") + except Exception as e2: + logging.error(f"AI分析すら実行できませんでした: {e2}") + try: + send_alert_to_mattermost("Suricata", { + **alert, + "signature": "🔎 AI分析に失敗", + "severity": 3, + "details": f"AI analysis failed: {e2}", + "confidence": "Info", + }) + except Exception: + logging.exception("AI分析失敗の通知にも失敗しました") + return + + details_parts = [ + f"method={method}", + f"risk={analysis.get('risk', 'n/a')}", + ] + if analysis.get("score") is not None: + details_parts.append(f"score={analysis.get('score')}") + if analysis.get("category"): + details_parts.append(f"category={analysis.get('category')}") + + details_text = " / ".join(details_parts) + + try: + send_alert_to_mattermost("Suricata", { + **alert, + "signature": "🔎 AI分析結果 (参考)", + "severity": 2, + "details": details_text, + "confidence": analysis.get("confidence", "Info"), }) - logging.info("🔴 [モード遷移] 封鎖モード発動") + except Exception as e: + logging.error(f"AI分析結果の通知に失敗: {e}") + + +def notify_ai_analysis_async(alert: dict) -> None: + threading.Thread(target=_run_ai_analysis_and_notify, args=(alert,), daemon=True).start() def restore_normal_mode(): """通常モード復帰:すべての制御ルールを停止""" @@ -417,7 +392,7 @@ def restore_normal_mode(): logging.info(f"✅ 通常モード復帰: {removed_count}件の制御ルールを解除") def main(): - global last_summary_time, last_evaluation_time + global last_summary_time, last_cleanup_time logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") @@ -426,7 +401,8 @@ def main(): logging.info(f"🚀 Monitoring eve.json: {EVE_FILE}") logging.info(f"🛡️ 初期状態: {state_machine.current_state.name}") - + logging.info("⚠️ スコアリングは一時的に無効化。検知トラフィックは即座にOpenCanaryへ転送します。") + for line in follow(EVE_FILE): alert = parse_alert(line) if not alert: @@ -461,100 +437,73 @@ def main(): except Exception: pass - # まずAI強化スコアを算出し、状態機械へ反映 - threat_score, ai_detail = calculate_threat_score(alert, sig) - - # リスク起点でトリガ判定(t1以上でアクション)。後方互換としてnmap検知も許容 - thresholds = state_machine.get_thresholds() - legacy_hint = ("nmap" in sig.lower()) - risk_trigger = threat_score >= max(thresholds.get("t1", 30), 1) - trigger = risk_trigger or legacy_hint - - severity_for_state = threat_score + (30 if trigger else 0) - state_machine.apply_score(severity_for_state) - - if trigger and state_machine.get_base_mode() != "shield": - state_machine.dispatch(Event(name="shield", severity=severity_for_state)) - - # ── 攻撃検知時の処理 ────────────────── - if trigger: - # 通知はクールダウン制御、制御発動はクールダウン非依存 - if should_notify(key): - send_alert_to_mattermost("Suricata",{ - **alert, - "signature":"⚠️ 偵察/攻撃を検知", - "severity":1, - "details":sig, - "confidence":"High" - }) - logging.info(f"Notify attack: {sig}") - - try: - traffic_engine = get_traffic_control_engine() - mode_for_actions = "shield" if trigger else state_machine.current_state.name - - active_ips = set(traffic_engine.get_active_rules().keys()) - if src_ip not in active_ips: - if traffic_engine.apply_combined_action(src_ip, mode_for_actions): - # 後方互換用の active_diversions にも反映 - if 'active_diversions' not in globals(): - global active_diversions - active_diversions = {} - active_diversions[src_ip] = dport - - if 'NOTIFY_CALLBACK' in globals(): - NOTIFY_CALLBACK() - - # モード別の詳細メッセージ - config = traffic_engine._load_config() - actions = config.get("actions", {}) - preset = actions.get(mode_for_actions, {}) - delay_info = f"遅延{preset.get('delay_ms', 0)}ms" - shape_info = f"帯域{preset.get('shape_kbps', 'unlimited')}kbps" if preset.get('shape_kbps') else "" - mode_details = f"{delay_info} {shape_info}".strip() - - if should_notify(key + ":action"): - send_alert_to_mattermost("Suricata",{ - "timestamp": alert["timestamp"], - "signature": f"🛡️ 遅滞行動発動({mode_for_actions.upper()})", - "severity": 2, - "src_ip": src_ip, - "dest_ip": f"OpenCanary:{dport}", - "proto": alert["proto"], - "details": f"攻撃元に統合制御を適用: DNAT転送 + {mode_details}", - "confidence": "High" - }) - logging.info(f"[統合制御] {src_ip}:{dport} -> {mode_for_actions}モード適用") - else: - logging.debug(f"Control already active for {src_ip}, skip re-apply") - - except Exception as e: - logging.error(f"統合制御エラー: {e}") - continue - - # ── 通常通知 ────────────────── + # ── 通知(クールダウン制御あり) ────────────────── if should_notify(key): - # 通常のアラート: 既にスコア反映済みのため通知のみ - send_alert_to_mattermost("Suricata", alert) + send_alert_to_mattermost("Suricata", { + **alert, + "signature": "⚠️ 偵察/攻撃を検知", + "severity": 1, + "details": sig, + "confidence": "High" + }) + logging.info(f"Notify attack: {sig}") + + # AI分析は通知時のみ実施(クールダウン制御により重複を防ぐ) + notify_ai_analysis_async(alert) else: suppressed_alerts[sig] += 1 - # ── 定期評価・サマリ ──────────────────── - now = time.time() - if now - last_evaluation_time >= evaluation_interval: - evaluate_threat_level() - last_evaluation_time = now + # ── 無条件のOpenCanary転送 ────────────────── + try: + traffic_engine = get_traffic_control_engine() + active_ips = set(traffic_engine.get_active_rules().keys()) + already_active = src_ip in active_ips + + # 既にリダイレクト済みの場合はスキップ(冪等性) + if already_active: + # 既存のリダイレクトが有効なのでログのみ + if src_ip not in active_diversions: + active_diversions[src_ip] = 2222 + continue + # OpenCanaryは2222番ポートで動作(dest_portパラメータは使用しない) + redirected = traffic_engine.apply_dnat_redirect(src_ip, dest_port=None) + if redirected: + active_diversions[src_ip] = 2222 # OpenCanaryの実際のポート + if state_machine.current_state.name != "shield": + state_machine.dispatch(Event(name="shield", severity=0)) + + if should_notify(key + ":action"): + send_alert_to_mattermost("Suricata", { + "timestamp": alert["timestamp"], + "signature": "🛡️ OpenCanary転送を開始", + "severity": 2, + "src_ip": src_ip, + "dest_ip": "OpenCanary:2222", + "proto": alert["proto"], + "details": f"検知した通信をOpenCanary(2222/TCP)へ即時転送しました(元の宛先: {dport})", + "confidence": "High" + }) + + logging.info(f"[OpenCanary転送] {src_ip} -> OpenCanary:2222 (original dest: {dport})") + else: + logging.error(f"DNAT redirect failed for {src_ip}") + except Exception as e: + logging.error(f"OpenCanary転送エラー: {e}") + + # ── サマリとクリーンアップ ──────────────────── + now = time.time() + if now - last_summary_time >= summary_interval: send_summary() last_summary_time = now - # 定期クリーンアップ(10分毎) - global last_cleanup_time if now - last_cleanup_time >= 600: try: engine = get_traffic_control_engine() engine.cleanup_expired_rules(max_age_seconds=3600) + if not engine.get_active_rules() and state_machine.current_state.name != "portal": + state_machine.dispatch(Event(name="portal", severity=0)) except Exception: pass last_cleanup_time = now diff --git a/azazel_pi/utils/delay_action.py b/azazel_pi/utils/delay_action.py index 9036d42..66d48d5 100644 --- a/azazel_pi/utils/delay_action.py +++ b/azazel_pi/utils/delay_action.py @@ -10,8 +10,8 @@ from typing import Optional # OpenCanary IP address (デフォルト値、設定ファイルから上書き可能) -# Docker bridge azazel_net の固定アドレス -OPENCANARY_IP = "172.16.10.10" +# Direct container IP to bypass Docker bridge complications +OPENCANARY_IP = "172.16.10.3" # ログ設定 try: @@ -47,8 +47,8 @@ def load_opencanary_ip() -> str: # OpenCanaryのIPアドレス設定を探す canary_ip = config.get('canary', {}).get('ip') if not canary_ip: - # デフォルトでローカルIPレンジの.100を使用 - canary_ip = "192.168.1.100" + # デフォルトは直接コンテナ IP を使用 + canary_ip = "172.16.10.3" OPENCANARY_IP = canary_ip logger.info(f"OpenCanary IP loaded from config: {OPENCANARY_IP}") @@ -327,4 +327,4 @@ def cleanup_expired_rules(max_age_minutes: int = 60) -> int: else: print("✗ Failed to remove DNAT rule") else: - print("✗ Failed to add DNAT rule") \ No newline at end of file + print("✗ Failed to add DNAT rule") diff --git a/configs/monitoring/notify.yaml.bak b/configs/monitoring/notify.yaml.bak index c235fdb..3f0af96 100644 --- a/configs/monitoring/notify.yaml.bak +++ b/configs/monitoring/notify.yaml.bak @@ -36,10 +36,10 @@ suppress: # OpenCanary設定 opencanary: - ip: "172.16.10.10" + ip: "172.16.10.3" ports: - - 22 # SSH - - 80 # HTTP + - 2222 # SSH (OpenCanary container) + - 8081 # HTTP (OpenCanary container) - 5432 # PostgreSQL # ネットワーク制御設定 diff --git a/configs/network/azazel.yaml b/configs/network/azazel.yaml index cfa2579..ce56ba3 100644 --- a/configs/network/azazel.yaml +++ b/configs/network/azazel.yaml @@ -26,9 +26,9 @@ soc: - "ET TROJAN" - "Critical Infrastructure Attack" canary: - # OpenCanary honeypot IP address (Docker bridge azazel_net) - ip: "172.16.10.10" - port: 22 # Default SSH port for honeypot + # OpenCanary honeypot IP address (direct container IP) + ip: "172.16.10.3" + port: 2222 # OpenCanary SSH port actions: normal: { delay_ms: 0, shape_kbps: null, block: false } portal: { delay_ms: 0, shape_kbps: null, block: false } diff --git a/configs/nftables/README.md b/configs/nftables/README.md new file mode 100644 index 0000000..efd79ff --- /dev/null +++ b/configs/nftables/README.md @@ -0,0 +1,37 @@ +# nftables Configuration (Deprecated) + +**⚠️ NOTICE: These nftables configuration files are no longer used.** + +Azazel-Pi has been migrated from nftables to iptables for better compatibility with Docker and to avoid conflicts with the `inet filter/forward` chain. + +## What changed? + +- **Previous setup**: Used nftables with custom tables (`inet azazel`, `inet filter`, `ip nat`) +- **Current setup**: Uses iptables exclusively (via `iptables-nft` backend) +- **Reason**: The `inet filter/forward policy drop` in nftables was blocking Docker container traffic, including OpenCanary SSH (port 2222) + +## Migration summary + +1. `/etc/nftables.conf` has been minimized (no active rules) +2. `nftables.service` has been disabled +3. NAT rules are now managed via iptables: + ```bash + iptables -t nat -A POSTROUTING -s 172.16.0.0/24 -o wlan1 -j MASQUERADE + iptables -t nat -A POSTROUTING -s 172.16.10.0/24 -o wlan1 -j MASQUERADE + ``` +4. Blocking rules are now managed via iptables custom chains (see `scripts/ai_policy_block.sh`) + +## Files in this directory + +- **azazel.nft**: Old nftables ruleset with blocked_hosts set and redirect chain (deprecated) +- **lockdown.nft**: Old lockdown mode configuration (deprecated) + +## For future reference + +If you need to implement equivalent functionality with iptables: + +- **Blocked hosts**: Use iptables custom chain with DROP rules +- **DNAT/redirect**: Use iptables NAT table PREROUTING chain +- **Lockdown allowlist**: Use iptables with ipset for efficient IP set matching + +See `scripts/ai_policy_block.sh` and `scripts/azazel_update_dnat.sh` for iptables implementations. diff --git a/configs/notify.yaml b/configs/notify.yaml index b7ff239..9ac09e3 100644 --- a/configs/notify.yaml +++ b/configs/notify.yaml @@ -36,10 +36,10 @@ suppress: # OpenCanary設定 opencanary: - ip: "172.16.10.10" + ip: "172.16.10.3" ports: - - 22 # SSH - - 80 # HTTP + - 2222 # SSH (OpenCanary container) + - 8081 # HTTP (OpenCanary container) - 5432 # PostgreSQL # ネットワーク制御設定 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index b12d92f..045046b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -39,12 +39,15 @@ services: image: thinkst/opencanary:latest container_name: azazel_opencanary restart: always + # Publish to all interfaces so 2222/8081 are reachable from outside. + ports: + - "0.0.0.0:2222:2222" + - "0.0.0.0:8081:8081" volumes: - /opt/azazel/config/opencanary.conf:/root/.opencanary.conf:ro - /opt/azazel/logs:/logs networks: - azazel_net: - ipv4_address: 172.16.10.10 + - azazel_net volumes: ollama_data: diff --git a/deploy/opencanary.conf b/deploy/opencanary.conf index f016b55..a00b6d2 100644 --- a/deploy/opencanary.conf +++ b/deploy/opencanary.conf @@ -2,7 +2,7 @@ "device.node_id": "azazel-canary", "ssh.enabled": true, - "ssh.port": 22, + "ssh.port": 2222, "ssh.version": "SSH-2.0-OpenSSH_8.4p1 Debian-5+deb11u1", "ssh.banner": "SSH-2.0-OpenSSH_8.4p1 Debian-5+deb11u1", diff --git a/scripts/README.md b/scripts/README.md index 005625a..0b3018b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -112,7 +112,7 @@ sudo scripts/setup_wireless.sh --ssid "MyNetwork" --passphrase "MyPassword" --sk **Features:** - **Dual Interface Setup**: ${AZAZEL_LAN_IF:-wlan0} as internal AP (172.16.0.0/24), ${AZAZEL_WAN_IF:-wlan1} for upstream/monitoring -- **Access Point Configuration**: hostapd, dnsmasq, NAT with nftables +- **Access Point Configuration**: hostapd, dnsmasq, NAT with iptables - **Suricata Integration**: HOME_NET configuration and interface monitoring setup - **Flexible Options**: Can configure AP-only, monitoring-only, or both - **Status Verification**: Built-in health checks and connectivity testing diff --git a/scripts/ai_policy_block.sh b/scripts/ai_policy_block.sh index d4f8e90..e257a00 100755 --- a/scripts/ai_policy_block.sh +++ b/scripts/ai_policy_block.sh @@ -25,24 +25,26 @@ if [[ $EUID -ne 0 ]]; then exit 1 fi -log "Blocking traffic from $SRC_IP for ${DURATION}s using nftables" +log "Blocking traffic from $SRC_IP for ${DURATION}s using iptables" -# Create table and chain if not exists -nft add table inet "$TABLE_NAME" 2>/dev/null || true -nft add chain inet "$TABLE_NAME" input '{ type filter hook input priority 0; }' 2>/dev/null || true +# Create custom chain if not exists +iptables -N "$TABLE_NAME" 2>/dev/null || true + +# Link custom chain to INPUT if not already linked +iptables -C INPUT -j "$TABLE_NAME" 2>/dev/null || iptables -I INPUT -j "$TABLE_NAME" # Remove existing rule for this IP (if any) -nft delete rule inet "$TABLE_NAME" input ip saddr "$SRC_IP" drop 2>/dev/null || true +iptables -D "$TABLE_NAME" -s "$SRC_IP" -j DROP 2>/dev/null || true # Add blocking rule -if nft add rule inet "$TABLE_NAME" input ip saddr "$SRC_IP" drop; then - log "SUCCESS: Blocked $SRC_IP via nftables" +if iptables -A "$TABLE_NAME" -s "$SRC_IP" -j DROP; then + log "SUCCESS: Blocked $SRC_IP via iptables" # Optional: Set cleanup timer if [[ "$DURATION" -gt 0 ]]; then ( sleep "$DURATION" - nft delete rule inet "$TABLE_NAME" input ip saddr "$SRC_IP" drop 2>/dev/null || true + iptables -D "$TABLE_NAME" -s "$SRC_IP" -j DROP 2>/dev/null || true log "Cleanup: Removed block rule for $SRC_IP" ) & fi diff --git a/scripts/azazel_update_dnat.sh b/scripts/azazel_update_dnat.sh index fc20a21..67f5e0b 100644 --- a/scripts/azazel_update_dnat.sh +++ b/scripts/azazel_update_dnat.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Safe helper to update nft DNAT rules for Azazel -> redirect SSH (tcp dport 22) -# to OpenCanary IP and port (default 192.168.1.100:2222). +# Safe helper to update iptables DNAT rules for Azazel -> redirect SSH (tcp dport 22) +# to OpenCanary IP and port (default 127.0.0.1:2222). # Usage (recommended): edit CANARY_IP / CANARY_PORT below if needed, then: # sudo cp scripts/azazel_update_dnat.sh /usr/local/sbin/azazel_update_dnat.sh # sudo bash /usr/local/sbin/azazel_update_dnat.sh --apply @@ -10,33 +10,32 @@ set -euo pipefail SCRIPT_NAME=$(basename "$0") BACKUP_DIR=/tmp/azazel_backup_$(date +%s) SRC_IP_FILE="$BACKUP_DIR/src_ips.txt" -NFT_BACKUP="$BACKUP_DIR/nft_ruleset_backup.conf" +IPTABLES_BACKUP="$BACKUP_DIR/iptables_backup.rules" # Default values (edit this file or pass via env) -CANARY_IP=${CANARY_IP:-192.168.1.100} +CANARY_IP=${CANARY_IP:-127.0.0.1} CANARY_PORT=${CANARY_PORT:-2222} -NFT_TABLE=${NFT_TABLE:-inet azazel} -PREROUTING_CHAIN=${PREROUTING_CHAIN:-prerouting} +CHAIN_NAME=${CHAIN_NAME:-AZAZEL_DNAT} usage(){ cat < CANARY_IP:CANARY_PORT - - print resulting nft table and suggest tcpdump / ss checks + - print resulting iptables rules and suggest tcpdump / ss checks To rollback, run: - sudo nft -f $NFT_BACKUP + sudo iptables-restore < $IPTABLES_BACKUP EOF } @@ -77,26 +76,20 @@ run(){ # Create backup dir run "mkdir -p '$BACKUP_DIR' && chmod 700 '$BACKUP_DIR'" -# Backup full nft ruleset -run "nft list ruleset > '$NFT_BACKUP' 2> '$BACKUP_DIR/nft_backup.err' || true" +# Backup full iptables ruleset +run "iptables-save > '$IPTABLES_BACKUP' 2> '$BACKUP_DIR/iptables_backup.err' || true" echo "Backup will be stored in $BACKUP_DIR" -# Check table/chain existence -if ! nft list table inet azazel >/dev/null 2>&1; then - echo "Warning: nft table 'inet azazel' not present. Trying 'ip nat' fallback." >&2 -fi +# Create custom chain if not exists +run "iptables -t nat -N '$CHAIN_NAME' 2>/dev/null || true" -# Extract src IPs from prerouting chain -# Use list chain which is safer than list table in some setups -if nft list chain inet azazel $PREROUTING_CHAIN >/dev/null 2>&1; then - CMD_EXTRACT="nft list chain inet azazel $PREROUTING_CHAIN | grep -oP 'ip saddr \\K[^ ]+' | sort -u | sed '/^\s*$/d' > '$SRC_IP_FILE'" - run "$CMD_EXTRACT" -else - echo "Prerouting chain not found in inet azazel; trying 'nft list table inet azazel' to search rules." >&2 - CMD_EXTRACT2="nft list table inet azazel | grep -oP 'ip saddr \\K[^ ]+' | sort -u | sed '/^\s*$/d' > '$SRC_IP_FILE'" - run "$CMD_EXTRACT2" -fi +# Link chain to PREROUTING if not already linked +run "iptables -t nat -C PREROUTING -j '$CHAIN_NAME' 2>/dev/null || iptables -t nat -I PREROUTING -j '$CHAIN_NAME' || true" + +# Extract src IPs from existing DNAT rules in custom chain +CMD_EXTRACT="iptables-save -t nat | grep -E '^-A $CHAIN_NAME.*--source' | grep -oP '\\-\\-source \\K[0-9.]+' | sort -u > '$SRC_IP_FILE'" +run "$CMD_EXTRACT" # Show saved src IPs if [ -f "$SRC_IP_FILE" ]; then @@ -105,24 +98,24 @@ else echo "No src IPs file created - proceeding but there may be nothing to add."; fi -# Flush prerouting chain -run "nft flush chain inet azazel $PREROUTING_CHAIN || nft flush chain ip nat $PREROUTING_CHAIN || true" +# Flush custom chain +run "iptables -t nat -F '$CHAIN_NAME' || true" -echo "Prerouting chain flushed (dry-run shows command)." +echo "AZAZEL_DNAT chain flushed (dry-run shows command)." # If src IP file exists and not empty, add single test rule for first IP if [ -s "$SRC_IP_FILE" ]; then FIRST=$(head -n1 "$SRC_IP_FILE") echo "Will attempt single test DNAT for $FIRST -> $CANARY_IP:$CANARY_PORT" - run "nft add rule inet azazel $PREROUTING_CHAIN ip saddr $FIRST tcp dport 22 dnat to $CANARY_IP:$CANARY_PORT || echo 'add failed'" - echo "Show chain after test add:"; run "nft list chain inet azazel $PREROUTING_CHAIN || true" + run "iptables -t nat -A '$CHAIN_NAME' -p tcp -s $FIRST --dport 22 -j DNAT --to-destination $CANARY_IP:$CANARY_PORT || echo 'add failed'" + echo "Show chain after test add:"; run "iptables -t nat -L '$CHAIN_NAME' -n -v || true" # If apply mode, then add for all if [ "$APPLY" = true ]; then echo "Adding DNAT entries for all saved src IPs..." while read -r src; do [ -z "$src" ] && continue - run "nft add rule inet azazel $PREROUTING_CHAIN ip saddr $src tcp dport 22 dnat to $CANARY_IP:$CANARY_PORT || echo 'failed for $src'" + run "iptables -t nat -A '$CHAIN_NAME' -p tcp -s $src --dport 22 -j DNAT --to-destination $CANARY_IP:$CANARY_PORT || echo 'failed for $src'" done < "$SRC_IP_FILE" else echo "Dry-run mode: skip bulk add. Rerun with --apply to apply changes." @@ -132,8 +125,8 @@ else fi # Final view -echo "Final nft table (prerouting chain):"; run "nft list chain inet azazel $PREROUTING_CHAIN || nft list table inet azazel || true" +echo "Final iptables NAT rules (AZAZEL_DNAT chain):"; run "iptables -t nat -L '$CHAIN_NAME' -n -v || true" echo "Check OpenCanary listener (ss):"; run "ss -ltnp | egrep ':$CANARY_PORT\\s' || ss -ltnp | grep $CANARY_PORT || echo 'no listener on $CANARY_PORT detected'" -echo "Done. If you applied changes and want to rollback use: sudo nft -f $NFT_BACKUP" +echo "Done. If you applied changes and want to rollback use: sudo iptables-restore < $IPTABLES_BACKUP" diff --git a/scripts/install_azazel.sh b/scripts/install_azazel.sh index 1dbadfb..c354c2c 100755 --- a/scripts/install_azazel.sh +++ b/scripts/install_azazel.sh @@ -260,11 +260,11 @@ APT_PACKAGES=( docker-compose gnupg git + iptables-persistent jq moreutils netfilter-persistent nginx - nftables python3 python3-pip python3-toml diff --git a/scripts/iptables_save.sh b/scripts/iptables_save.sh new file mode 100755 index 0000000..dba6fbe --- /dev/null +++ b/scripts/iptables_save.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Script to save current iptables rules for persistence +# This ensures NAT and filter rules survive reboots + +set -euo pipefail + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2 +} + +# Check if root privileges +if [[ $EUID -ne 0 ]]; then + log "ERROR: This script requires root privileges" + exit 1 +fi + +log "Saving iptables rules..." + +# Create directory for rules if it doesn't exist +mkdir -p /etc/iptables + +# Save IPv4 rules +if iptables-save > /etc/iptables/rules.v4; then + log "SUCCESS: IPv4 rules saved to /etc/iptables/rules.v4" +else + log "ERROR: Failed to save IPv4 rules" + exit 1 +fi + +# If netfilter-persistent is installed, use it +if command -v netfilter-persistent >/dev/null 2>&1; then + log "Using netfilter-persistent to save rules..." + netfilter-persistent save || log "WARNING: netfilter-persistent save had issues" +fi + +log "Done. Rules will be restored on next boot." +log "To manually restore rules: iptables-restore < /etc/iptables/rules.v4" diff --git a/scripts/manual_monitor.sh b/scripts/manual_monitor.sh new file mode 100755 index 0000000..643e8c3 --- /dev/null +++ b/scripts/manual_monitor.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +# 手動実行用ラッパー: 統合監視を停止して `run_all` を root で起動 +# 使い方: sudo ./scripts/manual_monitor.sh + +REPO_ROOT="/home/azazel/Azazel-Pi" +PYTHONPATH_ENV="$REPO_ROOT" +MODULE="azazel_pi.monitor.run_all" + +if [ "$(id -u)" -ne 0 ]; then + echo "このスクリプトは root(または sudo) で実行してください。例: sudo $0" + exit 1 +fi + +echo "[INFO] 停止: systemd サービス azctl-unified.service, mattermost.service を停止します" +systemctl stop azctl-unified.service || true +systemctl stop mattermost.service || true + +echo "[INFO] Suricata は動作させたままにしてください(eve.json を監視するため)。" +echo "[INFO] PYTHONPATH を設定して監視を起動します: PYTHONPATH=$PYTHONPATH_ENV" +echo "--- ログは標準出力に出ます。別ターミナルで確認してください ---" + +export PYTHONPATH="$PYTHONPATH_ENV" +exec python3 -u -m "$MODULE" diff --git a/scripts/nft_apply.sh b/scripts/nft_apply.sh index 97c227e..1ac9198 100644 --- a/scripts/nft_apply.sh +++ b/scripts/nft_apply.sh @@ -1,11 +1,17 @@ #!/usr/bin/env bash +# NOTE: This script is deprecated as Azazel-Pi now uses iptables instead of nftables. +# For iptables rule management, use iptables-restore or netfilter-persistent. set -euo pipefail -RULESET=${1:-/etc/azazel/nftables/azazel.nft} +echo "WARNING: nft_apply.sh is deprecated. Azazel-Pi now uses iptables." >&2 +echo "Please use 'iptables-restore < /path/to/rules' or 'netfilter-persistent reload' instead." >&2 +exit 1 -if [[ ! -f "$RULESET" ]]; then - echo "ruleset not found: $RULESET" >&2 - exit 1 -fi - -nft -f "$RULESET" +# RULESET=${1:-/etc/azazel/nftables/azazel.nft} +# +# if [[ ! -f "$RULESET" ]]; then +# echo "ruleset not found: $RULESET" >&2 +# exit 1 +# fi +# +# nft -f "$RULESET" diff --git a/scripts/setup_wireless.sh b/scripts/setup_wireless.sh index 2e791f2..6e28629 100755 --- a/scripts/setup_wireless.sh +++ b/scripts/setup_wireless.sh @@ -206,43 +206,27 @@ EOF sysctl -w net.ipv4.ip_forward=1 >/dev/null echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/99-azazel.conf - # Configure nftables for NAT - log "Configuring NAT rules..." + # Configure NAT with iptables + log "Configuring NAT rules with iptables..." + + # Add MASQUERADE rule for NAT + iptables -t nat -C POSTROUTING -s 172.16.0.0/24 -o "$WLAN_UP" -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -s 172.16.0.0/24 -o "$WLAN_UP" -j MASQUERADE + + # Save iptables rules + if command -v netfilter-persistent >/dev/null 2>&1; then + netfilter-persistent save || warn "Failed to save iptables rules" + elif command -v iptables-save >/dev/null 2>&1; then + iptables-save > /etc/iptables/rules.v4 || warn "Failed to save iptables rules" + fi + + # Keep /etc/nftables.conf minimal (not used) cat > /etc/nftables.conf <" でルールを識別 + - 一定時間アラートが無ければルール削除 +""" +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +import time +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + + +COMMENT_PREFIX = "SSH_HONEYPOT_" +ACCEPT_COMMENT = "SSH_HONEYPOT_ACCEPT" +DEFAULT_TARGET = "127.0.0.1:2222" # fallback +REDIRECT_PORT = 2222 +SSH_PORT = 22 +# current DNAT target (host:port) – initialized at startup +TARGET_DEST = DEFAULT_TARGET + + +def log(msg: str, log_file: Optional[Path]) -> None: + timestamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) + line = f"[{timestamp}] {msg}" + print(line) + if log_file: + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + with log_file.open("a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception as e: # best effort + print(f"[warn] failed to write log file: {e}", file=sys.stderr) + + +def run_iptables(args: List[str], dry_run: bool) -> subprocess.CompletedProcess: + if dry_run: + print("DRY-RUN:", " ".join(args)) + return subprocess.CompletedProcess(args, 0, "", "") + return subprocess.run(args, capture_output=True, text=True, timeout=10) + + +def build_rule_spec(ip: str, iface: str) -> List[str]: + comment = f"{COMMENT_PREFIX}{ip}" + return [ + "-i", + iface, + "-p", + "tcp", + "-s", + ip, + "--dport", + str(SSH_PORT), + "-j", + "DNAT", + "--to-destination", + TARGET_DEST, + "-m", + "comment", + "--comment", + comment, + ] + + +def build_accept_rule_args(iface: str) -> List[str]: + return [ + "-I", + "INPUT", + "1", + "-i", + iface, + "-p", + "tcp", + "--dport", + str(REDIRECT_PORT), + "-m", + "conntrack", + "--ctstate", + "DNAT", + "-j", + "ACCEPT", + "-m", + "comment", + "--comment", + ACCEPT_COMMENT, + ] + + +def rule_exists(ip: str, iface: str, iptables_path: str) -> bool: + args = [iptables_path, "-t", "nat", "-C", "PREROUTING"] + build_rule_spec(ip, iface) + proc = subprocess.run(args, capture_output=True, text=True) + return proc.returncode == 0 + + +def accept_rule_exists(iface: str, iptables_path: str) -> bool: + args = [iptables_path, "-C"] + build_accept_rule_args(iface)[1:] + proc = subprocess.run(args, capture_output=True, text=True) + return proc.returncode == 0 + + +def ensure_accept_rule(iface: str, iptables_path: str, dry_run: bool, log_file: Optional[Path]) -> None: + if accept_rule_exists(iface, iptables_path): + return + args = [iptables_path] + build_accept_rule_args(iface) + proc = run_iptables(args, dry_run) + if proc.returncode == 0: + log(f"[add] INPUT accept for DNATed 2222 ({iface})", log_file) + else: + log(f"[error] failed to ensure INPUT accept: {proc.stderr.strip()}", log_file) + + +def _detect_container_ip(container: str = "azazel_opencanary") -> Optional[str]: + try: + proc = subprocess.run( + ["docker", "inspect", "-f", "{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}", container], + capture_output=True, + text=True, + timeout=5, + ) + if proc.returncode == 0: + ip = proc.stdout.strip() + if ip: + return ip + except Exception: + return None + return None + + +def _parse_target(target: str) -> Tuple[str, str]: + if ":" in target: + host, port = target.rsplit(":", 1) + else: + host, port = target, str(REDIRECT_PORT) + return host, port + + +def _cleanup_rules_with_comment(table: Optional[str], comment: str, iptables_path: str, dry_run: bool, log_file: Optional[Path]) -> int: + """Remove existing rules that contain the given comment.""" + args = [iptables_path] + if table: + args += ["-t", table] + args += ["-S"] + try: + proc = subprocess.run(args, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + log(f"[warn] failed to list rules for cleanup: {e}", log_file) + return 0 + + removed = 0 + for line in proc.stdout.splitlines(): + if comment not in line: + continue + try: + tokens = shlex.split(line.strip()) + if not tokens: + continue + tokens[0] = "-D" # replace -A + del_args = [iptables_path] + if table: + del_args += ["-t", table] + del_args += tokens + res = run_iptables(del_args, dry_run) + if res.returncode == 0: + removed += 1 + except Exception: + continue + if removed: + log(f"[cleanup] removed {removed} rule(s) with comment '{comment}' from {table or 'filter'}", log_file) + return removed + + +def _cleanup_rules_containing(table: Optional[str], needle: str, iptables_path: str, dry_run: bool, log_file: Optional[Path]) -> int: + """Remove existing rules whose text contains the needle.""" + args = [iptables_path] + if table: + args += ["-t", table] + args += ["-S"] + try: + proc = subprocess.run(args, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + log(f"[warn] failed to list rules for cleanup: {e}", log_file) + return 0 + + removed = 0 + for line in proc.stdout.splitlines(): + if needle not in line: + continue + try: + tokens = shlex.split(line.strip()) + if not tokens: + continue + tokens[0] = "-D" + del_args = [iptables_path] + if table: + del_args += ["-t", table] + del_args += tokens + res = run_iptables(del_args, dry_run) + if res.returncode == 0: + removed += 1 + except Exception: + continue + if removed: + log(f"[cleanup] removed {removed} rule(s) containing '{needle}' from {table or 'filter'}", log_file) + return removed + + +def cleanup_existing_rules(iptables_path: str, dry_run: bool, log_file: Optional[Path]) -> None: + """Clear old honeypot redirect/accept rules on startup.""" + _cleanup_rules_with_comment("nat", COMMENT_PREFIX, iptables_path, dry_run, log_file) + _cleanup_rules_with_comment(None, ACCEPT_COMMENT, iptables_path, dry_run, log_file) + # Legacy 172.16.10.10 DNAT (bridge IP時代) を除去 + _cleanup_rules_containing("nat", "172.16.10.10", iptables_path, dry_run, log_file) + # 誤った 127.0.0.1:22 DNAT も除去 + _cleanup_rules_containing("nat", "127.0.0.1:22", iptables_path, dry_run, log_file) + + +def add_rule(ip: str, iface: str, iptables_path: str, dry_run: bool, log_file: Optional[Path]) -> None: + args = [iptables_path, "-t", "nat", "-I", "PREROUTING", "1"] + build_rule_spec(ip, iface) + proc = run_iptables(args, dry_run) + if proc.returncode == 0: + log(f"[add] redirect {ip} -> {TARGET_DEST}", log_file) + else: + log(f"[error] failed to add rule for {ip}: {proc.stderr.strip()}", log_file) + + +def delete_rule(ip: str, iface: str, iptables_path: str, dry_run: bool, log_file: Optional[Path]) -> None: + args = [iptables_path, "-t", "nat", "-D", "PREROUTING"] + build_rule_spec(ip, iface) + proc = run_iptables(args, dry_run) + if proc.returncode == 0: + log(f"[del] redirect removed for {ip}", log_file) + else: + log(f"[warn] failed to delete rule for {ip}: {proc.stderr.strip()}", log_file) + + +def match_alert(data: Dict[str, Any], sigs: set[str], cats: set[str], sids: set[int]) -> bool: + if data.get("event_type") != "alert": + return False + alert = data.get("alert") or {} + if sigs and str(alert.get("signature") or "").strip() not in sigs: + return False + if cats and str(alert.get("category") or "").strip() not in cats: + return False + if sids: + try: + sid = int(alert.get("signature_id")) + except Exception: + return False + if sid not in sids: + return False + return True + + +def tail_lines(path: Path) -> Iterable[str]: + """Minimal tail -F behavior (rotation tolerant).""" + last_inode = None + offset_end = True + while True: + try: + with path.open("r", encoding="utf-8") as f: + if offset_end: + f.seek(0, os.SEEK_END) + while True: + line = f.readline() + if line: + yield line + else: + time.sleep(0.2) + # detect rotation + try: + stat = path.stat() + if last_inode is None: + last_inode = stat.st_ino + elif stat.st_ino != last_inode: + last_inode = stat.st_ino + offset_end = False + break + except FileNotFoundError: + time.sleep(0.5) + break + except FileNotFoundError: + time.sleep(0.5) + continue + except Exception: + time.sleep(0.5) + continue + + +def cleanup_rules( + ip_map: OrderedDict[str, float], + now: float, + hold_seconds: int, + iface: str, + iptables_path: str, + dry_run: bool, + log_file: Optional[Path], +) -> None: + to_delete = [ip for ip, ts in list(ip_map.items()) if now - ts > hold_seconds] + for ip in to_delete: + delete_rule(ip, iface, iptables_path, dry_run, log_file) + ip_map.pop(ip, None) + + +def enforce_max_ips( + ip_map: OrderedDict[str, float], + max_ips: int, + iface: str, + iptables_path: str, + dry_run: bool, + log_file: Optional[Path], +) -> None: + while len(ip_map) > max_ips: + ip, _ = ip_map.popitem(last=False) + delete_rule(ip, iface, iptables_path, dry_run, log_file) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Suricata SSH alert -> iptables redirect to OpenCanary") + parser.add_argument("--eve-path", default="/var/log/suricata/eve.json") + parser.add_argument("--iface", default="eth0") + parser.add_argument("--iptables", default="/sbin/iptables") + parser.add_argument("--hold-seconds", type=int, default=600) + parser.add_argument("--max-ips", type=int, default=100) + parser.add_argument("--signature", action="append", default=[], help="exact match of alert.signature (repeatable)") + parser.add_argument("--category", action="append", default=[], help="exact match of alert.category (repeatable)") + parser.add_argument("--sid", type=int, action="append", default=[], help="alert.signature_id (repeatable)") + parser.add_argument("--log-file", type=Path, default=None) + parser.add_argument("--dry-run", action="store_true", help="do not execute iptables, only log") + parser.add_argument("--skip-ensure-accept", action="store_true", help="do not add INPUT accept rule for DNATed 2222") + parser.add_argument("--target", default="auto", help="DNAT target host:port (default: auto -> docker inspect azazel_opencanary, fallback 127.0.0.1:2222)") + parser.add_argument("--container-name", default="azazel_opencanary", help="container to inspect when target=auto") + args = parser.parse_args() + + sigs = {s.strip() for s in args.signature if s.strip()} + cats = {c.strip() for c in args.category if c.strip()} + sids = {int(s) for s in args.sid if s is not None} + + log_file = args.log_file + ip_map: OrderedDict[str, float] = OrderedDict() + eve_path = Path(args.eve_path) + global TARGET_DEST + if args.target == "auto": + auto_ip = _detect_container_ip(args.container_name) + if auto_ip: + TARGET_DEST = f"{auto_ip}:{REDIRECT_PORT}" + log(f"[auto] resolved container {args.container_name} -> {TARGET_DEST}", log_file) + else: + TARGET_DEST = DEFAULT_TARGET + log(f"[auto] failed to resolve {args.container_name}; fallback {TARGET_DEST}", log_file) + else: + host, port = _parse_target(args.target) + TARGET_DEST = f"{host}:{port}" + log(f"[config] using target {TARGET_DEST}", log_file) + + log(f"start watching {eve_path} (iface={args.iface}, hold={args.hold_seconds}s, max_ips={args.max_ips}, target={TARGET_DEST})", log_file) + + # 初期化: 過去のリダイレクト/ACCEPTルールを掃除 + cleanup_existing_rules(args.iptables, args.dry_run, log_file) + + if not args.skip_ensure_accept: + ensure_accept_rule(args.iface, args.iptables, args.dry_run, log_file) + + last_cleanup = 0.0 + for line in tail_lines(eve_path): + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + except Exception: + continue + if not match_alert(data, sigs, cats, sids): + continue + + src_ip = data.get("src_ip") or data.get("srcip") + if not src_ip: + continue + + now = time.time() + # update order: move to end to mark recent + if src_ip in ip_map: + ip_map.move_to_end(src_ip) + ip_map[src_ip] = now + + if not rule_exists(src_ip, args.iface, args.iptables): + add_rule(src_ip, args.iface, args.iptables, args.dry_run, log_file) + + enforce_max_ips(ip_map, args.max_ips, args.iface, args.iptables, args.dry_run, log_file) + + if now - last_cleanup > 5: + cleanup_rules(ip_map, now, args.hold_seconds, args.iface, args.iptables, args.dry_run, log_file) + last_cleanup = now + + return 0 + + +if __name__ == "__main__": + sys.exit(main())