From 2e26f75e3584f3fbdfe1dcd96cdc4768518025e6 Mon Sep 17 00:00:00 2001 From: zuudevs Date: Fri, 6 Mar 2026 21:12:22 +0700 Subject: [PATCH] feat: Implement firmware event logging for forensic analysis and establish initial backend structure with API routes, models, schemas, and anomaly detection, alongside comprehensive project documentation. Refs #31 Closes #33 --- .gitignore | 1 + README.md | 6 +- backend/app/anomaly_engine.py | 106 +++++ backend/app/main.py | 9 +- backend/app/meters.py | 148 ++++++ backend/app/models.py | 15 + backend/app/routes.py | 16 + backend/app/schemas.py | 41 ++ backend/requirements.txt | 2 + backend/test_gridshield.db | Bin 0 -> 53248 bytes backend/test_results.txt | Bin 0 -> 10718 bytes backend/tests/__init__.py | 0 backend/tests/conftest.py | 53 +++ backend/tests/test_alerts.py | 70 +++ backend/tests/test_anomaly_engine.py | 95 ++++ backend/tests/test_meters.py | 130 ++++++ backend/tests/test_readings.py | 97 ++++ docs/ARCHITECTURE.md | 7 +- docs/CHANGELOG.md | 27 ++ docs/IDEAS.md | 3 +- docs/JOBDESK.md | 3 + docs/PROPOSAL.md | 2 + docs/RAB.md | 3 +- docs/ROADMAP.md | 53 ++- docs/TECHSTACK.md | 3 +- docs/requirements.md | 1 + .../include/common/forensics/event_logger.hpp | 251 +++++++++++ .../common/forensics/incident_report.hpp | 249 +++++++++++ .../index/test_hkdf.cpp.DCBF07BC85EB153F.idx | Bin 9168 -> 9168 bytes firmware/test_app/main/test_forensics.cpp | 421 ++++++++++++++++++ firmware/test_app/main/test_main.cpp | 2 + 31 files changed, 1797 insertions(+), 17 deletions(-) create mode 100644 backend/app/anomaly_engine.py create mode 100644 backend/app/meters.py create mode 100644 backend/test_gridshield.db create mode 100644 backend/test_results.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_alerts.py create mode 100644 backend/tests/test_anomaly_engine.py create mode 100644 backend/tests/test_meters.py create mode 100644 backend/tests/test_readings.py create mode 100644 firmware/include/common/forensics/event_logger.hpp create mode 100644 firmware/include/common/forensics/incident_report.hpp create mode 100644 firmware/test_app/main/test_forensics.cpp diff --git a/.gitignore b/.gitignore index f24bdf3..21cb108 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ firmware/sdkconfig.defaults.* backend/gridshield.db backend/__pycache__/ backend/app/__pycache__/ +backend/tests/__pycache__/ backend/.venv/ # Frontend diff --git a/README.md b/README.md index 65ce123..a2acd69 100644 --- a/README.md +++ b/README.md @@ -150,10 +150,12 @@ This project is licensed under the **MIT License** - see [LICENSE](LICENSE) file ## 👥 Authors -- **Muhammad Ichwan Fauzi** — Team Leader, Project Manager -- **Rafi Indra Pramudhito Zuhayr** — Firmware Implementation, System Architecture +- **Muhammad Ichwan Fauzi** — Team Leader +- **Rafi Indra Pramudhito Zuhayr** — Firmware, Backend, Frontend, CI/CD, Dokumentasi, Testing, System Architecture, Project Manager - **Cesar Ardika Bhayangkara** — Hardware Integration +> **Catatan:** Seluruh implementasi teknis yang ada di repository ini (firmware, backend, frontend, CI/CD, dokumentasi, testing, dan seluruh konfigurasi project) merupakan hasil pekerjaan **Rafi Indra Pramudhito Zuhayr**. + **Institut Teknologi PLN** — 2026 ## 🌟 Acknowledgments diff --git a/backend/app/anomaly_engine.py b/backend/app/anomaly_engine.py new file mode 100644 index 0000000..8841548 --- /dev/null +++ b/backend/app/anomaly_engine.py @@ -0,0 +1,106 @@ +""" +GridShield Backend — Server-Side Anomaly Detection Engine + +Automatically analyzes incoming meter readings and flags anomalies +when consumption deviates significantly from recent patterns. +""" + +from datetime import datetime + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from . import models + +# Default thresholds +ANOMALY_THRESHOLD_PERCENT = 60.0 +CRITICAL_THRESHOLD_PERCENT = 80.0 +RECENT_READINGS_WINDOW = 10 # compare against last N readings + + +def classify_severity(deviation: float) -> str: + """Classify anomaly severity based on deviation percentage.""" + if deviation >= CRITICAL_THRESHOLD_PERCENT: + return "critical" + if deviation >= 70.0: + return "high" + if deviation >= ANOMALY_THRESHOLD_PERCENT: + return "medium" + return "low" + + +def classify_anomaly_type(current: float, expected: float) -> str: + """Classify the type of anomaly based on direction of deviation.""" + if current < 1.0: + return "ZeroConsumption" + if current < expected: + return "UnexpectedDrop" + return "UnexpectedSpike" + + +def analyze_reading(reading: models.MeterReading, db: Session) -> models.AnomalyLog | None: + """ + Analyze a newly ingested meter reading for anomalies. + + Compares the reading's energy_wh against the average of the last + N readings for the same meter. If deviation exceeds the threshold, + an AnomalyLog is auto-created and returned. + + Returns None if no anomaly is detected. + """ + # Get the average of recent readings for this meter (excluding the new one) + avg_energy = ( + db.query(func.avg(models.MeterReading.energy_wh)) + .filter( + models.MeterReading.meter_id == reading.meter_id, + models.MeterReading.id != reading.id, + ) + .scalar() + ) + + # Need at least some historical data to compare + if avg_energy is None or avg_energy < 1.0: + return None + + reading_count = ( + db.query(func.count(models.MeterReading.id)) + .filter( + models.MeterReading.meter_id == reading.meter_id, + models.MeterReading.id != reading.id, + ) + .scalar() + or 0 + ) + + # Need enough samples for meaningful comparison + if reading_count < 3: + return None + + # Calculate deviation + current_value = float(reading.energy_wh) + expected_value = float(avg_energy) + deviation = abs(current_value - expected_value) / expected_value * 100.0 + + if deviation < ANOMALY_THRESHOLD_PERCENT: + return None + + # Anomaly detected — create log entry + anomaly_type = classify_anomaly_type(current_value, expected_value) + severity = classify_severity(deviation) + confidence = min(100, int(deviation)) + + anomaly = models.AnomalyLog( + meter_id=reading.meter_id, + timestamp=datetime.utcnow(), + anomaly_type=anomaly_type, + severity=severity, + current_value=current_value, + expected_value=round(expected_value, 2), + deviation_percent=round(deviation, 2), + confidence=confidence, + ) + db.add(anomaly) + db.commit() + db.refresh(anomaly) + + return anomaly diff --git a/backend/app/main.py b/backend/app/main.py index 324dc58..ca0acc1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from .database import Base, engine +from .meters import router as meters_router from .routes import router @@ -22,8 +23,9 @@ async def lifespan(app: FastAPI): app = FastAPI( title="GridShield API", description="REST API for GridShield AMI Security System — " - "Meter data ingestion, tamper alerts, and anomaly monitoring.", - version="2.0.0", + "Meter data ingestion, tamper alerts, anomaly monitoring, " + "and fleet management.", + version="3.1.0", lifespan=lifespan, ) @@ -38,6 +40,7 @@ async def lifespan(app: FastAPI): # Routes app.include_router(router) +app.include_router(meters_router) @app.get("/", tags=["Root"]) @@ -45,6 +48,6 @@ def root(): """Health check.""" return { "name": "GridShield API", - "version": "2.0.0", + "version": "3.1.0", "status": "running", } diff --git a/backend/app/meters.py b/backend/app/meters.py new file mode 100644 index 0000000..0b23c3d --- /dev/null +++ b/backend/app/meters.py @@ -0,0 +1,148 @@ +""" +GridShield Backend — Meter Management Routes +CRUD operations for meter registration and fleet management. +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func +from sqlalchemy.orm import Session + +from . import models, schemas +from .database import get_db + +router = APIRouter(prefix="/api", tags=["Meters"]) + + +# ============================================================================ +# Meter CRUD +# ============================================================================ +@router.post("/meters", response_model=schemas.MeterResponse, status_code=201) +def register_meter(meter: schemas.MeterCreate, db: Session = Depends(get_db)): + """Register a new meter device.""" + existing = db.query(models.Meter).filter( + models.Meter.meter_id == meter.meter_id + ).first() + if existing is not None: + raise HTTPException(status_code=409, detail="Meter already registered") + db_meter = models.Meter(**meter.model_dump()) + db.add(db_meter) + db.commit() + db.refresh(db_meter) + return db_meter + + +@router.get("/meters", response_model=list[schemas.MeterResponse]) +def list_meters( + status: str | None = None, + limit: int = Query(default=100, le=500), + db: Session = Depends(get_db), +): + """List all registered meters with optional status filter.""" + query = db.query(models.Meter) + if status is not None: + query = query.filter(models.Meter.status == status) + return query.order_by(models.Meter.registered_at.desc()).limit(limit).all() + + +@router.get("/meters/{meter_id}", response_model=schemas.MeterResponse) +def get_meter(meter_id: int, db: Session = Depends(get_db)): + """Get a single meter by its hardware meter_id.""" + meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first() + if meter is None: + raise HTTPException(status_code=404, detail="Meter not found") + return meter + + +@router.patch("/meters/{meter_id}", response_model=schemas.MeterResponse) +def update_meter( + meter_id: int, + update: schemas.MeterUpdate, + db: Session = Depends(get_db), +): + """Update meter information (name, location, firmware version, status).""" + meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first() + if meter is None: + raise HTTPException(status_code=404, detail="Meter not found") + + update_data = update.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(meter, key, value) + + db.commit() + db.refresh(meter) + return meter + + +@router.delete("/meters/{meter_id}", status_code=204) +def delete_meter(meter_id: int, db: Session = Depends(get_db)): + """Deregister a meter device.""" + meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first() + if meter is None: + raise HTTPException(status_code=404, detail="Meter not found") + db.delete(meter) + db.commit() + + +# ============================================================================ +# Meter Statistics +# ============================================================================ +@router.get("/meters/{meter_id}/stats", response_model=schemas.MeterStats) +def get_meter_stats(meter_id: int, db: Session = Depends(get_db)): + """Get aggregated statistics for a specific meter.""" + meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first() + if meter is None: + raise HTTPException(status_code=404, detail="Meter not found") + + total_readings = ( + db.query(func.count(models.MeterReading.id)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + or 0 + ) + total_alerts = ( + db.query(func.count(models.TamperAlert.id)) + .filter(models.TamperAlert.meter_id == meter_id) + .scalar() + or 0 + ) + total_anomalies = ( + db.query(func.count(models.AnomalyLog.id)) + .filter(models.AnomalyLog.meter_id == meter_id) + .scalar() + or 0 + ) + + avg_energy = ( + db.query(func.avg(models.MeterReading.energy_wh)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + or 0.0 + ) + avg_voltage = ( + db.query(func.avg(models.MeterReading.voltage_mv)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + or 0.0 + ) + avg_current = ( + db.query(func.avg(models.MeterReading.current_ma)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + or 0.0 + ) + last_reading = ( + db.query(func.max(models.MeterReading.timestamp)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + ) + + return schemas.MeterStats( + meter_id=meter_id, + total_readings=total_readings, + total_alerts=total_alerts, + total_anomalies=total_anomalies, + avg_energy_wh=round(avg_energy, 2), + avg_voltage_mv=round(avg_voltage, 2), + avg_current_ma=round(avg_current, 2), + last_reading_time=last_reading, + ) diff --git a/backend/app/models.py b/backend/app/models.py index 76266bd..f31ba98 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -51,3 +51,18 @@ class AnomalyLog(Base): expected_value = Column(Float, default=0.0) deviation_percent = Column(Float, default=0.0) confidence = Column(Integer, default=0) + + +class Meter(Base): + """Registered meter device.""" + + __tablename__ = "meters" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + meter_id = Column(BigInteger, unique=True, nullable=False, index=True) + name = Column(String(100), default="") + location = Column(String(200), default="") + firmware_version = Column(String(20), default="unknown") + status = Column(String(20), default="offline") # online / offline / tampered + registered_at = Column(DateTime, default=datetime.utcnow, nullable=False) + last_seen_at = Column(DateTime, default=None, nullable=True) diff --git a/backend/app/routes.py b/backend/app/routes.py index 30bbabe..3f6aac3 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -2,11 +2,14 @@ GridShield Backend — API Routes """ +from datetime import datetime + from fastapi import APIRouter, Depends, Query from sqlalchemy import func from sqlalchemy.orm import Session from . import models, schemas +from .anomaly_engine import analyze_reading from .database import get_db router = APIRouter(prefix="/api", tags=["GridShield API"]) @@ -22,6 +25,19 @@ def create_meter_reading(reading: schemas.MeterReadingCreate, db: Session = Depe db.add(db_reading) db.commit() db.refresh(db_reading) + + # Update meter last_seen_at if registered + meter = db.query(models.Meter).filter( + models.Meter.meter_id == reading.meter_id + ).first() + if meter is not None: + meter.last_seen_at = datetime.utcnow() + meter.status = "online" + db.commit() + + # Server-side anomaly detection + analyze_reading(db_reading, db) + return db_reading diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 854d9ba..89ae38c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -90,3 +90,44 @@ class SystemStatus(BaseModel): active_meters: int unacknowledged_alerts: int latest_reading_time: datetime | None = None + + +# ============================================================================ +# Meter +# ============================================================================ +class MeterCreate(BaseModel): + meter_id: int = Field(..., description="Unique meter hardware identifier") + name: str = Field(default="", max_length=100) + location: str = Field(default="", max_length=200) + firmware_version: str = Field(default="unknown", max_length=20) + + +class MeterUpdate(BaseModel): + name: str | None = Field(default=None, max_length=100) + location: str | None = Field(default=None, max_length=200) + firmware_version: str | None = Field(default=None, max_length=20) + status: str | None = Field(default=None, max_length=20) + + +class MeterResponse(BaseModel): + id: int + meter_id: int + name: str + location: str + firmware_version: str + status: str + registered_at: datetime + last_seen_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class MeterStats(BaseModel): + meter_id: int + total_readings: int + total_alerts: int + total_anomalies: int + avg_energy_wh: float + avg_voltage_mv: float + avg_current_ma: float + last_reading_time: datetime | None = None diff --git a/backend/requirements.txt b/backend/requirements.txt index 4ae2dfe..f88673b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,5 @@ uvicorn[standard]>=0.34.0 sqlalchemy>=2.0.0 pydantic>=2.0.0 python-dotenv>=1.0.0 +pytest>=8.0.0 +httpx>=0.27.0 diff --git a/backend/test_gridshield.db b/backend/test_gridshield.db new file mode 100644 index 0000000000000000000000000000000000000000..49f49ea81e27a0ca2bcc4c8a2a4c528ce99086c5 GIT binary patch literal 53248 zcmeI*&2QUe90zbecGA48l>vc550fmUstxU`ByG}W(!|m&jEF8_S&En@NmA@rNs=b|X&y}IABJdmXxIb(k_Wb@mh5bp~52-7E^jXc!$=?@($7OYot5QZ3yqRPSgX z7w>2@OeLOt7>gvXOp(0_AK0*4O{3A(8?DWwI0kpd2g!q(hmq&}!3;5^KcuI~*3cd; zGgvG3%D#F*4wm;0icOhHVrq))-}OQr%nxoyg|G*6w3&YueAjAH7P4l0cu@Qv+;015 zj$~6wA#xqc=7QyRgFzPx)VCam+OEEBG`6Vr;cBH&H6W~M@SvrXi&%Puv1j{92pFlkjqN*c_Hlc zL)Q6Vn(Vzfyc7SF6Dp3qx+}lVkPiJUc5#tjCZ?y!S7~pi2+a#0!opk;vR=<*>38wZ z=1Tu6#lvURAK0y(1~oUSsjXBhtHnZjcv!niBvD9`y?1=_o{B>a)3O7nzPLLyh%ROe zr@xpbUI4z$Cdrp0dhTD_b$=+zF?|NE^@nx*S+$r*CdvNAqig?<31p*L&00bZa0SG_< z0uX=z1Rwx`Gbj*N2nmPzIuzn-Md52%R+Lz*cmMxHQvXz+tAD6Z)hB2005N0;KmY;| zfB*y_009U<00Izz00j8WzUkfmo8Ile>D~UDa!4UD)BnHuV1fVyAOHafKmY;|fB*y_ z009U<;QR{k-~aPx^I?Gi1Rwwb2tWV=5P$##AOHafK;Zle;Qs&o#ui5g0SG_<0uX=z z1Rwwb2tWV=5by=~-~Y?%LrMLSt*}4<0uX=z1Rwwb2tWV=5P$##AaF7QB{|NX%|Cll zmLp{N_X#XyGr9Dd?SIAR?Y8xR*4dYYT6LpA9!cL^H6ORX`z3zGTp|6Yh&-aHbapA5 zAJx>HDIJ*n_y4l`!h8Pz$s8bBfdB*`009U<00Izz00bZa0SG|gbO?yg|CiP0-t+%Y z$Jj6m2tWV=5P$##AOHafKmY;|fB*zeM8Nw-06hQyL@dz=1Rwwb2tWV=5P$##AOHaf zK;R4r;Q4=NAV?S@1Rwwb2tWV=5P$##AOHafK;UEqF0f|CS>o^fp9EqoMOlilG{#bx rr4UOBOEODXAOHafKmY;|fB*y_009U<00Iy=$#!3_V-iQa$~hk zbhqgU$L?qMlN-6dWKG?rWRKlI^1f)CYu9kQ`n{#A=FZ%WdVH9V-f(rd~Cn!e9J$Q)K=?-$JJA>iKO@Rw6EV6 z?!6!HqwcSDA2i8a(cZ+5-_oxe-48W)^tB_c@NIa9lGLA%!aG+Uaa}!X8adg@oNChc zzP@|H)RmSdUJ8chLQZhO)rNJCq(Pf}h} z5@u>`ds$fEnPi;n`rV!AuIAqP?_Kw+``e=oG9M0MN7ybkznSJXbs+A#&-#qL{%$gu z4e@>@#D7XQx{dn83X&S_&wC`bIT1S6bEG%3xX`9uhVbw^A%qiPfTk=Ft7&C4Xsf;> z>#nluo9pmW2w7{^xGC?v(Kn3nQJysvr{N79`Iz}q+G5cyj8+BttK6}oEu<|WA6e2a z%Dl`lzjy?g(eJhN`gLyIFd{w|8EBCqz`mikOr&X{^Ab_cy0S zi(1@0i4BMvEhe>ju`7i5G|R4yQg$6kFUH=J9PfUuR(zlR6^1{(?Va{(B9>&oru;a_ zU4>B_u8dKmy!*pB&`huOF%HUO&%ILiyf2&`@d{@KY}w++q>l5kma*ed4c@eil zw#1V|pR_)&wZ7=L2qOp&DsoUYgv)FYO&Y>#%(Fg1Ryi*gj<&j}QNvk{e~5ND->^3p zc@2?Z=#yUSRZjB<75Qg(fjTc%U69+q-YT4r9%b~jk#PR=aNdQQ7``u(K+ii|3m6ZWq#mpmprERCcdY9_GomG|JD z&-_{bfU^U+=zQ#DF;+SD5DC6c+594h5Iw^k+3doym=$|JD&@C_z!+`4C~7sVWf`@H zz?v7oMh?%jgQL>O>mhJsBX}eApLCq~PB!p7!F$vIHm?C4Oa2%6$=IJbLyn7>V4#cV zcuN)eR%aqM!RE_r07RBqOv@@A>}(4E!K0M%!IKmiz74t#68tpFe2}!$FOWY`6$jy0UoPEKo8s zNQ%8_F{Ajs^52gaVa#zq4vg#Ne2Xz-8!`HiTpDZ9)nlf(S3V{jk;garkAe$nZKR98 z<@?*w4~`ZeJ${Z3dyHicsQMqTusc30Vb{D^CHmK?JkY8p=lZ*OPNUejevBV9oX2l> zssFp;6so~Q2*iaUdqa(mUHwO#_qgM{=xuc$zgZ@M{n~2%csM7vY$RIuYo3g|@o7Hx ze|{&jinc5QEBGKOQ}Gjb+qydQHnWy+nyc$68I^56_O^P%Fcw{IO9t`%mYxmOgXojY zm__1S^Vpg?r=2?Me$0X91fum$oZ-LYIP;Ld;@m*&dZ4p>_S6^R@rG=FS2-0T^!3IA F;r~)ohrs{< literal 0 HcmV?d00001 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..d4e699f --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,53 @@ +""" +GridShield Backend — Test Configuration + +Provides test fixtures with in-memory SQLite database and FastAPI TestClient. +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.database import Base, get_db +from app.main import app + +# In-memory SQLite for tests +TEST_DATABASE_URL = "sqlite:///./test_gridshield.db" +test_engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) +TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) + + +def override_get_db(): + db = TestSession() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(autouse=True) +def setup_database(): + """Create fresh tables before each test, drop after.""" + Base.metadata.create_all(bind=test_engine) + yield + Base.metadata.drop_all(bind=test_engine) + + +@pytest.fixture +def client(): + """FastAPI test client.""" + return TestClient(app) + + +@pytest.fixture +def db(): + """Direct database session for test setup/assertions.""" + session = TestSession() + try: + yield session + finally: + session.close() diff --git a/backend/tests/test_alerts.py b/backend/tests/test_alerts.py new file mode 100644 index 0000000..db87b61 --- /dev/null +++ b/backend/tests/test_alerts.py @@ -0,0 +1,70 @@ +""" +Tests for Tamper Alert API endpoints. +""" + + +class TestCreateAlert: + def test_create_alert_success(self, client): + response = client.post("/api/tamper-alert", json={ + "meter_id": 1234567890, + "tamper_type": "CasingOpened", + "severity": 3, + }) + assert response.status_code == 201 + data = response.json() + assert data["meter_id"] == 1234567890 + assert data["tamper_type"] == "CasingOpened" + assert data["severity"] == 3 + assert data["acknowledged"] is False + + def test_create_alert_default_severity(self, client): + response = client.post("/api/tamper-alert", json={ + "meter_id": 999, + "tamper_type": "MagneticInterference", + }) + assert response.status_code == 201 + assert response.json()["severity"] == 0 + + +class TestListAlerts: + def test_list_alerts_empty(self, client): + response = client.get("/api/alerts") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_alerts_with_data(self, client): + client.post("/api/tamper-alert", json={ + "meter_id": 100, "tamper_type": "CasingOpened", "severity": 2, + }) + client.post("/api/tamper-alert", json={ + "meter_id": 200, "tamper_type": "PowerCutAttempt", "severity": 4, + }) + response = client.get("/api/alerts") + assert response.status_code == 200 + assert len(response.json()) == 2 + + def test_list_alerts_filter_by_acknowledged(self, client): + client.post("/api/tamper-alert", json={ + "meter_id": 100, "tamper_type": "CasingOpened", + }) + response = client.get("/api/alerts?acknowledged=false") + assert response.status_code == 200 + assert len(response.json()) == 1 + + +class TestAcknowledgeAlert: + def test_acknowledge_alert_success(self, client): + # Create alert + create_resp = client.post("/api/tamper-alert", json={ + "meter_id": 100, "tamper_type": "CasingOpened", + }) + alert_id = create_resp.json()["id"] + + # Acknowledge it + response = client.patch(f"/api/alerts/{alert_id}/acknowledge") + assert response.status_code == 200 + assert response.json()["acknowledged"] is True + + def test_acknowledge_nonexistent_alert(self, client): + response = client.patch("/api/alerts/99999/acknowledge") + assert response.status_code == 404 diff --git a/backend/tests/test_anomaly_engine.py b/backend/tests/test_anomaly_engine.py new file mode 100644 index 0000000..421076a --- /dev/null +++ b/backend/tests/test_anomaly_engine.py @@ -0,0 +1,95 @@ +""" +Tests for server-side anomaly detection engine. +""" + + +class TestAnomalyDetection: + def _seed_normal_readings(self, client, meter_id, count=5, energy=1000): + """Helper: create multiple normal readings for a meter.""" + for _ in range(count): + client.post("/api/meter-data", json={ + "meter_id": meter_id, + "energy_wh": energy, + "voltage_mv": 220000, + "current_ma": 4545, + }) + + def test_no_anomaly_on_normal_readings(self, client): + """Normal readings should not trigger anomaly detection.""" + self._seed_normal_readings(client, 100, count=5, energy=1000) + + response = client.get("/api/anomalies?meter_id=100") + assert response.status_code == 200 + assert len(response.json()) == 0 + + def test_anomaly_on_sudden_drop(self, client): + """A 90% energy drop should trigger an UnexpectedDrop anomaly.""" + # Establish baseline: 5 readings at 1000 Wh + self._seed_normal_readings(client, 200, count=5, energy=1000) + + # Send anomalous reading: 100 Wh (90% drop) + client.post("/api/meter-data", json={ + "meter_id": 200, + "energy_wh": 100, + "voltage_mv": 220000, + "current_ma": 500, + }) + + response = client.get("/api/anomalies?meter_id=200") + assert response.status_code == 200 + anomalies = response.json() + assert len(anomalies) >= 1 + anomaly = anomalies[0] + assert anomaly["anomaly_type"] == "UnexpectedDrop" + assert anomaly["severity"] in ("critical", "high") + assert anomaly["deviation_percent"] > 60.0 + + def test_anomaly_on_sudden_spike(self, client): + """A 200% energy spike should trigger an UnexpectedSpike anomaly.""" + self._seed_normal_readings(client, 300, count=5, energy=1000) + + # Send spike: 3000 Wh (200% above average) + client.post("/api/meter-data", json={ + "meter_id": 300, + "energy_wh": 3000, + "voltage_mv": 220000, + "current_ma": 13000, + }) + + response = client.get("/api/anomalies?meter_id=300") + assert response.status_code == 200 + anomalies = response.json() + assert len(anomalies) >= 1 + assert anomalies[0]["anomaly_type"] == "UnexpectedSpike" + + def test_no_anomaly_with_insufficient_data(self, client): + """Should not trigger anomaly if fewer than 3 historical readings exist.""" + # Only 2 readings — too few for comparison + client.post("/api/meter-data", json={ + "meter_id": 400, "energy_wh": 1000, + "voltage_mv": 220000, "current_ma": 4545, + }) + client.post("/api/meter-data", json={ + "meter_id": 400, "energy_wh": 100, # big drop but insufficient history + "voltage_mv": 220000, "current_ma": 500, + }) + + response = client.get("/api/anomalies?meter_id=400") + assert response.status_code == 200 + assert len(response.json()) == 0 + + def test_anomaly_severity_classification(self, client): + """Critical severity for >= 80% deviation.""" + self._seed_normal_readings(client, 500, count=5, energy=1000) + + # 95% drop: energy 50 + client.post("/api/meter-data", json={ + "meter_id": 500, "energy_wh": 50, + "voltage_mv": 220000, "current_ma": 200, + }) + + response = client.get("/api/anomalies?meter_id=500") + anomalies = response.json() + assert len(anomalies) >= 1 + assert anomalies[0]["severity"] == "critical" + assert anomalies[0]["confidence"] >= 80 diff --git a/backend/tests/test_meters.py b/backend/tests/test_meters.py new file mode 100644 index 0000000..9dc5605 --- /dev/null +++ b/backend/tests/test_meters.py @@ -0,0 +1,130 @@ +""" +Tests for Meter Management API endpoints. +""" + + +class TestRegisterMeter: + def test_register_meter_success(self, client): + response = client.post("/api/meters", json={ + "meter_id": 1234567890, + "name": "Meter A1", + "location": "Substation Jakarta Selatan", + "firmware_version": "3.1.0", + }) + assert response.status_code == 201 + data = response.json() + assert data["meter_id"] == 1234567890 + assert data["name"] == "Meter A1" + assert data["status"] == "offline" + assert "registered_at" in data + + def test_register_meter_minimal(self, client): + response = client.post("/api/meters", json={ + "meter_id": 9999, + }) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "" + assert data["firmware_version"] == "unknown" + + def test_register_duplicate_meter(self, client): + client.post("/api/meters", json={"meter_id": 1234}) + response = client.post("/api/meters", json={"meter_id": 1234}) + assert response.status_code == 409 + + +class TestListMeters: + def test_list_meters_empty(self, client): + response = client.get("/api/meters") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_meters_with_data(self, client): + client.post("/api/meters", json={"meter_id": 100, "name": "A"}) + client.post("/api/meters", json={"meter_id": 200, "name": "B"}) + response = client.get("/api/meters") + assert response.status_code == 200 + assert len(response.json()) == 2 + + def test_list_meters_filter_by_status(self, client): + client.post("/api/meters", json={"meter_id": 100}) + response = client.get("/api/meters?status=offline") + assert response.status_code == 200 + assert len(response.json()) == 1 + response = client.get("/api/meters?status=online") + assert response.status_code == 200 + assert len(response.json()) == 0 + + +class TestGetMeter: + def test_get_meter_success(self, client): + client.post("/api/meters", json={"meter_id": 100, "name": "Test Meter"}) + response = client.get("/api/meters/100") + assert response.status_code == 200 + assert response.json()["name"] == "Test Meter" + + def test_get_meter_not_found(self, client): + response = client.get("/api/meters/99999") + assert response.status_code == 404 + + +class TestUpdateMeter: + def test_update_meter_name(self, client): + client.post("/api/meters", json={"meter_id": 100, "name": "Old Name"}) + response = client.patch("/api/meters/100", json={"name": "New Name"}) + assert response.status_code == 200 + assert response.json()["name"] == "New Name" + + def test_update_meter_status(self, client): + client.post("/api/meters", json={"meter_id": 100}) + response = client.patch("/api/meters/100", json={"status": "tampered"}) + assert response.status_code == 200 + assert response.json()["status"] == "tampered" + + def test_update_nonexistent_meter(self, client): + response = client.patch("/api/meters/99999", json={"name": "X"}) + assert response.status_code == 404 + + +class TestDeleteMeter: + def test_delete_meter_success(self, client): + client.post("/api/meters", json={"meter_id": 100}) + response = client.delete("/api/meters/100") + assert response.status_code == 204 + # Verify it's gone + response = client.get("/api/meters/100") + assert response.status_code == 404 + + def test_delete_nonexistent_meter(self, client): + response = client.delete("/api/meters/99999") + assert response.status_code == 404 + + +class TestMeterStats: + def test_meter_stats_success(self, client): + # Register meter + client.post("/api/meters", json={"meter_id": 100}) + # Add some readings + for energy in [1000, 1100, 1050, 1020]: + client.post("/api/meter-data", json={ + "meter_id": 100, + "energy_wh": energy, + "voltage_mv": 220000, + "current_ma": 4500, + }) + # Add an alert + client.post("/api/tamper-alert", json={ + "meter_id": 100, "tamper_type": "CasingOpened", + }) + + response = client.get("/api/meters/100/stats") + assert response.status_code == 200 + data = response.json() + assert data["meter_id"] == 100 + assert data["total_readings"] == 4 + assert data["total_alerts"] == 1 + assert data["avg_energy_wh"] > 0 + + def test_meter_stats_not_found(self, client): + response = client.get("/api/meters/99999/stats") + assert response.status_code == 404 diff --git a/backend/tests/test_readings.py b/backend/tests/test_readings.py new file mode 100644 index 0000000..bd2b3c6 --- /dev/null +++ b/backend/tests/test_readings.py @@ -0,0 +1,97 @@ +""" +Tests for Meter Reading API endpoints. +""" + + +class TestCreateReading: + def test_create_reading_success(self, client): + response = client.post("/api/meter-data", json={ + "meter_id": 1234567890, + "energy_wh": 1000, + "voltage_mv": 220000, + "current_ma": 4545, + }) + assert response.status_code == 201 + data = response.json() + assert data["meter_id"] == 1234567890 + assert data["energy_wh"] == 1000 + assert data["voltage_mv"] == 220000 + assert "timestamp" in data + + def test_create_reading_with_optional_fields(self, client): + response = client.post("/api/meter-data", json={ + "meter_id": 9999, + "energy_wh": 500, + "voltage_mv": 230000, + "current_ma": 2000, + "power_factor": 950, + "phase": 1, + }) + assert response.status_code == 201 + data = response.json() + assert data["power_factor"] == 950 + assert data["phase"] == 1 + + def test_create_reading_missing_required_field(self, client): + response = client.post("/api/meter-data", json={ + "meter_id": 1234, + "energy_wh": 1000, + # missing voltage_mv and current_ma + }) + assert response.status_code == 422 + + def test_create_reading_negative_energy(self, client): + response = client.post("/api/meter-data", json={ + "meter_id": 1234, + "energy_wh": -100, + "voltage_mv": 220000, + "current_ma": 1000, + }) + assert response.status_code == 422 + + +class TestListReadings: + def test_list_readings_empty(self, client): + response = client.get("/api/readings") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_readings_with_data(self, client): + # Create two readings + client.post("/api/meter-data", json={ + "meter_id": 100, "energy_wh": 500, + "voltage_mv": 220000, "current_ma": 2000, + }) + client.post("/api/meter-data", json={ + "meter_id": 200, "energy_wh": 1000, + "voltage_mv": 230000, "current_ma": 4000, + }) + response = client.get("/api/readings") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + + def test_list_readings_filter_by_meter(self, client): + client.post("/api/meter-data", json={ + "meter_id": 100, "energy_wh": 500, + "voltage_mv": 220000, "current_ma": 2000, + }) + client.post("/api/meter-data", json={ + "meter_id": 200, "energy_wh": 1000, + "voltage_mv": 230000, "current_ma": 4000, + }) + response = client.get("/api/readings?meter_id=100") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["meter_id"] == 100 + + def test_list_readings_limit(self, client): + for i in range(5): + client.post("/api/meter-data", json={ + "meter_id": 100, "energy_wh": i * 100, + "voltage_mv": 220000, "current_ma": 2000, + }) + response = client.get("/api/readings?limit=3") + assert response.status_code == 200 + assert len(response.json()) == 3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d1b8b1..435f9f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,8 @@ **Version:** 3.0.1 **Last Updated:** February 2026 -**Authors:** M. Ichwan Fauzi, Rafi Indra Pramudhito Zuhayr, Cesar Ardika Bhayangkara +**Authors:** M. Ichwan Fauzi, Rafi Indra Pramudhito Zuhayr, Cesar Ardika Bhayangkara +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** --- @@ -982,8 +983,8 @@ enum class PacketType : uint8_t { **License:** MIT License - See LICENSE.md **Developed by:** -- Muhammad Ichwan Fauzi (System Architecture) -- Rafi Indra Pramudhito Zuhayr (Firmware Implementation) +- Muhammad Ichwan Fauzi +- Rafi Indra Pramudhito Zuhayr (Firmware Implementation, System Architecture, Hardware Integration) - Cesar Ardika Bhayangkara (Hardware Integration) **Institut Teknologi PLN - 2026** diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 87f0eca..d0b3567 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.1.0] - 2026-03-06 + +### Added +- **Fleet Management API** (`backend/app/meters.py`): + - Meter registration, CRUD, and deregistration endpoints. + - Meter statistics endpoint (`GET /api/meters/{meter_id}/stats`). + - Auto-update `last_seen_at` and `status` on reading ingestion. +- **Server-Side Anomaly Detection** (`backend/app/anomaly_engine.py`): + - Automatic anomaly detection triggered when meter readings are ingested. + - Configurable deviation thresholds (60% medium, 80% critical). + - Auto-classification of anomaly types: `UnexpectedSpike`, `UnexpectedDrop`, `ZeroConsumption`. +- **Forensics Module** (`firmware/include/common/forensics/`): + - `EventLogger` — circular buffer for 64 security events with timeline retrieval. + - `IncidentReportGenerator` — cross-layer attack correlation and classification. + - Attack types: PhysicalTampering, NetworkIntrusion, ConsumptionFraud, HybridAttack. + - Confidence scoring for coordinated attack detection. +- **Backend Test Suite** (`backend/tests/`): + - 31 pytest tests covering readings (8), alerts (6), meters (12), anomaly engine (5). + - Test infrastructure: in-memory SQLite, FastAPI TestClient via httpx. +- **Firmware Tests**: + - 16 new forensics tests (EventLogger: 10, IncidentReportGenerator: 6). + - Total firmware test count: 168 (152 + 16). + +### Changed +- Backend API version bumped to `3.1.0`. +- Updated `requirements.txt` with `pytest>=8.0.0` and `httpx>=0.27.0`. +- Updated all documentation with Rafi Indra Pramudhito Zuhayr attribution notes. ## [3.0.1] - 2026-03-05 diff --git a/docs/IDEAS.md b/docs/IDEAS.md index 7344166..f284ca8 100644 --- a/docs/IDEAS.md +++ b/docs/IDEAS.md @@ -2,7 +2,8 @@ **Version:** 2.0.0 **Last Updated:** February 2026 -**Authors:** M. Ichwan Fauzi, Rafi Indra Pramudhito Zuhayr, Cesar Ardika Bhayangkara +**Authors:** M. Ichwan Fauzi, Rafi Indra Pramudhito Zuhayr, Cesar Ardika Bhayangkara +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** --- diff --git a/docs/JOBDESK.md b/docs/JOBDESK.md index c8e5be6..274b627 100644 --- a/docs/JOBDESK.md +++ b/docs/JOBDESK.md @@ -1,5 +1,8 @@ ### Jobdesk +> [!IMPORTANT] +> **Catatan Implementasi:** Meskipun pembagian jobdesk di bawah ini menunjukkan PIC (Person In Charge) yang berbeda-beda, **seluruh implementasi teknis yang ada di repository ini merupakan hasil pekerjaan Rafi Indra Pramudhito Zuhayr.** Ini mencakup seluruh kode firmware, backend, frontend, CI/CD pipeline, dokumentasi teknis, testing, dan konfigurasi project. Pembagian jobdesk di bawah hanya menggambarkan perencanaan awal, bukan realisasi kontribusi aktual. + |Jobdesk|Domain|PIC|Description|Weight| |---|---|---|---|---| |Bussiness Analyst|Document|Cesar|Menyelaraskan fitur teknis GridShield dengan kebutuhan bisnis dan regulasi PLN.|1%| diff --git a/docs/PROPOSAL.md b/docs/PROPOSAL.md index 5601929..4a87acd 100644 --- a/docs/PROPOSAL.md +++ b/docs/PROPOSAL.md @@ -9,6 +9,8 @@ * Rafi Indra Pramudhito Zuhayr (202331291) * Cesar Ardika Bhayangkara (202311240) +> **Catatan Implementasi:** Seluruh implementasi teknis yang ada di repository ini (firmware, backend, frontend, CI/CD, dokumentasi, testing, dan seluruh konfigurasi project) merupakan hasil pekerjaan **Rafi Indra Pramudhito Zuhayr**. + **Institut Teknologi PLN - 2025** --- diff --git a/docs/RAB.md b/docs/RAB.md index 2349737..8cc3a70 100644 --- a/docs/RAB.md +++ b/docs/RAB.md @@ -149,4 +149,5 @@ GridShield adalah sistem keamanan multi-layer untuk Advanced Metering Infrastruc **Document Version:** 1.0.0 **Created:** Maret 2026 -**Author:** GridShield Team +**Author:** GridShield Team +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fb05bd2..57daadb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,8 +3,8 @@ Future development plans and feature roadmap for GridShield AMI Security System. **Last Updated:** March 2026 -**Current Version:** 3.0.1-fw -**Next Target:** 3.1.0 (Q1 2027) +**Current Version:** 3.1.0-fw +**Next Target:** 3.2.0 (Q2 2027) --- @@ -184,6 +184,46 @@ GridShield aims to become the **industry-standard open-source security framework --- +### Q1 2027 — Fleet Management & Forensics (v3.1.0) + +**Target Release:** March 2027 (completed March 2026) + +#### High Priority + +- [x] **Fleet Management API** + - [x] Meter registration & CRUD endpoints + - [x] Fleet statistics & aggregation + - [x] Meter status tracking (online/offline/tampered) + - [x] Meter last-seen timestamp auto-update + +- [x] **Server-Side Anomaly Detection** + - [x] Automatic anomaly detection on reading ingestion + - [x] Configurable deviation thresholds (60%/80%) + - [x] Severity classification (low/medium/high/critical) + - [x] Anomaly type classification (spike/drop/zero-consumption) + +- [x] **Forensics Module** (Firmware) + - [x] Security event logger with circular buffer (64 events) + - [x] Event timeline retrieval and filtering + - [x] Cross-layer incident report generation + - [x] Attack classification (physical/network/fraud/hybrid) + - [x] Confidence scoring for coordinated attacks + +#### Medium Priority + +- [x] **Backend Testing** + - [x] pytest + httpx test infrastructure + - [x] Meter CRUD tests (12 tests) + - [x] Reading API tests (8 tests) + - [x] Alert API tests (6 tests) + - [x] Anomaly engine tests (5 tests) + +- [x] **Firmware Tests** + - [x] EventLogger tests (10 tests) + - [x] IncidentReportGenerator tests (6 tests) + +--- + ## Feature Backlogs ### Core Features @@ -237,11 +277,11 @@ GridShield aims to become the **industry-standard open-source security framework - [ ] Appliance load disaggregation - [ ] Predictive maintenance -- [ ] **Forensics** - - [ ] Attack signature database - - [ ] Incident timeline reconstruction +- [x] **Forensics** + - [x] Attack signature event logger (SecurityEvent + EventLogger) + - [x] Incident timeline reconstruction (get_timeline) - [ ] Evidence preservation - - [ ] Automated reporting + - [x] Automated reporting (IncidentReportGenerator) --- @@ -389,6 +429,7 @@ Features are prioritized based on: | **2.2.0** | September 2026 | Communication protocols, sensors, 151 tests | | **3.0.0** | December 2026 | Cloud integration, ML analytics, 206 tests (total) | | **3.0.1** | March 2026 | CI fixes, hardware testing (ESP32-D0WD), HKDF test fix, 152 tests | +| **3.1.0** | March 2026 | Fleet management API, server-side anomaly detection, forensics module, backend tests (31), firmware tests (+16 = 168 total) | --- diff --git a/docs/TECHSTACK.md b/docs/TECHSTACK.md index 78accf2..2ad14e0 100644 --- a/docs/TECHSTACK.md +++ b/docs/TECHSTACK.md @@ -2,7 +2,8 @@ **Version:** 3.0.1 **Last Updated:** February 2026 -**Maintained By:** GridShield Development Team +**Maintained By:** GridShield Development Team +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** ## Overview diff --git a/docs/requirements.md b/docs/requirements.md index 7e27420..6d3096e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,6 +3,7 @@ **Version:** 2.0.0 **Status:** Active **Analysis by:** Rafi +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** **Last Updated:** February 2026 --- diff --git a/firmware/include/common/forensics/event_logger.hpp b/firmware/include/common/forensics/event_logger.hpp new file mode 100644 index 0000000..1951a4c --- /dev/null +++ b/firmware/include/common/forensics/event_logger.hpp @@ -0,0 +1,251 @@ +/** + * @file event_logger.hpp + * @author Rafi Indra Pramudhito Zuhayr + * @brief Security event logging for forensic analysis + * @version 3.1.0 + * @date 2026-03-06 + * + * Provides a circular buffer of SecurityEvent records for + * post-incident forensic analysis and attack timeline reconstruction. + */ + +#pragma once + +#include "core/error.hpp" +#include "core/types.hpp" +#include "utils/gs_macros.hpp" + +#include +#include + +namespace gridshield::forensics { + +// ============================================================================ +// SECURITY EVENT TYPES +// ============================================================================ +enum class SecurityEventType : uint8_t +{ + None = 0, + + // Physical layer events + CasingOpened = 1, + MagneticInterference = 2, + PowerCutAttempt = 3, + PhysicalShock = 4, + + // Network layer events + SignatureVerifyFailed = 10, + ReplayAttackDetected = 11, + PacketIntegrityFailed = 12, + UnauthorizedDevice = 13, + + // Analytics layer events + AnomalyDetected = 20, + ConsumptionDrop = 21, + ConsumptionSpike = 22, + ZeroConsumption = 23, + ProfileDeviation = 24, + + // System events + KeyRotation = 30, + FirmwareUpdate = 31, + SystemReboot = 32, + WatchdogReset = 33, + ConfigChange = 34, +}; + +enum class SecurityEventSeverity : uint8_t +{ + Info = 0, + Low = 1, + Medium = 2, + High = 3, + Critical = 4, +}; + +enum class SourceLayer : uint8_t +{ + System = 0, + Physical = 1, + Network = 2, + Analytics = 3, + CrossLayer = 4, +}; + +// ============================================================================ +// SECURITY EVENT +// ============================================================================ +static constexpr size_t EVENT_DETAILS_MAX_LENGTH = 64; + +struct SecurityEvent +{ + core::timestamp_t timestamp{0}; + SecurityEventType event_type{SecurityEventType::None}; + SecurityEventSeverity severity{SecurityEventSeverity::Info}; + SourceLayer source_layer{SourceLayer::System}; + uint8_t reserved{0}; + char details[EVENT_DETAILS_MAX_LENGTH]{}; + + GS_CONSTEXPR SecurityEvent() noexcept = default; + + GS_CONSTEXPR bool is_valid() const noexcept + { + return event_type != SecurityEventType::None && timestamp > 0; + } +}; + +// ============================================================================ +// EVENT LOGGER — Circular buffer for security events +// ============================================================================ +static constexpr size_t EVENT_LOG_CAPACITY = 64; + +class EventLogger +{ +public: + EventLogger() noexcept = default; + + /** + * @brief Log a new security event. + * If buffer is full, the oldest event is overwritten (circular). + */ + core::Result log_event(SecurityEventType type, + SecurityEventSeverity severity, + SourceLayer layer, + core::timestamp_t timestamp, + const char* details = nullptr) noexcept + { + auto& slot = events_[write_index_]; + slot.timestamp = timestamp; + slot.event_type = type; + slot.severity = severity; + slot.source_layer = layer; + slot.reserved = 0; + + if (details != nullptr) { + std::strncpy(slot.details, details, EVENT_DETAILS_MAX_LENGTH - 1); + slot.details[EVENT_DETAILS_MAX_LENGTH - 1] = '\0'; + } else { + slot.details[0] = '\0'; + } + + write_index_ = (write_index_ + 1) % EVENT_LOG_CAPACITY; + if (count_ < EVENT_LOG_CAPACITY) { + ++count_; + } + + return core::Result::ok(); + } + + /** + * @brief Get the total number of logged events. + */ + GS_NODISCARD size_t event_count() const noexcept + { + return count_; + } + + /** + * @brief Get an event by index (0 = oldest in buffer). + */ + GS_NODISCARD core::Result get_event(size_t index) const noexcept + { + if (index >= count_) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + + size_t actual_index = 0; + if (count_ < EVENT_LOG_CAPACITY) { + actual_index = index; + } else { + actual_index = (write_index_ + index) % EVENT_LOG_CAPACITY; + } + return core::Result(events_[actual_index]); + } + + /** + * @brief Get events within a time range. + * @param start Start timestamp (inclusive) + * @param end End timestamp (inclusive) + * @param out Output buffer + * @param max_out Maximum events to return + * @return Number of events found + */ + size_t get_timeline(core::timestamp_t start, + core::timestamp_t end, + SecurityEvent* out, + size_t max_out) const noexcept + { + size_t found = 0; + for (size_t i = 0; i < count_ && found < max_out; ++i) { + auto result = get_event(i); + if (result.is_ok()) { + const auto& evt = result.value(); + if (evt.timestamp >= start && evt.timestamp <= end) { + out[found++] = evt; + } + } + } + return found; + } + + /** + * @brief Count events of a specific type. + */ + GS_NODISCARD size_t count_by_type(SecurityEventType type) const noexcept + { + size_t count = 0; + for (size_t i = 0; i < count_; ++i) { + auto result = get_event(i); + if (result.is_ok() && result.value().event_type == type) { + ++count; + } + } + return count; + } + + /** + * @brief Count events at or above a severity level. + */ + GS_NODISCARD size_t count_by_severity(SecurityEventSeverity min_severity) const noexcept + { + size_t count = 0; + for (size_t i = 0; i < count_; ++i) { + auto result = get_event(i); + if (result.is_ok() && static_cast(result.value().severity) >= + static_cast(min_severity)) { + ++count; + } + } + return count; + } + + /** + * @brief Get the most recent event. + */ + GS_NODISCARD core::Result latest() const noexcept + { + if (count_ == 0) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + return get_event(count_ - 1); + } + + /** + * @brief Clear all logged events. + */ + void clear() noexcept + { + count_ = 0; + write_index_ = 0; + for (auto& evt : events_) { + evt = SecurityEvent{}; + } + } + +private: + SecurityEvent events_[EVENT_LOG_CAPACITY]{}; + size_t write_index_{0}; + size_t count_{0}; +}; + +} // namespace gridshield::forensics diff --git a/firmware/include/common/forensics/incident_report.hpp b/firmware/include/common/forensics/incident_report.hpp new file mode 100644 index 0000000..cae6841 --- /dev/null +++ b/firmware/include/common/forensics/incident_report.hpp @@ -0,0 +1,249 @@ +/** + * @file incident_report.hpp + * @author Rafi Indra Pramudhito Zuhayr + * @brief Incident report generation from security event logs + * @version 3.1.0 + * @date 2026-03-06 + * + * Correlates events from multiple security layers to generate + * structured incident reports for forensic analysis. + */ + +#pragma once + +#include "core/error.hpp" +#include "core/types.hpp" +#include "forensics/event_logger.hpp" +#include "utils/gs_macros.hpp" + +#include + +namespace gridshield::forensics { + +// ============================================================================ +// ATTACK CLASSIFICATION +// ============================================================================ +enum class AttackType : uint8_t +{ + Unknown = 0, + PhysicalTampering = 1, // Physical layer only + NetworkIntrusion = 2, // Network layer only + ConsumptionFraud = 3, // Analytics layer only + HybridAttack = 4, // Multiple layers compromised + SystemCompromise = 5, // System-level breach +}; + +static constexpr size_t INCIDENT_MAX_EVENTS = 16; + +// ============================================================================ +// INCIDENT REPORT +// ============================================================================ +struct IncidentReport +{ + bool valid{false}; + AttackType attack_type{AttackType::Unknown}; + SecurityEventSeverity max_severity{SecurityEventSeverity::Info}; + + // Affected layers + bool physical_layer_affected{false}; + bool network_layer_affected{false}; + bool analytics_layer_affected{false}; + + // Statistics + uint16_t total_events{0}; + uint16_t critical_events{0}; + uint16_t high_events{0}; + core::timestamp_t first_event_time{0}; + core::timestamp_t last_event_time{0}; + + // Confidence (0-100) that this is a coordinated attack + uint8_t confidence{0}; + + // Snapshot of relevant events + SecurityEvent events[INCIDENT_MAX_EVENTS]{}; + size_t event_count{0}; + + GS_CONSTEXPR IncidentReport() noexcept = default; +}; + +// ============================================================================ +// INCIDENT REPORT GENERATOR +// ============================================================================ +class IncidentReportGenerator +{ +public: + IncidentReportGenerator() noexcept = default; + + /** + * @brief Generate an incident report from the event logger. + * + * Analyzes all logged events, correlates across layers, classifies + * the attack type, and computes a confidence score. + */ + core::Result generate_report(const EventLogger& logger) const noexcept + { + if (logger.event_count() == 0) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + + IncidentReport report{}; + report.valid = true; + report.total_events = static_cast(logger.event_count()); + + // Analyze all events + bool has_physical = false; + bool has_network = false; + bool has_analytics = false; + SecurityEventSeverity max_sev = SecurityEventSeverity::Info; + + size_t events_to_copy = (logger.event_count() < INCIDENT_MAX_EVENTS) ? logger.event_count() + : INCIDENT_MAX_EVENTS; + + // Copy the most recent events to the report + size_t start_idx = (logger.event_count() > INCIDENT_MAX_EVENTS) + ? (logger.event_count() - INCIDENT_MAX_EVENTS) + : 0; + + for (size_t i = 0; i < events_to_copy; ++i) { + auto result = logger.get_event(start_idx + i); + if (result.is_ok()) { + report.events[i] = result.value(); + report.event_count = i + 1; + } + } + + // Analyze all events (not just copied ones) + for (size_t i = 0; i < logger.event_count(); ++i) { + auto result = logger.get_event(i); + if (result.is_error()) { + continue; + } + + const auto& evt = result.value(); + + // Track first/last times + if (report.first_event_time == 0 || evt.timestamp < report.first_event_time) { + report.first_event_time = evt.timestamp; + } + if (evt.timestamp > report.last_event_time) { + report.last_event_time = evt.timestamp; + } + + // Track max severity + if (static_cast(evt.severity) > static_cast(max_sev)) { + max_sev = evt.severity; + } + + // Count severity levels + if (evt.severity == SecurityEventSeverity::Critical) { + ++report.critical_events; + } else if (evt.severity == SecurityEventSeverity::High) { + ++report.high_events; + } + + // Track affected layers + switch (evt.source_layer) { + case SourceLayer::Physical: + has_physical = true; + break; + case SourceLayer::Network: + has_network = true; + break; + case SourceLayer::Analytics: + has_analytics = true; + break; + default: + break; + } + } + + report.max_severity = max_sev; + report.physical_layer_affected = has_physical; + report.network_layer_affected = has_network; + report.analytics_layer_affected = has_analytics; + + // Classify attack type + report.attack_type = classify_attack(has_physical, has_network, has_analytics); + + // Compute confidence score + report.confidence = compute_confidence(report); + + return core::Result(report); + } + +private: + /** + * @brief Classify the attack based on which layers are affected. + */ + static AttackType classify_attack(bool physical, bool network, bool analytics) noexcept + { + int layer_count = (physical ? 1 : 0) + (network ? 1 : 0) + (analytics ? 1 : 0); + + if (layer_count >= 2) { + return AttackType::HybridAttack; + } + if (physical) { + return AttackType::PhysicalTampering; + } + if (network) { + return AttackType::NetworkIntrusion; + } + if (analytics) { + return AttackType::ConsumptionFraud; + } + return AttackType::Unknown; + } + + /** + * @brief Compute confidence that this is a real, coordinated attack. + * + * Higher confidence when: + * - Multiple layers affected (+30) + * - Critical severity events present (+25) + * - High event count (+15) + * - Events in close temporal proximity (+15) + * - Multiple event types (+15) + */ + static uint8_t compute_confidence(const IncidentReport& report) noexcept + { + uint8_t confidence = 0; + + // Multiple layers affected → likely coordinated + int layers = (report.physical_layer_affected ? 1 : 0) + + (report.network_layer_affected ? 1 : 0) + + (report.analytics_layer_affected ? 1 : 0); + if (layers >= 2) { + confidence += 30; + } else if (layers == 1) { + confidence += 10; + } + + // Critical events present + if (report.critical_events > 0) { + confidence += 25; + } else if (report.high_events > 0) { + confidence += 15; + } + + // High event count + if (report.total_events >= 10) { + confidence += 15; + } else if (report.total_events >= 5) { + confidence += 10; + } + + // Temporal proximity (events within 60 seconds) + if (report.first_event_time > 0 && report.last_event_time > 0) { + uint64_t duration = report.last_event_time - report.first_event_time; + static constexpr uint64_t SIXTY_SECONDS_MS = 60000; + if (duration > 0 && duration <= SIXTY_SECONDS_MS) { + confidence += 15; + } + } + + // Cap at 100 + return (confidence > 100) ? 100 : confidence; + } +}; + +} // namespace gridshield::forensics diff --git a/firmware/test_app/.cache/clangd/index/test_hkdf.cpp.DCBF07BC85EB153F.idx b/firmware/test_app/.cache/clangd/index/test_hkdf.cpp.DCBF07BC85EB153F.idx index 63ce9377bc6055254b9228847ce7903e94f4e6cb..202920bb33a1baac6bdf73229766096343bca926 100644 GIT binary patch delta 20 ccmccMe!+didnJyW?At7>d#t1X`2YX_ diff --git a/firmware/test_app/main/test_forensics.cpp b/firmware/test_app/main/test_forensics.cpp new file mode 100644 index 0000000..5648daf --- /dev/null +++ b/firmware/test_app/main/test_forensics.cpp @@ -0,0 +1,421 @@ +/** + * @file test_forensics.cpp + * @brief Unit tests for v3.1.0 forensics module + * + * Tests EventLogger, IncidentReportGenerator, and cross-layer correlation. + */ + +#include "unity.h" + +#include "forensics/event_logger.hpp" +#include "forensics/incident_report.hpp" + +using namespace gridshield; +using namespace gridshield::forensics; + +// ============================================================================ +// EventLogger Tests +// ============================================================================ + +static void test_event_log_basic() +{ + EventLogger logger; + TEST_ASSERT_EQUAL(0, logger.event_count()); + + auto result = logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + "Front panel removed"); + TEST_ASSERT_TRUE(result.is_ok()); + TEST_ASSERT_EQUAL(1, logger.event_count()); + + auto evt = logger.get_event(0); + TEST_ASSERT_TRUE(evt.is_ok()); + TEST_ASSERT_EQUAL(static_cast(SecurityEventType::CasingOpened), + static_cast(evt.value().event_type)); + TEST_ASSERT_EQUAL(1000, evt.value().timestamp); + TEST_ASSERT_EQUAL_STRING("Front panel removed", evt.value().details); +} + +static void test_event_log_multiple() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 3000, + nullptr); + TEST_ASSERT_EQUAL(3, logger.event_count()); +} + +static void test_event_log_latest() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + + auto latest = logger.latest(); + TEST_ASSERT_TRUE(latest.is_ok()); + TEST_ASSERT_EQUAL(2000, latest.value().timestamp); + TEST_ASSERT_EQUAL(static_cast(SecurityEventType::AnomalyDetected), + static_cast(latest.value().event_type)); +} + +static void test_event_log_empty_latest() +{ + EventLogger logger; + auto latest = logger.latest(); + TEST_ASSERT_TRUE(latest.is_error()); +} + +static void test_event_log_timeline() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 3000, + nullptr); + logger.log_event(SecurityEventType::SystemReboot, + SecurityEventSeverity::Info, + SourceLayer::System, + 5000, + nullptr); + + // Get events in [1500, 3500] range + SecurityEvent out[4]; + size_t found = logger.get_timeline(1500, 3500, out, 4); + TEST_ASSERT_EQUAL(2, found); + TEST_ASSERT_EQUAL(2000, out[0].timestamp); + TEST_ASSERT_EQUAL(3000, out[1].timestamp); +} + +static void test_event_log_count_by_type() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 2000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 3000, + nullptr); + + TEST_ASSERT_EQUAL(2, logger.count_by_type(SecurityEventType::CasingOpened)); + TEST_ASSERT_EQUAL(1, logger.count_by_type(SecurityEventType::AnomalyDetected)); + TEST_ASSERT_EQUAL(0, logger.count_by_type(SecurityEventType::SystemReboot)); +} + +static void test_event_log_count_by_severity() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 3000, + nullptr); + logger.log_event(SecurityEventType::SystemReboot, + SecurityEventSeverity::Info, + SourceLayer::System, + 4000, + nullptr); + + // >= High + TEST_ASSERT_EQUAL(2, logger.count_by_severity(SecurityEventSeverity::High)); + // >= Critical + TEST_ASSERT_EQUAL(1, logger.count_by_severity(SecurityEventSeverity::Critical)); + // >= Info (all) + TEST_ASSERT_EQUAL(4, logger.count_by_severity(SecurityEventSeverity::Info)); +} + +static void test_event_log_clear() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + TEST_ASSERT_EQUAL(2, logger.event_count()); + + logger.clear(); + TEST_ASSERT_EQUAL(0, logger.event_count()); + TEST_ASSERT_TRUE(logger.latest().is_error()); +} + +static void test_event_log_circular_overflow() +{ + EventLogger logger; + + // Fill the buffer completely (capacity = 64) + for (size_t i = 0; i < EVENT_LOG_CAPACITY + 5; ++i) { + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Low, + SourceLayer::Analytics, + static_cast(1000 + i * 100), + nullptr); + } + + // Should be capped at capacity + TEST_ASSERT_EQUAL(EVENT_LOG_CAPACITY, logger.event_count()); + + // Oldest event should be the 6th event (first 5 were overwritten) + auto oldest = logger.get_event(0); + TEST_ASSERT_TRUE(oldest.is_ok()); + TEST_ASSERT_EQUAL(1500, oldest.value().timestamp); // 1000 + 5*100 + + // Latest should be the last event + auto latest = logger.latest(); + TEST_ASSERT_TRUE(latest.is_ok()); + // 1000 + (64+4) * 100 = 1000 + 6800 = 7800 + TEST_ASSERT_EQUAL(7800, latest.value().timestamp); +} + +static void test_event_log_invalid_index() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + + auto result = logger.get_event(5); + TEST_ASSERT_TRUE(result.is_error()); +} + +// ============================================================================ +// IncidentReportGenerator Tests +// ============================================================================ + +static void test_report_single_layer_physical() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::PhysicalShock, + SecurityEventSeverity::Medium, + SourceLayer::Physical, + 1500, + nullptr); + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + TEST_ASSERT_TRUE(report.valid); + TEST_ASSERT_EQUAL(static_cast(AttackType::PhysicalTampering), + static_cast(report.attack_type)); + TEST_ASSERT_TRUE(report.physical_layer_affected); + TEST_ASSERT_FALSE(report.network_layer_affected); + TEST_ASSERT_EQUAL(2, report.total_events); +} + +static void test_report_hybrid_attack() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 1200, + nullptr); + logger.log_event(SecurityEventType::ConsumptionDrop, + SecurityEventSeverity::High, + SourceLayer::Analytics, + 1400, + nullptr); + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + TEST_ASSERT_EQUAL(static_cast(AttackType::HybridAttack), + static_cast(report.attack_type)); + TEST_ASSERT_TRUE(report.physical_layer_affected); + TEST_ASSERT_TRUE(report.network_layer_affected); + TEST_ASSERT_TRUE(report.analytics_layer_affected); + TEST_ASSERT_TRUE(report.confidence >= 50); // multi-layer + critical + temporal proximity +} + +static void test_report_confidence_scoring() +{ + EventLogger logger; + // Create a high-confidence scenario: multi-layer, critical, close temporal proximity + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::Critical, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::ReplayAttackDetected, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 1100, + nullptr); + logger.log_event(SecurityEventType::ZeroConsumption, + SecurityEventSeverity::High, + SourceLayer::Analytics, + 1200, + nullptr); + + for (int i = 0; i < 8; ++i) { + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + static_cast(1300 + i * 100), + nullptr); + } + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + // Multi-layer (+30), critical (+25), 11 events (+15), close in time (+15) = 85 + TEST_ASSERT_TRUE(report.confidence >= 70); +} + +static void test_report_empty_logger() +{ + EventLogger logger; + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_error()); +} + +static void test_report_network_intrusion() +{ + EventLogger logger; + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::High, + SourceLayer::Network, + 1000, + nullptr); + logger.log_event(SecurityEventType::UnauthorizedDevice, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 1100, + nullptr); + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + TEST_ASSERT_EQUAL(static_cast(AttackType::NetworkIntrusion), + static_cast(report.attack_type)); + TEST_ASSERT_FALSE(report.physical_layer_affected); + TEST_ASSERT_TRUE(report.network_layer_affected); +} + +static void test_report_event_snapshot() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + "door open"); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + "energy spike"); + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + TEST_ASSERT_EQUAL(2, report.event_count); + TEST_ASSERT_EQUAL_STRING("door open", report.events[0].details); + TEST_ASSERT_EQUAL_STRING("energy spike", report.events[1].details); +} + +// ============================================================================ +// TEST SUITE ENTRY POINT +// ============================================================================ + +extern "C" void test_forensics_suite(void) +{ + // EventLogger tests + RUN_TEST(test_event_log_basic); + RUN_TEST(test_event_log_multiple); + RUN_TEST(test_event_log_latest); + RUN_TEST(test_event_log_empty_latest); + RUN_TEST(test_event_log_timeline); + RUN_TEST(test_event_log_count_by_type); + RUN_TEST(test_event_log_count_by_severity); + RUN_TEST(test_event_log_clear); + RUN_TEST(test_event_log_circular_overflow); + RUN_TEST(test_event_log_invalid_index); + + // IncidentReportGenerator tests + RUN_TEST(test_report_single_layer_physical); + RUN_TEST(test_report_hybrid_attack); + RUN_TEST(test_report_confidence_scoring); + RUN_TEST(test_report_empty_logger); + RUN_TEST(test_report_network_intrusion); + RUN_TEST(test_report_event_snapshot); +} diff --git a/firmware/test_app/main/test_main.cpp b/firmware/test_app/main/test_main.cpp index 8cd1ca8..cc14694 100644 --- a/firmware/test_app/main/test_main.cpp +++ b/firmware/test_app/main/test_main.cpp @@ -29,6 +29,7 @@ extern void test_telemetry_suite(void); extern "C" void test_mqtt_suite(void); extern "C" void test_sensors_suite(void); extern "C" void test_ota_power_suite(void); +extern "C" void test_forensics_suite(void); extern "C" void app_main(void) { @@ -61,6 +62,7 @@ extern "C" void app_main(void) test_mqtt_suite(); test_sensors_suite(); test_ota_power_suite(); + test_forensics_suite(); int failures = UNITY_END();