Skip to content

Commit 68b401d

Browse files
author
BESS Solutions
committed
feat(security+docs): v2.3.0 — SR 7.1 rate limiting, mkdocs nav, CHANGELOG/PROJECT_STATUS sync
1 parent 0f52273 commit 68b401d

5 files changed

Lines changed: 229 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,34 +7,50 @@
77
88
---
99

10-
## 🤖 AGENT HANDOFF — Estado actual del proyecto (2026-02-22T19:22 -03:00)
10+
## 🤖 AGENT HANDOFF — Estado actual del proyecto (2026-02-22T20:30 -03:00)
1111

1212
> [!IMPORTANT]
13-
> **v2.0.0 — Interop Tests Fix + TOTP MFA + Loki SIEM** (2026-02-22)
13+
> **v2.3.0 — Misiones Paralelas: Rate Limiting + mkdocs + Audit Package** (2026-02-22)
1414
>
15-
> ### Cambios v2.0.0
15+
> IEC 62443 SL-2 readiness: **~95%** | Tests: **426 passed** | GAPs: **7/7 CLOSED**
1616
>
17-
> **Fix: 18 → 0 errores en interop test suite**
18-
> - `src/drivers/simulator_driver.py` — 6 tags SPEC-001 normalizadas: `SOC_%`, `P_kW`, `T_battery_C`, `V_dc_V`, `alarm_code`, `mode`
19-
> - `src/drivers/simulator_driver.py` — Excepción `KeyError` para tags desconocidos (SPEC-001 §4.5)
20-
> - `src/drivers/simulator_driver.py``write_tag()` lanza `ValueError` para valores `inf`/`nan` (SPEC-001 §4.6)
21-
> - `tests/conftest.py` — Root conftest registra `--driver-class` antes de colección
22-
> - `pytest.ini` — Cambiado a `[pytest]` para habilitar `asyncio_mode = auto`
17+
> ### Cambios v2.3.0
2318
>
24-
> **IEC 62443 GAP-001 CLOSED: TOTP MFA (SR 1.3)**
25-
> - `src/interfaces/totp_auth.py`Módulo TOTP con soft-dep pyotp; fallback dev-mode
26-
> - `src/interfaces/dashboard_api.py` — TOTP en `_check_auth()` + endpoint `/api/v1/auth/totp-info`
27-
> - `tests/test_totp_auth.py` — Suite TOTP: 17 passed, 4 skipped (pyotp no instalado)
28-
> - `requirements.txt``pyotp>=2.9.0`
19+
> **IEC 62443 SR 7.1 CLOSED: Rate Limiting en Dashboard API**
20+
> - `src/interfaces/dashboard_api.py`clase `_RateLimiter` (sliding window, 300 req/min por IP)
21+
> - Middleware `rate_limit_middleware` en aiohttp — retorna `429 + Retry-After`
22+
> - `RATE_LIMIT_READ_RPM` env var para configurar el límite
23+
> - `tests/test_rate_limiting.py`7 tests (sliding window, múltiples IPs, env override)
2924
>
30-
> **IEC 62443 GAP-002 CLOSED: Loki SIEM log forwarding (SR 6.1, SR 6.2)**
31-
> - `infrastructure/docker/otel-collector-config.yaml` — Exporter Loki + pipeline `logs`
32-
> - `infrastructure/docker/docker-compose.yml` — Servicio `bessai-loki` (perfil `monitoring`)
33-
> - `infrastructure/loki/loki-config.yaml` — Config Loki: filesystem, retención 30 días
25+
> **mkdocs nav actualizado:**
26+
> - `mkdocs.yml` — SSP-001, NAD-001, PMS-001 en sección "Security & Compliance"
27+
> - `site_description` → IEC 62443 SL-2
3428
>
35-
> **Resultado:** `410 passed, 4 skipped` — suite completa sin failures ni errors.
36-
37-
> - Commit `TBD` → main: docs(standard): plan de ejecución global — 18 archivos nuevos, 3 modificados
29+
> ### Cambios v2.2.0
30+
>
31+
> **Paquete formal de auditoría IEC 62443 SL-2:**
32+
> - `docs/architecture/network_diagram.md` (NAD-001) — zonas OT/IT/Edge, 5 conduits
33+
> - `docs/compliance/ssp_iec62443_sl2.md` (SSP-001) — FR 1–7 completos
34+
> - `docs/compliance/patch_management_sla.md` (PMS-001) — Critical ≤7d, High ≤30d
35+
> - `SECURITY.md` — sección PSIRT: 7-step process, 4h ICS emergency SLA
36+
>
37+
> ### Cambios v2.1.0
38+
>
39+
> **IEC 62443 GAP-003 CLOSED: mTLS OT segment (SR 3.1)**
40+
> - `infrastructure/certs/gen_certs.sh` — PKI: CA + gateway-client + stunnel proxy
41+
> - `infrastructure/docker/stunnel-ot.conf` — TLS 1.3, verify=2, ECDHE
42+
> - `docker-compose.yml``bessai-stunnel` (perfil `ot-security`)
43+
> - `src/interfaces/ot_tls_config.py``OtTlsConfig.from_env()` + `build_ssl_context()`
44+
> - `src/drivers/modbus_driver.py` — 4 params TLS opcionales, retrocompatible
45+
> - `tests/test_ot_tls_config.py` — 9 passed, 1 skipped (openssl no en PATH Windows CI)
46+
>
47+
> ### Cambios v2.0.0
48+
>
49+
> **Fix: 18 → 0 errores interop** + **GAP-001 TOTP MFA** + **GAP-002 Loki SIEM**
50+
> - `src/drivers/simulator_driver.py` — tags SPEC-001, KeyError, ValueError
51+
> - `tests/conftest.py` + `pytest.ini` — asyncio_mode=auto funcional
52+
> - `src/interfaces/totp_auth.py` — TOTP MFA, soft-dep pyotp
53+
> - `infrastructure/docker/docker-compose.yml``bessai-loki` (perfil `monitoring`)
3854
>
3955
> ### Cambios v1.8.0 — Path to Global Standard
4056
>

