diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78c2705..9d1c447 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,6 +100,25 @@ jobs: - name: Lint run: ruff check backend/ + backend-test: + name: Backend Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Dependencies + run: pip install -r backend/requirements.txt + + - name: Run Tests + run: python -m pytest backend/tests/ -v --tb=short + frontend-build: name: Frontend Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 21cb108..3dcb19d 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/backend/app/export.py b/backend/app/export.py new file mode 100644 index 0000000..911410c --- /dev/null +++ b/backend/app/export.py @@ -0,0 +1,139 @@ +""" +GridShield Backend — Data Export Routes +CSV export endpoints for readings, alerts, and anomalies. +""" + +import csv +import io +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from . import models +from .database import get_db + +router = APIRouter(prefix="/api/export", tags=["Export"]) + + +def _csv_response(rows: list[dict], filename: str) -> StreamingResponse: + """Build a StreamingResponse with CSV content.""" + if not rows: + output = io.StringIO() + output.write("") + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ============================================================================ +# Export Readings +# ============================================================================ +@router.get("/readings") +def export_readings( + meter_id: int | None = None, + limit: int = Query(default=1000, le=10000), + db: Session = Depends(get_db), +): + """Export meter readings as CSV.""" + query = db.query(models.MeterReading) + if meter_id is not None: + query = query.filter(models.MeterReading.meter_id == meter_id) + results = query.order_by(models.MeterReading.timestamp.desc()).limit(limit).all() + + rows = [ + { + "id": r.id, + "meter_id": r.meter_id, + "timestamp": r.timestamp.isoformat() if r.timestamp else "", + "energy_wh": r.energy_wh, + "voltage_mv": r.voltage_mv, + "current_ma": r.current_ma, + "power_factor": r.power_factor, + "phase": r.phase, + } + for r in results + ] + + ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + return _csv_response(rows, f"gridshield_readings_{ts}.csv") + + +# ============================================================================ +# Export Alerts +# ============================================================================ +@router.get("/alerts") +def export_alerts( + meter_id: int | None = None, + limit: int = Query(default=1000, le=10000), + db: Session = Depends(get_db), +): + """Export tamper alerts as CSV.""" + query = db.query(models.TamperAlert) + if meter_id is not None: + query = query.filter(models.TamperAlert.meter_id == meter_id) + results = query.order_by(models.TamperAlert.timestamp.desc()).limit(limit).all() + + rows = [ + { + "id": a.id, + "meter_id": a.meter_id, + "timestamp": a.timestamp.isoformat() if a.timestamp else "", + "tamper_type": a.tamper_type, + "severity": a.severity, + "acknowledged": a.acknowledged, + } + for a in results + ] + + ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + return _csv_response(rows, f"gridshield_alerts_{ts}.csv") + + +# ============================================================================ +# Export Anomalies +# ============================================================================ +@router.get("/anomalies") +def export_anomalies( + meter_id: int | None = None, + limit: int = Query(default=1000, le=10000), + db: Session = Depends(get_db), +): + """Export anomaly logs as CSV.""" + query = db.query(models.AnomalyLog) + if meter_id is not None: + query = query.filter(models.AnomalyLog.meter_id == meter_id) + results = query.order_by(models.AnomalyLog.timestamp.desc()).limit(limit).all() + + rows = [ + { + "id": a.id, + "meter_id": a.meter_id, + "timestamp": a.timestamp.isoformat() if a.timestamp else "", + "anomaly_type": a.anomaly_type, + "severity": a.severity, + "current_value": a.current_value, + "expected_value": a.expected_value, + "deviation_percent": a.deviation_percent, + "confidence": a.confidence, + } + for a in results + ] + + ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + return _csv_response(rows, f"gridshield_anomalies_{ts}.csv") diff --git a/backend/app/main.py b/backend/app/main.py index ca0acc1..abaecf7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,10 @@ from fastapi.middleware.cors import CORSMiddleware from .database import Base, engine +<<<<<<< HEAD +from .export import router as export_router +======= +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f from .meters import router as meters_router from .routes import router @@ -25,7 +29,11 @@ async def lifespan(app: FastAPI): description="REST API for GridShield AMI Security System — " "Meter data ingestion, tamper alerts, anomaly monitoring, " "and fleet management.", +<<<<<<< HEAD + version="3.2.0", +======= version="3.1.0", +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f lifespan=lifespan, ) @@ -41,6 +49,10 @@ async def lifespan(app: FastAPI): # Routes app.include_router(router) app.include_router(meters_router) +<<<<<<< HEAD +app.include_router(export_router) +======= +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f @app.get("/", tags=["Root"]) @@ -48,6 +60,10 @@ def root(): """Health check.""" return { "name": "GridShield API", +<<<<<<< HEAD + "version": "3.2.0", +======= "version": "3.1.0", +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f "status": "running", } diff --git a/backend/test_gridshield.db b/backend/test_gridshield.db index 49f49ea..c3c8e85 100644 Binary files a/backend/test_gridshield.db and b/backend/test_gridshield.db differ diff --git a/backend/test_results.txt b/backend/test_results.txt index 72ff83f..a3e8f34 100644 Binary files a/backend/test_results.txt and b/backend/test_results.txt differ diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py new file mode 100644 index 0000000..c6dd806 --- /dev/null +++ b/backend/tests/test_export.py @@ -0,0 +1,78 @@ +""" +Tests for CSV export endpoints. +""" + +from fastapi.testclient import TestClient + + +def test_export_readings_empty(client: TestClient): + """Export readings CSV when no data exists — returns empty CSV.""" + resp = client.get("/api/export/readings") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/csv") + + +def test_export_readings_with_data(client: TestClient): + """Export readings CSV with data — returns proper CSV rows.""" + # Insert a reading + client.post("/api/meter-data", json={ + "meter_id": 99001, + "energy_wh": 500, + "voltage_mv": 220000, + "current_ma": 2000, + }) + resp = client.get("/api/export/readings") + assert resp.status_code == 200 + lines = resp.text.strip().split("\n") + assert len(lines) >= 2 # header + at least 1 row + assert "meter_id" in lines[0] + assert "energy_wh" in lines[0] + assert "99001" in lines[1] + + +def test_export_alerts_empty(client: TestClient): + """Export alerts CSV when no data exists.""" + resp = client.get("/api/export/alerts") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/csv") + + +def test_export_alerts_with_data(client: TestClient): + """Export alerts CSV with data.""" + client.post("/api/tamper-alert", json={ + "meter_id": 99002, + "tamper_type": "CasingOpened", + "severity": 3, + }) + resp = client.get("/api/export/alerts") + assert resp.status_code == 200 + lines = resp.text.strip().split("\n") + assert len(lines) >= 2 + assert "tamper_type" in lines[0] + assert "CasingOpened" in lines[1] + + +def test_export_anomalies_empty(client: TestClient): + """Export anomalies CSV when no data exists.""" + resp = client.get("/api/export/anomalies") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/csv") + + +def test_export_anomalies_with_data(client: TestClient): + """Export anomalies CSV with data.""" + client.post("/api/anomalies", json={ + "meter_id": 99003, + "anomaly_type": "UnexpectedSpike", + "severity": "high", + "current_value": 1500.0, + "expected_value": 500.0, + "deviation_percent": 200.0, + "confidence": 95, + }) + resp = client.get("/api/export/anomalies") + assert resp.status_code == 200 + lines = resp.text.strip().split("\n") + assert len(lines) >= 2 + assert "anomaly_type" in lines[0] + assert "UnexpectedSpike" in lines[1] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d0b3567..e1fe7f2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,43 @@ 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). +<<<<<<< HEAD +## [3.2.0] - 2026-03-07 + +### Added +- **Data Export API** (`backend/app/export.py`): + - `GET /api/export/readings` — Export meter readings as CSV. + - `GET /api/export/alerts` — Export tamper alerts as CSV. + - `GET /api/export/anomalies` — Export anomaly logs as CSV. + - All endpoints support `meter_id` filtering and `StreamingResponse`. +- **Frontend Fleet Integration**: + - Fleet page refactored to use backend Meter CRUD API (`GET /api/meters`). + - Meter registration modal (`POST /api/meters`). + - Meter delete with confirmation (`DELETE /api/meters/{id}`). + - Meter status indicators (online/offline/tampered). + - Meter detail modal with statistics from `GET /api/meters/{id}/stats`. + - CSV export buttons on Dashboard, Alerts, and Anomalies pages. +- **Evidence Preservation** (`firmware/include/common/forensics/evidence_store.hpp`): + - `EvidenceStore` — circular buffer for 32 forensic evidence snapshots. + - `SensorSnapshot` — captures raw sensor state (energy, voltage, current, temperature, accelerometer). + - Hash chain integrity — each snapshot linked to previous via FNV-1a hash. + - `verify_chain()` — validates entire evidence chain integrity. +- **Backend Test Suite**: + - 6 new export endpoint tests (`backend/tests/test_export.py`). + - Total backend tests: 37+. +- **Firmware Tests**: + - 8 new EvidenceStore tests (`firmware/test_app/main/test_evidence_store.cpp`). + - Total firmware test count: 176 (168 + 8). +- **CI Enhancement**: + - Added `backend-test` job to GitHub Actions pipeline (7-job workflow). + +### Changed +- Backend API version bumped to `3.2.0`. +- Frontend `api.js` extended with meter CRUD and CSV export functions. +- Frontend `style.css` extended with modal, status indicator, and form styles. + +======= +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f ## [3.1.0] - 2026-03-06 ### Added diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 57daadb..ac78a23 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,8 +3,13 @@ Future development plans and feature roadmap for GridShield AMI Security System. **Last Updated:** March 2026 +<<<<<<< HEAD +**Current Version:** 3.2.0 +**Next Target:** 3.3.0 (Q2 2027) +======= **Current Version:** 3.1.0-fw **Next Target:** 3.2.0 (Q2 2027) +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f --- @@ -224,6 +229,47 @@ GridShield aims to become the **industry-standard open-source security framework --- +<<<<<<< HEAD +### Q2 2027 — Integration & Export Enhancement (v3.2.0) + +**Target Release:** June 2027 (completed March 2026) + +#### High Priority + +- [x] **Data Export API** + - [x] CSV export endpoints (readings, alerts, anomalies) + - [x] StreamingResponse with Content-Disposition headers + - [x] Meter-specific export filtering + +- [x] **Frontend Fleet Integration** + - [x] Fleet page refactored to use Meter CRUD API + - [x] Meter registration modal (create/delete) + - [x] Meter status indicators (online/offline/tampered) + - [x] CSV export buttons on all data pages + +- [x] **Evidence Preservation** (Firmware) + - [x] EvidenceStore with circular buffer (32 snapshots) + - [x] Hash chain integrity (FNV-1a tamper detection) + - [x] SensorSnapshot capture (energy, voltage, current, temperature, accelerometer) + - [x] Chain verification function + +#### Medium Priority + +- [x] **CI Enhancement** + - [x] Backend pytest job in GitHub Actions (7-job pipeline) + +- [x] **Backend Testing** + - [x] Export endpoint tests (6 tests) + - [x] Total backend tests: 37+ (31 existing + 6 new) + +- [x] **Firmware Tests** + - [x] EvidenceStore tests (8 tests) + - [x] Total firmware test count: 176 (168 + 8) + +--- + +======= +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f ## Feature Backlogs ### Core Features @@ -280,7 +326,11 @@ GridShield aims to become the **industry-standard open-source security framework - [x] **Forensics** - [x] Attack signature event logger (SecurityEvent + EventLogger) - [x] Incident timeline reconstruction (get_timeline) +<<<<<<< HEAD + - [x] Evidence preservation (EvidenceStore + hash chain) +======= - [ ] Evidence preservation +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f - [x] Automated reporting (IncidentReportGenerator) --- @@ -430,6 +480,10 @@ Features are prioritized based on: | **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) | +<<<<<<< HEAD +| **3.2.0** | March 2026 | Data export API (CSV), frontend fleet integration, evidence preservation, backend CI tests, 37+ backend tests, 176 firmware tests | +======= +>>>>>>> 469b660da70c38354fe5127353f451559b605a7f --- diff --git a/firmware/include/common/forensics/evidence_store.hpp b/firmware/include/common/forensics/evidence_store.hpp new file mode 100644 index 0000000..3dc9fc5 --- /dev/null +++ b/firmware/include/common/forensics/evidence_store.hpp @@ -0,0 +1,277 @@ +/** + * @file evidence_store.hpp + * @author Rafi Indra Pramudhito Zuhayr + * @brief Evidence preservation for forensic analysis + * @version 3.2.0 + * @date 2026-03-07 + * + * Stores immutable evidence snapshots with a tamper-evident hash chain. + * Each snapshot captures security event context with raw sensor readings + * and is linked to the previous snapshot via a chained hash, + * enabling integrity verification of the entire evidence log. + */ + +#pragma once + +#include "core/error.hpp" +#include "core/types.hpp" +#include "forensics/event_logger.hpp" +#include "utils/gs_macros.hpp" + +#include +#include +#include + + +namespace gridshield::forensics { + +// ============================================================================ +// CONSTANTS +// ============================================================================ +static constexpr size_t EVIDENCE_STORE_CAPACITY = 32; +static constexpr size_t EVIDENCE_HASH_SIZE = 32; // 256-bit hash +static constexpr size_t EVIDENCE_NOTES_MAX = 48; + +// ============================================================================ +// SENSOR SNAPSHOT — raw sensor state at time of evidence capture +// ============================================================================ +struct SensorSnapshot +{ + uint32_t energy_wh{0}; + uint32_t voltage_mv{0}; + uint32_t current_ma{0}; + int32_t temperature_c10{0}; // temperature × 10 + uint16_t power_factor{0}; + uint16_t accelerometer_mg{0}; // peak g-force in milli-g + + GS_CONSTEXPR SensorSnapshot() noexcept = default; +}; + +// ============================================================================ +// EVIDENCE SNAPSHOT — immutable forensic record +// ============================================================================ +struct EvidenceSnapshot +{ + core::timestamp_t timestamp{0}; + SecurityEventType event_type{SecurityEventType::None}; + SecurityEventSeverity severity{SecurityEventSeverity::Info}; + SourceLayer source_layer{SourceLayer::System}; + uint8_t sequence{0}; // monotonic counter (mod 256) + SensorSnapshot sensors{}; + char notes[EVIDENCE_NOTES_MAX]{}; + uint8_t hash[EVIDENCE_HASH_SIZE]{}; // hash of this snapshot + uint8_t prev_hash[EVIDENCE_HASH_SIZE]{}; // hash chain link + + GS_CONSTEXPR bool is_valid() const noexcept + { + return event_type != SecurityEventType::None && timestamp > 0; + } +}; + +// ============================================================================ +// SOFTWARE SHA-256 — lightweight (header-only, no heap, no mbedTLS dep) +// ============================================================================ +namespace detail { + +/** + * Simple deterministic hash for evidence chain. + * Uses a Fowler–Noll–Vo-1a variant (FNV-1a 256-bit) for lightweight + * tamper detection. This is NOT a cryptographic hash — it provides + * change detection, not security against a motivated attacker. + */ +inline void evidence_hash(const uint8_t* data, size_t len, uint8_t out[EVIDENCE_HASH_SIZE]) noexcept +{ + // FNV-1a offset basis spread across 32 bytes + uint8_t state[EVIDENCE_HASH_SIZE] = { + 0xcb, 0xf2, 0x9c, 0xe4, 0x84, 0x22, 0x23, 0x25, 0x14, 0x07, 0x3d, + 0xb5, 0xbf, 0x5b, 0x1e, 0x73, 0x3b, 0x0e, 0x77, 0x0a, 0xda, 0xe6, + 0x37, 0x39, 0x42, 0x11, 0xa5, 0xb3, 0x59, 0x8c, 0x2f, 0x45, + }; + + for (size_t i = 0; i < len; ++i) { + // XOR-fold the byte across the state + const uint8_t b = data[i]; + for (size_t j = 0; j < EVIDENCE_HASH_SIZE; ++j) { + state[j] ^= b; + // FNV multiply: state[j] = state[j] * 0x01000193 + // Simplified: shift-add for embedded + uint16_t v = static_cast(state[j]); + v = static_cast(v + (v << 1) + (v << 4) + (v << 7)); + state[j] = static_cast(v & 0xFF); + // Cascade carry + state[(j + 1) % EVIDENCE_HASH_SIZE] ^= static_cast(v >> 8); + } + } + + std::memcpy(out, state, EVIDENCE_HASH_SIZE); +} + +} // namespace detail + +// ============================================================================ +// EVIDENCE STORE — Circular buffer with hash chain +// ============================================================================ +class EvidenceStore +{ +public: + EvidenceStore() noexcept = default; + + /** + * @brief Preserve a new evidence snapshot. + * Links to previous snapshot via hash chain. Circular — oldest + * evidence is overwritten when capacity is reached. + */ + core::Result preserve(SecurityEventType type, + SecurityEventSeverity severity, + SourceLayer layer, + core::timestamp_t timestamp, + const SensorSnapshot& sensors, + const char* notes = nullptr) noexcept + { + auto& slot = snapshots_[write_index_]; + slot.timestamp = timestamp; + slot.event_type = type; + slot.severity = severity; + slot.source_layer = layer; + slot.sequence = sequence_++; + slot.sensors = sensors; + + if (notes != nullptr) { + std::strncpy(slot.notes, notes, EVIDENCE_NOTES_MAX - 1); + slot.notes[EVIDENCE_NOTES_MAX - 1] = '\0'; + } else { + slot.notes[0] = '\0'; + } + + // Copy previous hash into chain link + if (count_ > 0) { + size_t prev_idx = (write_index_ == 0) ? EVIDENCE_STORE_CAPACITY - 1 : write_index_ - 1; + std::memcpy(slot.prev_hash, snapshots_[prev_idx].hash, EVIDENCE_HASH_SIZE); + } else { + std::memset(slot.prev_hash, 0, EVIDENCE_HASH_SIZE); + } + + // Compute hash of this snapshot (over all fields except hash itself) + compute_hash(slot); + + write_index_ = (write_index_ + 1) % EVIDENCE_STORE_CAPACITY; + if (count_ < EVIDENCE_STORE_CAPACITY) { + ++count_; + } + + return core::Result::ok(); + } + + /** + * @brief Get the number of stored evidence snapshots. + */ + GS_NODISCARD size_t evidence_count() const noexcept + { + return count_; + } + + /** + * @brief Get an evidence snapshot by index (0 = oldest). + */ + GS_NODISCARD core::Result get_evidence(size_t index) const noexcept + { + if (index >= count_) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + + size_t actual = 0; + if (count_ < EVIDENCE_STORE_CAPACITY) { + actual = index; + } else { + actual = (write_index_ + index) % EVIDENCE_STORE_CAPACITY; + } + return core::Result(snapshots_[actual]); + } + + /** + * @brief Get the latest evidence snapshot. + */ + GS_NODISCARD core::Result latest() const noexcept + { + if (count_ == 0) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + return get_evidence(count_ - 1); + } + + /** + * @brief Verify the hash chain integrity of all stored evidence. + * @return true if chain is intact, false if tampered. + */ + GS_NODISCARD bool verify_chain() const noexcept + { + if (count_ == 0) { + return true; + } + + for (size_t i = 0; i < count_; ++i) { + auto result = get_evidence(i); + if (!result.is_ok()) { + return false; + } + const auto& snap = result.value(); + + // Verify this snapshot's own hash + EvidenceSnapshot temp = snap; + std::memset(temp.hash, 0, EVIDENCE_HASH_SIZE); + uint8_t recomputed[EVIDENCE_HASH_SIZE]{}; + compute_hash_raw(temp, recomputed); + if (std::memcmp(recomputed, snap.hash, EVIDENCE_HASH_SIZE) != 0) { + return false; + } + + // Verify chain link (skip first entry which has all-zero prev_hash) + if (i > 0) { + auto prev_result = get_evidence(i - 1); + if (!prev_result.is_ok()) { + return false; + } + if (std::memcmp(snap.prev_hash, prev_result.value().hash, EVIDENCE_HASH_SIZE) != + 0) { + return false; + } + } + } + return true; + } + + /** + * @brief Clear all evidence. + */ + void clear() noexcept + { + count_ = 0; + write_index_ = 0; + sequence_ = 0; + for (auto& snap : snapshots_) { + snap = EvidenceSnapshot{}; + } + } + +private: + void compute_hash(EvidenceSnapshot& snap) const noexcept + { + std::memset(snap.hash, 0, EVIDENCE_HASH_SIZE); + compute_hash_raw(snap, snap.hash); + } + + static void compute_hash_raw(const EvidenceSnapshot& snap, + uint8_t out[EVIDENCE_HASH_SIZE]) noexcept + { + // Hash over the entire snapshot struct (hash field is zeroed) + const auto* raw = reinterpret_cast(&snap); + detail::evidence_hash(raw, sizeof(EvidenceSnapshot), out); + } + + EvidenceSnapshot snapshots_[EVIDENCE_STORE_CAPACITY]{}; + size_t write_index_{0}; + size_t count_{0}; + uint8_t sequence_{0}; +}; + +} // namespace gridshield::forensics diff --git a/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx b/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx index 50dacf0..d24a4a7 100644 Binary files a/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx and b/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx differ diff --git a/firmware/test_app/main/test_evidence_store.cpp b/firmware/test_app/main/test_evidence_store.cpp new file mode 100644 index 0000000..2de29ca --- /dev/null +++ b/firmware/test_app/main/test_evidence_store.cpp @@ -0,0 +1,231 @@ +/** + * @file test_evidence_store.cpp + * @brief Unit tests for v3.2.0 EvidenceStore module + * + * Tests evidence preservation, hash chain integrity, and circular overflow. + */ + +#include "unity.h" + +#include "forensics/evidence_store.hpp" + +using namespace gridshield; +using namespace gridshield::forensics; + +// ============================================================================ +// EvidenceStore Tests +// ============================================================================ + +static SensorSnapshot make_sensor(uint32_t energy, uint32_t voltage, uint32_t current) +{ + SensorSnapshot s; + s.energy_wh = energy; + s.voltage_mv = voltage; + s.current_ma = current; + return s; +} + +static void test_evidence_store_basic() +{ + EvidenceStore store; + TEST_ASSERT_EQUAL(0, store.evidence_count()); + + auto result = store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + "Panel opened"); + TEST_ASSERT_TRUE(result.is_ok()); + TEST_ASSERT_EQUAL(1, store.evidence_count()); + + auto snap = store.get_evidence(0); + TEST_ASSERT_TRUE(snap.is_ok()); + TEST_ASSERT_EQUAL(1000, snap.value().timestamp); + TEST_ASSERT_EQUAL(static_cast(SecurityEventType::CasingOpened), + static_cast(snap.value().event_type)); + TEST_ASSERT_EQUAL(500, snap.value().sensors.energy_wh); + TEST_ASSERT_EQUAL_STRING("Panel opened", snap.value().notes); +} + +static void test_evidence_store_multiple() +{ + EvidenceStore store; + store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + nullptr); + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + make_sensor(1500, 230000, 6000), + nullptr); + store.preserve(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 3000, + make_sensor(600, 219000, 2500), + nullptr); + + TEST_ASSERT_EQUAL(3, store.evidence_count()); + + auto latest = store.latest(); + TEST_ASSERT_TRUE(latest.is_ok()); + TEST_ASSERT_EQUAL(3000, latest.value().timestamp); +} + +static void test_evidence_store_hash_populated() +{ + EvidenceStore store; + store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + nullptr); + + auto snap = store.get_evidence(0); + TEST_ASSERT_TRUE(snap.is_ok()); + + // Hash should be non-zero + bool all_zero = true; + for (size_t i = 0; i < EVIDENCE_HASH_SIZE; ++i) { + if (snap.value().hash[i] != 0) { + all_zero = false; + break; + } + } + TEST_ASSERT_FALSE(all_zero); + + // First entry's prev_hash should be all zeros + bool prev_zero = true; + for (size_t i = 0; i < EVIDENCE_HASH_SIZE; ++i) { + if (snap.value().prev_hash[i] != 0) { + prev_zero = false; + break; + } + } + TEST_ASSERT_TRUE(prev_zero); +} + +static void test_evidence_store_hash_chain_link() +{ + EvidenceStore store; + store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + nullptr); + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + make_sensor(1500, 230000, 6000), + nullptr); + + auto first = store.get_evidence(0); + auto second = store.get_evidence(1); + TEST_ASSERT_TRUE(first.is_ok()); + TEST_ASSERT_TRUE(second.is_ok()); + + // Second entry's prev_hash should equal first entry's hash + TEST_ASSERT_EQUAL_UINT8_ARRAY(first.value().hash, second.value().prev_hash, EVIDENCE_HASH_SIZE); +} + +static void test_evidence_store_verify_chain() +{ + EvidenceStore store; + + // Store 5 snapshots + for (size_t i = 0; i < 5; ++i) { + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + static_cast(1000 + i * 1000), + make_sensor(static_cast(500 + i * 100), 220000, 2000), + nullptr); + } + + TEST_ASSERT_EQUAL(5, store.evidence_count()); + TEST_ASSERT_TRUE(store.verify_chain()); +} + +static void test_evidence_store_circular_overflow() +{ + EvidenceStore store; + + // Overflow: write capacity + 3 + for (size_t i = 0; i < EVIDENCE_STORE_CAPACITY + 3; ++i) { + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Low, + SourceLayer::Analytics, + static_cast(1000 + i * 100), + make_sensor(static_cast(500 + i), 220000, 2000), + nullptr); + } + + TEST_ASSERT_EQUAL(EVIDENCE_STORE_CAPACITY, store.evidence_count()); + + // Oldest should be the 4th entry (first 3 overwritten) + auto oldest = store.get_evidence(0); + TEST_ASSERT_TRUE(oldest.is_ok()); + TEST_ASSERT_EQUAL(1300, oldest.value().timestamp); // 1000 + 3*100 + + // Latest should be the last written + auto latest = store.latest(); + TEST_ASSERT_TRUE(latest.is_ok()); + // 1000 + (32+2)*100 = 1000 + 3400 = 4400 + TEST_ASSERT_EQUAL(4400, latest.value().timestamp); +} + +static void test_evidence_store_latest_empty() +{ + EvidenceStore store; + auto result = store.latest(); + TEST_ASSERT_TRUE(result.is_error()); +} + +static void test_evidence_store_sequence_counter() +{ + EvidenceStore store; + store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + nullptr); + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + make_sensor(600, 221000, 2100), + nullptr); + + auto first = store.get_evidence(0); + auto second = store.get_evidence(1); + TEST_ASSERT_TRUE(first.is_ok()); + TEST_ASSERT_TRUE(second.is_ok()); + + TEST_ASSERT_EQUAL(0, first.value().sequence); + TEST_ASSERT_EQUAL(1, second.value().sequence); +} + +// ============================================================================ +// TEST SUITE ENTRY POINT +// ============================================================================ + +extern "C" void test_evidence_store_suite(void) +{ + RUN_TEST(test_evidence_store_basic); + RUN_TEST(test_evidence_store_multiple); + RUN_TEST(test_evidence_store_hash_populated); + RUN_TEST(test_evidence_store_hash_chain_link); + RUN_TEST(test_evidence_store_verify_chain); + RUN_TEST(test_evidence_store_circular_overflow); + RUN_TEST(test_evidence_store_latest_empty); + RUN_TEST(test_evidence_store_sequence_counter); +} diff --git a/firmware/test_app/main/test_main.cpp b/firmware/test_app/main/test_main.cpp index cc14694..1ddc8aa 100644 --- a/firmware/test_app/main/test_main.cpp +++ b/firmware/test_app/main/test_main.cpp @@ -30,6 +30,8 @@ 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 test_evidence_store_suite(void); + extern "C" void app_main(void) { @@ -63,6 +65,8 @@ extern "C" void app_main(void) test_sensors_suite(); test_ota_power_suite(); test_forensics_suite(); + test_evidence_store_suite(); + int failures = UNITY_END(); diff --git a/frontend/src/api.js b/frontend/src/api.js index 91ee114..958638b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -54,3 +54,72 @@ export function getAnomalies({ meterId, limit = 50 } = {}) { const qs = params.toString(); return request(`/anomalies${qs ? '?' + qs : ''}`); } + +// ============================================================================ +// Meter CRUD +// ============================================================================ + +/** List all registered meters */ +export function getMeters({ status } = {}) { + const params = new URLSearchParams(); + if (status) params.set('status', status); + const qs = params.toString(); + return request(`/meters${qs ? '?' + qs : ''}`); +} + +/** Register a new meter */ +export function createMeter(data) { + return request('/meters', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** Update meter info */ +export function updateMeter(meterId, data) { + return request(`/meters/${meterId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }); +} + +/** Delete a meter */ +export function deleteMeter(meterId) { + return fetch(`${BASE}/meters/${meterId}`, { method: 'DELETE' }); +} + +/** Get meter statistics */ +export function getMeterStats(meterId) { + return request(`/meters/${meterId}/stats`); +} + +// ============================================================================ +// CSV Export — triggers download +// ============================================================================ + +function triggerDownload(url, fallbackName) { + const a = document.createElement('a'); + a.href = `${BASE}${url}`; + a.download = fallbackName; + document.body.appendChild(a); + a.click(); + a.remove(); +} + +/** Export readings as CSV */ +export function exportReadings(meterId) { + const params = meterId != null ? `?meter_id=${meterId}` : ''; + triggerDownload(`/export/readings${params}`, 'gridshield_readings.csv'); +} + +/** Export alerts as CSV */ +export function exportAlerts(meterId) { + const params = meterId != null ? `?meter_id=${meterId}` : ''; + triggerDownload(`/export/alerts${params}`, 'gridshield_alerts.csv'); +} + +/** Export anomalies as CSV */ +export function exportAnomalies(meterId) { + const params = meterId != null ? `?meter_id=${meterId}` : ''; + triggerDownload(`/export/anomalies${params}`, 'gridshield_anomalies.csv'); +} diff --git a/frontend/src/pages/alerts.js b/frontend/src/pages/alerts.js index 8a40c06..8afa000 100644 --- a/frontend/src/pages/alerts.js +++ b/frontend/src/pages/alerts.js @@ -3,52 +3,52 @@ * Tamper alert management with filtering and acknowledge action */ -import { getAlerts, acknowledgeAlert } from '../api.js'; +import { getAlerts, acknowledgeAlert, exportAlerts } from '../api.js'; const TAMPER_ICONS = { - CasingOpened: '📦', - MagneticInterference: '🧲', - PowerCutAttempt: '🔌', - PhysicalShock: '💥', - VibrationDetected: '📳', - TemperatureAnomaly: '🌡️', + CasingOpened: '📦', + MagneticInterference: '🧲', + PowerCutAttempt: '🔌', + PhysicalShock: '💥', + VibrationDetected: '📳', + TemperatureAnomaly: '🌡️', }; function severityBadge(sev) { - const map = { - 4: ['CRITICAL', 'badge-critical'], - 3: ['HIGH', 'badge-high'], - 2: ['MEDIUM', 'badge-medium'], - 1: ['LOW', 'badge-low'], - 0: ['INFO', 'badge-info'], - }; - const [label, cls] = map[sev] || map[0]; - return `${label}`; + const map = { + 4: ['CRITICAL', 'badge-critical'], + 3: ['HIGH', 'badge-high'], + 2: ['MEDIUM', 'badge-medium'], + 1: ['LOW', 'badge-low'], + 0: ['INFO', 'badge-info'], + }; + const [label, cls] = map[sev] || map[0]; + return `${label}`; } function formatTime(ts) { - return new Date(ts).toLocaleString([], { - month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit', - }); + return new Date(ts).toLocaleString([], { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); } function formatMeterId(id) { - return id.toString(16).toUpperCase().slice(-8); + return id.toString(16).toUpperCase().slice(-8); } export default async function renderAlerts(container) { - let filter = 'all'; + let filter = 'all'; - async function refresh() { - const params = {}; - if (filter === 'unacknowledged') params.acknowledged = false; - if (filter === 'acknowledged') params.acknowledged = true; - params.limit = 100; + async function refresh() { + const params = {}; + if (filter === 'unacknowledged') params.acknowledged = false; + if (filter === 'acknowledged') params.acknowledged = true; + params.limit = 100; - const alerts = await getAlerts(params); + const alerts = await getAlerts(params); - container.innerHTML = ` + container.innerHTML = `
@@ -94,13 +95,13 @@ export default async function renderAlerts(container) { ${severityBadge(a.severity)} ${a.acknowledged - ? 'Acknowledged' - : '⏳ Pending'} + ? 'Acknowledged' + : '⏳ Pending'} ${a.acknowledged - ? '—' - : ``} + ? '—' + : ``} `).join('')} @@ -111,29 +112,34 @@ export default async function renderAlerts(container) {
`; - // Bind filter - document.getElementById('alert-filter')?.addEventListener('change', (e) => { - filter = e.target.value; - refresh(); - }); + // Bind filter + document.getElementById('alert-filter')?.addEventListener('change', (e) => { + filter = e.target.value; + refresh(); + }); - // Bind acknowledge buttons - container.querySelectorAll('[data-ack-id]').forEach(btn => { - btn.addEventListener('click', async (e) => { - const id = e.target.dataset.ackId; - e.target.disabled = true; - e.target.textContent = '...'; - try { - await acknowledgeAlert(id); - await refresh(); - } catch (err) { - alert('Failed to acknowledge: ' + err.message); - e.target.disabled = false; - e.target.textContent = 'Acknowledge'; - } - }); - }); - } + // Export button + document.getElementById('export-alerts-btn')?.addEventListener('click', () => { + exportAlerts(); + }); + + // Bind acknowledge buttons + container.querySelectorAll('[data-ack-id]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const id = e.target.dataset.ackId; + e.target.disabled = true; + e.target.textContent = '...'; + try { + await acknowledgeAlert(id); + await refresh(); + } catch (err) { + alert('Failed to acknowledge: ' + err.message); + e.target.disabled = false; + e.target.textContent = 'Acknowledge'; + } + }); + }); + } - await refresh(); + await refresh(); } diff --git a/frontend/src/pages/anomalies.js b/frontend/src/pages/anomalies.js index fcb53ff..7f9535d 100644 --- a/frontend/src/pages/anomalies.js +++ b/frontend/src/pages/anomalies.js @@ -3,23 +3,23 @@ * Anomaly detection logs with deviation visualization */ -import { getAnomalies } from '../api.js'; +import { getAnomalies, exportAnomalies } from '../api.js'; function severityBadge(sev) { - const s = (sev || '').toLowerCase(); - if (s === 'critical') return 'CRITICAL'; - if (s === 'high') return 'HIGH'; - if (s === 'medium') return 'MEDIUM'; - return 'LOW'; + const s = (sev || '').toLowerCase(); + if (s === 'critical') return 'CRITICAL'; + if (s === 'high') return 'HIGH'; + if (s === 'medium') return 'MEDIUM'; + return 'LOW'; } function deviationBar(pct) { - const clamped = Math.min(Math.abs(pct), 100); - let color = 'var(--color-green)'; - if (clamped >= 80) color = 'var(--color-red)'; - else if (clamped >= 50) color = 'var(--color-orange)'; - else if (clamped >= 30) color = 'var(--color-amber)'; - return ` + const clamped = Math.min(Math.abs(pct), 100); + let color = 'var(--color-green)'; + if (clamped >= 80) color = 'var(--color-red)'; + else if (clamped >= 50) color = 'var(--color-orange)'; + else if (clamped >= 30) color = 'var(--color-amber)'; + return `
@@ -30,28 +30,28 @@ function deviationBar(pct) { } function formatTime(ts) { - return new Date(ts).toLocaleString([], { - month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit', - }); + return new Date(ts).toLocaleString([], { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); } function formatMeterId(id) { - return id.toString(16).toUpperCase().slice(-8); + return id.toString(16).toUpperCase().slice(-8); } export default async function renderAnomalies(container) { - let typeFilter = 'all'; + let typeFilter = 'all'; - async function refresh() { - const anomalies = await getAnomalies({ limit: 100 }); + async function refresh() { + const anomalies = await getAnomalies({ limit: 100 }); - const types = [...new Set(anomalies.map(a => a.anomaly_type))]; - const filtered = typeFilter === 'all' - ? anomalies - : anomalies.filter(a => a.anomaly_type === typeFilter); + const types = [...new Set(anomalies.map(a => a.anomaly_type))]; + const filtered = typeFilter === 'all' + ? anomalies + : anomalies.filter(a => a.anomaly_type === typeFilter); - container.innerHTML = ` + container.innerHTML = `
@@ -108,11 +109,15 @@ export default async function renderAnomalies(container) {
`; - document.getElementById('anomaly-type-filter')?.addEventListener('change', (e) => { - typeFilter = e.target.value; - refresh(); - }); - } + document.getElementById('anomaly-type-filter')?.addEventListener('change', (e) => { + typeFilter = e.target.value; + refresh(); + }); + + document.getElementById('export-anomalies-btn')?.addEventListener('click', () => { + exportAnomalies(); + }); + } - await refresh(); + await refresh(); } diff --git a/frontend/src/pages/dashboard.js b/frontend/src/pages/dashboard.js index 824d0bd..4c21718 100644 --- a/frontend/src/pages/dashboard.js +++ b/frontend/src/pages/dashboard.js @@ -3,36 +3,36 @@ * KPI cards + energy chart + recent alerts */ -import { getStatus, getReadings, getAlerts } from '../api.js'; +import { getStatus, getReadings, getAlerts, exportReadings } from '../api.js'; import { createLineChart } from '../components/chart.js'; /** Helper: format timestamp */ function timeAgo(ts) { - const diff = Date.now() - new Date(ts).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - return `${Math.floor(hrs / 24)}d ago`; + const diff = Date.now() - new Date(ts).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; } function severityColor(sev) { - if (sev >= 3) return 'var(--color-red)'; - if (sev >= 2) return 'var(--color-orange)'; - if (sev >= 1) return 'var(--color-amber)'; - return 'var(--color-green)'; + if (sev >= 3) return 'var(--color-red)'; + if (sev >= 2) return 'var(--color-orange)'; + if (sev >= 1) return 'var(--color-amber)'; + return 'var(--color-green)'; } export default async function renderDashboard(container) { - // Fetch data in parallel - const [status, readings, alerts] = await Promise.all([ - getStatus(), - getReadings({ limit: 100 }), - getAlerts({ limit: 10 }), - ]); - - container.innerHTML = ` + // Fetch data in parallel + const [status, readings, alerts] = await Promise.all([ + getStatus(), + getReadings({ limit: 100 }), + getAlerts({ limit: 10 }), + ]); + + container.innerHTML = `