fix(iwooos): 接上 Wazuh 只讀 API 邊界
This commit is contained in:
165
apps/api/src/api/v1/iwooos.py
Normal file
165
apps/api/src/api/v1/iwooos.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
IwoooS 安全治理 API。
|
||||
|
||||
Wazuh 接線採用只讀 metadata 模式:預設關閉、不保存 raw payload、
|
||||
不公開 agent 原名 / 內網 IP、不啟用 active response。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from base64 import b64encode
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
router = APIRouter(tags=["IwoooS Security"])
|
||||
REQUEST_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _wazuh_env() -> dict[str, str]:
|
||||
return {
|
||||
"enabled": os.getenv("IWOOOS_WAZUH_READONLY_ENABLED", "").strip().lower(),
|
||||
"base_url": os.getenv("WAZUH_API_BASE_URL", "").strip(),
|
||||
"username": os.getenv("WAZUH_API_USERNAME", "").strip(),
|
||||
"password": os.getenv("WAZUH_API_PASSWORD", "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _https_url(value: str) -> str | None:
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme != "https" or not parsed.netloc:
|
||||
return None
|
||||
return value.rstrip("/") + "/"
|
||||
|
||||
|
||||
def _boundary_response(status_text: str, http_status: int = 200) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=http_status,
|
||||
content={
|
||||
"schema_version": "iwooos_wazuh_readonly_status_v1",
|
||||
"status": status_text,
|
||||
"mode": "metadata_only_no_active_response_no_raw_payload",
|
||||
"configured": False,
|
||||
"summary": {
|
||||
"wazuh_platform_reported_count": 1,
|
||||
"readonly_api_enabled_count": 0,
|
||||
"wazuh_manager_query_accepted_count": 0,
|
||||
"wazuh_event_accepted_count": 0,
|
||||
"host_forensics_accepted_count": 0,
|
||||
"active_response_authorized_count": 0,
|
||||
"host_write_authorized_count": 0,
|
||||
"runtime_gate_count": 0,
|
||||
},
|
||||
"boundaries": _boundaries(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _boundaries() -> dict[str, bool]:
|
||||
return {
|
||||
"active_response_authorized": False,
|
||||
"host_write_authorized": False,
|
||||
"secret_value_collection_allowed": False,
|
||||
"raw_wazuh_payload_storage_allowed": False,
|
||||
"agent_identity_public_display_allowed": False,
|
||||
"internal_ip_public_display_allowed": False,
|
||||
"not_authorization": True,
|
||||
}
|
||||
|
||||
|
||||
def _redacted_agent(agent: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
os_info = agent.get("os") if isinstance(agent.get("os"), dict) else {}
|
||||
return {
|
||||
"alias": f"agent-{index + 1:02d}",
|
||||
"status": agent.get("status", "unknown"),
|
||||
"os": os_info.get("platform") or os_info.get("name") or "unknown",
|
||||
"last_seen_present": bool(agent.get("lastKeepAlive")),
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_json(client: httpx.AsyncClient, url: str, headers: dict[str, str]) -> dict[str, Any]:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
async def _wazuh_readonly_status() -> JSONResponse:
|
||||
env = _wazuh_env()
|
||||
if env["enabled"] != "true":
|
||||
return _boundary_response("disabled_waiting_iwooos_wazuh_owner_gate")
|
||||
|
||||
base_url = _https_url(env["base_url"])
|
||||
if not base_url or not env["username"] or not env["password"]:
|
||||
return _boundary_response("misconfigured_missing_server_side_wazuh_env", 503)
|
||||
|
||||
try:
|
||||
auth_header = b64encode(f"{env['username']}:{env['password']}".encode("utf-8")).decode("ascii")
|
||||
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client:
|
||||
auth = await _fetch_json(
|
||||
client,
|
||||
urljoin(base_url, "security/user/authenticate"),
|
||||
{"Authorization": f"Basic {auth_header}"},
|
||||
)
|
||||
token = (auth.get("data") or {}).get("token")
|
||||
if not token:
|
||||
return _boundary_response("wazuh_auth_token_missing", 502)
|
||||
|
||||
bearer_headers = {"Authorization": f"Bearer {token}"}
|
||||
status_payload = await _fetch_json(
|
||||
client,
|
||||
urljoin(base_url, "agents/summary/status"),
|
||||
bearer_headers,
|
||||
)
|
||||
agents_payload = await _fetch_json(
|
||||
client,
|
||||
urljoin(base_url, "agents?limit=100&select=id,status,os.name,os.platform,lastKeepAlive"),
|
||||
bearer_headers,
|
||||
)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return _boundary_response("wazuh_readonly_metadata_unavailable", 502)
|
||||
|
||||
connection = ((status_payload.get("data") or {}).get("connection") or {})
|
||||
affected_items = ((agents_payload.get("data") or {}).get("affected_items") or [])
|
||||
if not isinstance(affected_items, list):
|
||||
affected_items = []
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"schema_version": "iwooos_wazuh_readonly_status_v1",
|
||||
"status": "readonly_metadata_available",
|
||||
"mode": "metadata_only_no_active_response_no_raw_payload",
|
||||
"configured": True,
|
||||
"summary": {
|
||||
"wazuh_platform_reported_count": 1,
|
||||
"readonly_api_enabled_count": 1,
|
||||
"agent_total": connection.get("total", len(affected_items)),
|
||||
"agent_active": connection.get("active", 0),
|
||||
"agent_disconnected": connection.get("disconnected", 0),
|
||||
"agent_pending": connection.get("pending", 0),
|
||||
"wazuh_manager_query_accepted_count": 0,
|
||||
"wazuh_event_accepted_count": 0,
|
||||
"host_forensics_accepted_count": 0,
|
||||
"active_response_authorized_count": 0,
|
||||
"host_write_authorized_count": 0,
|
||||
"runtime_gate_count": 0,
|
||||
},
|
||||
"agents": [_redacted_agent(agent, index) for index, agent in enumerate(affected_items[:20])],
|
||||
"boundaries": _boundaries(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/iwooos/wazuh")
|
||||
async def get_iwooos_wazuh_readonly_status_compat() -> JSONResponse:
|
||||
return await _wazuh_readonly_status()
|
||||
|
||||
|
||||
@router.get("/api/v1/iwooos/wazuh")
|
||||
async def get_iwooos_wazuh_readonly_status_v1() -> JSONResponse:
|
||||
return await _wazuh_readonly_status()
|
||||
@@ -60,6 +60,7 @@ from src.api.v1 import (
|
||||
# Import API routers
|
||||
from src.api.v1 import health as health_v1
|
||||
from src.api.v1 import incidents as incidents_v1 # Phase 6.4: Decision Proposal
|
||||
from src.api.v1 import iwooos as iwooos_v1 # IwoooS security governance API
|
||||
from src.api.v1 import knowledge as knowledge_v1 # KB Phase 1: Knowledge Base
|
||||
from src.api.v1 import learning as learning_v1 # Phase D-G P0: Learning API
|
||||
from src.api.v1 import metrics as metrics_v1 # Phase 7: Gold Metrics (真實血脈)
|
||||
@@ -1035,6 +1036,7 @@ async def global_exception_handler(_request: Request, exc: Exception) -> JSONRes
|
||||
# =============================================================================
|
||||
|
||||
# New v1 API routes
|
||||
app.include_router(iwooos_v1.router, tags=["IwoooS Security"])
|
||||
app.include_router(health_v1.router, prefix="/api/v1", tags=["Health"])
|
||||
app.include_router(csrf_v1.router, prefix="/api/v1", tags=["Security"]) # Phase 20
|
||||
app.include_router(dashboard_v1.router, prefix="/api/v1", tags=["Dashboard"])
|
||||
|
||||
Reference in New Issue
Block a user