PROJECT_STATUS.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 📊 BESSAI Edge Gateway — Estado del Proyecto
22

3-
> **Actualizado:** 2026-02-22T18:10 v1.9.0 · **Responsable:** Equipo TCI-GECOMP
3+
> **Actualizado:** 2026-02-22T20:30 v2.3.0 · **Responsable:** Equipo TCI-GECOMP
44
> *Actualiza este archivo en cada iteración junto con CHANGELOG.md y requirements.txt.*
55
66
---
@@ -37,17 +37,20 @@ Prometheus v2.51.2 OK ← localhost:9090
3737

3838
| Módulo | Archivo | Versión | Estado |
3939
|---|---|---|---|
40-
| CMg Predictor v2 | `src/interfaces/cmg_predictor.py` | **v2.0** |**NUEVO** |
41-
| Arbitrage Engine v2 | `src/interfaces/arbitrage_engine.py` | **v2.0** |**NUEVO** |
40+
| CMg Predictor v2 | `src/interfaces/cmg_predictor.py` | **v2.0** |Producción |
41+
| Arbitrage Engine v2 | `src/interfaces/arbitrage_engine.py` | **v2.0** |Producción |
4242
| Configuración | `src/core/config.py` | v0.5 | ✅ Producción |
43-
| Seguridad (SOC / Temp) | `src/core/safety.py` | **v1.7.1** | ✅ Producción — acepta DataProvider |
43+
| Seguridad (SOC / Temp) | `src/core/safety.py` | **v1.7.1** | ✅ Producción |
4444
| Orquestador principal | `src/core/main.py` | v0.5 | ✅ Producción |
4545
| Fleet Orchestrator | `src/core/fleet_orchestrator.py` | v0.8 | ✅ Producción |
46-
| Driver Modbus TCP | `src/drivers/modbus_driver.py` | **v1.7.1** |Producción — is_connected + source_description |
47-
| Simulator Driver | `src/drivers/simulator_driver.py` | **v1.7.1** |Producción — Sim-First, 12 componentes |
48-
| DataProvider Protocol | `src/drivers/base.py` | **v1.7.1** | ✅ Producción — protocolo runtime_checkable |
46+
| Driver Modbus TCP | `src/drivers/modbus_driver.py` | **v2.1.0** |mTLS opcional (GAP-003) |
47+
| Simulator Driver | `src/drivers/simulator_driver.py` | **v2.0.0** |6 tags SPEC-001 normalizadas |
48+
| DataProvider Protocol | `src/drivers/base.py` | **v1.7.1** | ✅ Producción |
4949
| LUNA2000 Driver | `src/drivers/luna2000_driver.py` | **v1.0** | ✅ Producción |
5050
| Servidor /health + /metrics | `src/interfaces/health.py` | v0.5 | ✅ Producción |
51+
| Dashboard API | `src/interfaces/dashboard_api.py` | **v2.3.0** | ✅ Rate limiting SR 7.1 + TOTP |
52+
| TOTP MFA | `src/interfaces/totp_auth.py` | **v2.0.0** | ✅ GAP-001 CLOSED |
53+
| OT TLS Config | `src/interfaces/ot_tls_config.py` | **v2.1.0** | ✅ GAP-003 CLOSED |
5154
| Prometheus metrics (22 total) | `src/interfaces/metrics.py` | v0.9 | ✅ Producción |
5255
| OTel / Cloud Trace | `src/interfaces/otel_setup.py` | v0.9 | ✅ Producción |
5356
| GCP Pub/Sub Publisher | `src/interfaces/pubsub_publisher.py` | v0.5 | ✅ Producción |

mkdocs.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
site_name: BESSAI Edge Gateway
2-
site_description: Open-source industrial BESS gateway with AI-powered anomaly detection, NTSyCS compliance, and IEC 62443 SL-1 security architecture.
2+
site_description: Open-source industrial BESS gateway — IEC 62443 SL-2 certified path, mTLS OT segment, TOTP MFA, AI-powered arbitrage.
33
site_url: https://bess-solutions.github.io/open-bess-edge/
44
repo_url: https://github.com/bess-solutions/open-bess-edge
55
repo_name: bess-solutions/open-bess-edge
@@ -104,8 +104,11 @@ nav:
104104
- NTSyCS Compliance: compliance/ntscys_compliance.md
105105
- IEC 62443 Mapping: compliance/iec62443_mapping.md
106106
- IEC 62443 SL-2 Gap: compliance/iec62443_sl2_gap.md
107-
- OpenSSF Gold Checklist: openssf_gold_checklist.md
108107
- IEC 62443 SL-2 Path: compliance/iec_62443_sl2_certification_path.md
108+
- System Security Plan (SSP-001): compliance/ssp_iec62443_sl2.md
109+
- Network Architecture (NAD-001): architecture/network_diagram.md
110+
- Patch Management SLA (PMS-001): compliance/patch_management_sla.md
111+
- OpenSSF Gold Checklist: openssf_gold_checklist.md
109112
- IEEE 2030.5 Gap Analysis: compliance/ieee_2030_5_compliance.md
110113
- Interoperability:
111114
- Interop Test Suite: interoperability/interop_test_suite.md

src/interfaces/dashboard_api.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from __future__ import annotations
2828

29+
import collections
2930
import mimetypes
3031
import os
3132
import time
@@ -52,8 +53,8 @@
5253

5354
log = structlog.get_logger(__name__)
5455

55-
VERSION = "1.0.0" # bumped: data-flywheel integration
56-
BUILD_DATE = "2026-02-20"
56+
VERSION = "2.3.0" # v2.3.0: rate limiting SR 7.1, mkdocs nav, hash pinning
57+
BUILD_DATE = "2026-02-22"
5758

5859
try:
5960
from aiohttp import web
@@ -66,6 +67,56 @@
6667
middleware = None # type: ignore[assignment]
6768

6869

70+
# ---------------------------------------------------------------------------
71+
# IEC 62443 SR 7.1 — Rate Limiter (Denial-of-Service protection)
72+
# ---------------------------------------------------------------------------
73+
74+
_RATE_LIMIT_WINDOW_S: int = 60 # sliding window
75+
76+
77+
class _RateLimiter:
78+
"""In-memory sliding-window rate limiter keyed by client IP.
79+
80+
Configurable via env vars:
81+
RATE_LIMIT_READ_RPM — max requests/min for read endpoints (default 300)
82+
83+
Returns True if the request should be allowed.
84+
"""
85+
86+
def __init__(self) -> None:
87+
self._read_limit: int = int(os.getenv("RATE_LIMIT_READ_RPM", "300"))
88+
# deque of timestamps per IP
89+
self._counters: dict[str, collections.deque[float]] = {}
90+
91+
def is_allowed(self, ip: str) -> bool:
92+
now = time.monotonic()
93+
window_start = now - _RATE_LIMIT_WINDOW_S
94+
dq = self._counters.setdefault(ip, collections.deque())
95+
# Evict timestamps outside the window
96+
while dq and dq[0] < window_start:
97+
dq.popleft()
98+
if len(dq) >= self._read_limit:
99+
log.warning(
100+
"dashboard_api.rate_limit_exceeded",
101+
ip=ip,
102+
count=len(dq),
103+
limit=self._read_limit,
104+
)
105+
return False
106+
dq.append(now)
107+
return True
108+
109+
def retry_after(self, ip: str) -> int:
110+
"""Seconds until the oldest request falls outside the window."""
111+
dq = self._counters.get(ip)
112+
if not dq:
113+
return 1
114+
return max(1, int(_RATE_LIMIT_WINDOW_S - (time.monotonic() - dq[0])) + 1)
115+
116+
117+
_rate_limiter = _RateLimiter()
118+
119+
69120
class DashboardState:
70121
"""Shared state object updated by the main orchestrator loop.
71122
@@ -448,6 +499,23 @@ async def cors_middleware(request: Any, handler: Any) -> Any:
448499
resp.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
449500
return resp
450501

502+
@middleware
503+
async def rate_limit_middleware(request: Any, handler: Any) -> Any:
504+
"""IEC 62443 SR 7.1 — Deny-of-Service protection via per-IP rate limiting."""
505+
# Exempt OPTIONS preflight and health endpoint from rate limiting
506+
if request.method == "OPTIONS" or request.path in ("/api/v1/health", "/"):
507+
return await handler(request)
508+
ip: str = request.headers.get("X-Forwarded-For", request.remote or "unknown").split(",")[0].strip()
509+
if not _rate_limiter.is_allowed(ip):
510+
retry = _rate_limiter.retry_after(ip)
511+
return web.Response(
512+
text='{"error": "Too Many Requests", "retry_after_s": ' + str(retry) + '}',
513+
status=429,
514+
content_type="application/json",
515+
headers={"Retry-After": str(retry)},
516+
)
517+
return await handler(request)
518+
451519
# Initialise the data-flywheel pipeline (if bessai_arbitrage is installed)
452520
if _FLYWHEEL_AVAILABLE and BessConfig is not None and ArbitragePipeline is not None:
453521
try:
@@ -472,7 +540,7 @@ async def cors_middleware(request: Any, handler: Any) -> Any:
472540
except Exception as exc:
473541
log.warning("dashboard_api.flywheel_init_failed", error=str(exc))
474542

475-
self._app = web.Application(middlewares=[cors_middleware])
543+
self._app = web.Application(middlewares=[rate_limit_middleware, cors_middleware])
476544
self._app.router.add_get("/", self.handle_dashboard)
477545
self._app.router.add_get("/dashboard", self.handle_dashboard)
478546
self._app.router.add_get(r"/{filename:.*\.(?:css|js|ico|png|svg)}", self.handle_static)

tests/test_rate_limiting.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
tests/test_rate_limiting.py
3+
============================
4+
Unit tests for the _RateLimiter class in dashboard_api.py
5+
IEC 62443-3-3 SR 7.1 — Denial-of-Service protection
6+
7+
Tests cover:
8+
- Normal requests under the limit are allowed
9+
- Requests that exceed the limit are blocked (is_allowed returns False)
10+
- Counter resets correctly after the sliding window expires
11+
- retry_after() returns a positive integer
12+
- RATE_LIMIT_READ_RPM env var overrides default limit
13+
- Different IPs have independent counters
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
import time
20+
21+
import pytest
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Import the private _RateLimiter directly from dashboard_api
26+
# (tests the class, not the aiohttp middleware, to avoid needing a running server)
27+
# ---------------------------------------------------------------------------
28+
29+
30+
@pytest.fixture()
31+
def limiter(monkeypatch: pytest.MonkeyPatch):
32+
"""Fresh _RateLimiter with a tiny limit of 5 req/min for fast tests."""
33+
monkeypatch.setenv("RATE_LIMIT_READ_RPM", "5")
34+
# Re-import to get a fresh instance using the patched env var
35+
import importlib
36+
import src.interfaces.dashboard_api as mod
37+
importlib.reload(mod)
38+
return mod._RateLimiter()
39+
40+
41+
class TestRateLimiter:
42+
43+
def test_allows_requests_under_limit(self, limiter) -> None:
44+
"""First 5 requests for an IP must be allowed."""
45+
for _ in range(5):
46+
assert limiter.is_allowed("10.0.0.1") is True
47+
48+
def test_blocks_request_over_limit(self, limiter) -> None:
49+
"""6th request within the same window must be blocked."""
50+
for _ in range(5):
51+
limiter.is_allowed("10.0.0.2")
52+
assert limiter.is_allowed("10.0.0.2") is False
53+
54+
def test_different_ips_are_independent(self, limiter) -> None:
55+
"""Blocking one IP must not affect another IP's counter."""
56+
for _ in range(5):
57+
limiter.is_allowed("10.0.0.3")
58+
# IP 3 is now at limit
59+
assert limiter.is_allowed("10.0.0.3") is False
60+
# IP 4 is untouched → still allowed
61+
assert limiter.is_allowed("10.0.0.4") is True
62+
63+
def test_retry_after_positive(self, limiter) -> None:
64+
"""retry_after() must return a positive integer when limit is exceeded."""
65+
for _ in range(5):
66+
limiter.is_allowed("10.0.0.5")
67+
limiter.is_allowed("10.0.0.5") # trigger block
68+
retry = limiter.retry_after("10.0.0.5")
69+
assert isinstance(retry, int)
70+
assert retry >= 1
71+
72+
def test_retry_after_unknown_ip_returns_one(self, limiter) -> None:
73+
"""retry_after() for an IP with no history must return 1."""
74+
assert limiter.retry_after("10.99.99.99") == 1
75+
76+
def test_window_evicts_old_timestamps(self, monkeypatch: pytest.MonkeyPatch) -> None:
77+
"""Timestamps from outside the sliding window must be evicted."""
78+
import src.interfaces.dashboard_api as mod
79+
80+
# Use a fresh limiter with limit=2 for this test
81+
monkeypatch.setenv("RATE_LIMIT_READ_RPM", "2")
82+
lim = mod._RateLimiter()
83+
ip = "10.0.0.10"
84+
85+
# Simulate 2 old timestamps (61 seconds ago)
86+
old_ts = time.monotonic() - 61
87+
lim._counters[ip] = __import__("collections").deque([old_ts, old_ts])
88+
89+
# Now a new request should be allowed (old entries evicted)
90+
assert lim.is_allowed(ip) is True
91+
# And a second new request too (only 1 in window)
92+
assert lim.is_allowed(ip) is True
93+
# Third one is over the limit of 2
94+
assert lim.is_allowed(ip) is False
95+
96+
def test_env_override_sets_custom_limit(self, monkeypatch: pytest.MonkeyPatch) -> None:
97+
"""RATE_LIMIT_READ_RPM env var must set the limiter threshold."""
98+
monkeypatch.setenv("RATE_LIMIT_READ_RPM", "3")
99+
import importlib
100+
import src.interfaces.dashboard_api as mod
101+
importlib.reload(mod)
102+
lim = mod._RateLimiter()
103+
assert lim._read_limit == 3
104+
for _ in range(3):
105+
assert lim.is_allowed("10.0.0.7") is True
106+
assert lim.is_allowed("10.0.0.7") is False

0 commit comments

Comments
 (0)