From 529561b41e8f65657b740c66564380600b3c9f0a Mon Sep 17 00:00:00 2001 From: sudoNaji Date: Mon, 20 Jul 2026 16:34:28 +0500 Subject: [PATCH 1/2] UI improvements, ESP32 support, persistent data paths, pipeline wiring phases 1-7, sidebar dynamic badges, firmware extraction fixes --- .gitignore | 10 + backend/core/compliance/report_generator.py | 4 +- backend/core/cve_mapping/build_nvd_index.py | 4 +- backend/core/cve_mapping/cve_aggregator.py | 8 +- backend/core/cve_mapping/epss_scorer.py | 2 +- backend/core/cve_mapping/kev_checker.py | 2 +- backend/core/cve_mapping/nvd_lookup.py | 2 +- backend/core/firmware_extraction/extractor.py | 22 +- backend/core/firmware_extraction/routes.py | 43 ++-- backend/core/sbom/avr_component_extractor.py | 23 ++- backend/core/sbom/generator.py | 38 +++- backend/core/sbom/routes.py | 6 +- backend/core/static_analysis/analyzer.py | 10 + backend/core/vex_engine/analyzer.py | 17 +- backend/requirements.txt | 1 + frontend/package.json | 2 +- frontend/src/components/Layout.jsx | 194 +++++++++++++----- frontend/src/index.css | 41 +++- frontend/src/pages/AISettings.jsx | 131 +++++------- frontend/src/pages/Dashboard.jsx | 104 +++++++--- frontend/src/pages/DeviceDetection.jsx | 6 +- frontend/src/pages/FirmwareExtraction.jsx | 88 ++++++-- frontend/src/pages/StaticAnalysis.jsx | 32 ++- frontend/src/pages/VEXPage.jsx | 15 +- scripts/dev.sh | 0 scripts/install.sh | 0 26 files changed, 559 insertions(+), 246 deletions(-) mode change 100755 => 100644 scripts/dev.sh mode change 100755 => 100644 scripts/install.sh diff --git a/.gitignore b/.gitignore index 25b86a0..8882cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,13 @@ routes.py.bak backend/__pycache__/ *.pyc unblob.log +data/ +backend/venv/ +backend/__pycache__/ +backend/core/**/__pycache__/ +*.pyc +*.pyo +frontend/node_modules/ + +# Reference dashboard template source +corona-free-dark-bootstrap-admin-template-1.0.0/ diff --git a/backend/core/compliance/report_generator.py b/backend/core/compliance/report_generator.py index 6cce063..f216961 100644 --- a/backend/core/compliance/report_generator.py +++ b/backend/core/compliance/report_generator.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -OUTPUT_BASE = Path("/tmp/iifvdcas_compliance") +OUTPUT_BASE = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/compliance") @dataclass @@ -98,7 +98,7 @@ def _load_sbom(sbom_job_id: str) -> Dict: except Exception: pass # Disk fallback - base = Path(f"/tmp/iifvdcas_sbom/{sbom_job_id}") + base = Path(f"/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/sbom/{sbom_job_id}") result = {} for fname, key in [("cyclonedx_sbom.json","sbom"), ("hbom.json","hbom"), ("cbom.json","cbom")]: diff --git a/backend/core/cve_mapping/build_nvd_index.py b/backend/core/cve_mapping/build_nvd_index.py index 823b59b..b5aa113 100644 --- a/backend/core/cve_mapping/build_nvd_index.py +++ b/backend/core/cve_mapping/build_nvd_index.py @@ -2,7 +2,7 @@ """ Build a SQLite index of the local NVD per-CVE JSON feed. Run once: python3 build_nvd_index.py -Output: /tmp/iifvdcas_nvd.db (~50–80 MB) +Output: /mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/nvd.db (~50–80 MB) Schema: cve(id, published, cvss_score, cvss_severity, cvss_vector, description, cwe_ids, path) @@ -16,7 +16,7 @@ NVD_ROOT = Path.home() / "emba" / "external" / "nvd-json-data-feeds" EPSS_DIR = Path.home() / "emba" / "external" / "EPSS-data" / "EPSS_CVE_data" -DB_PATH = Path("/tmp/iifvdcas_nvd.db") +DB_PATH = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/nvd.db") STATE_CSV = NVD_ROOT / "_state.csv" def get_cve_path(cve_id: str) -> Path: diff --git a/backend/core/cve_mapping/cve_aggregator.py b/backend/core/cve_mapping/cve_aggregator.py index 117e65a..6494a6d 100644 --- a/backend/core/cve_mapping/cve_aggregator.py +++ b/backend/core/cve_mapping/cve_aggregator.py @@ -109,7 +109,7 @@ def _load_components(sbom_job_id: str) -> List[Dict]: except Exception as e: logger.debug(f"[CVE] memory lookup: {e}") - base = Path(f"/tmp/iifvdcas_sbom/{sbom_job_id}") + base = Path(f"/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/sbom/{sbom_job_id}") for name in ("cyclonedx_sbom.json", "sbom.json", "components.json", "bom.json"): fp = base / name if not fp.exists(): @@ -152,7 +152,7 @@ def _is_real_package(comp: Dict) -> bool: """Return True only if this component should be looked up in NVD.""" name = (comp.get("name") or "").lower().strip() source = (comp.get("source_path") or "").lower() - vendor = (comp.get("vendor") or "").strip() + vendor = (comp.get("vendor") or comp.get("supplier") or "").strip() cpe = (comp.get("cpe") or "").strip() if name in _SECURITY_MARKERS: @@ -168,7 +168,7 @@ def _is_real_package(comp: Dict) -> bool: def _should_do_product_lookup(comp: Dict) -> bool: """Return True if a name-based NVD product lookup is appropriate.""" - vendor = (comp.get("vendor") or "").strip() + vendor = (comp.get("vendor") or comp.get("supplier") or "").strip() source = (comp.get("source_path") or "").lower() if source in _FIRMWARE_ID_SOURCES: return False @@ -202,7 +202,7 @@ def _map_all( name = (comp.get("name") or "").strip() version = (comp.get("version") or "").strip() cpe = (comp.get("cpe") or "").strip() - vendor = (comp.get("vendor") or "").strip() + vendor = (comp.get("vendor") or comp.get("supplier") or "").strip() pct = 10 + int(i / max(total, 1) * 55) emit(pct, f"[{i+1}/{total}] {name}") diff --git a/backend/core/cve_mapping/epss_scorer.py b/backend/core/cve_mapping/epss_scorer.py index d576ee5..049df50 100644 --- a/backend/core/cve_mapping/epss_scorer.py +++ b/backend/core/cve_mapping/epss_scorer.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) -DB_PATH = Path("/tmp/iifvdcas_nvd.db") +DB_PATH = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/nvd.db") EPSS_DIR = Path.home() / "emba" / "external" / "EPSS-data" / "EPSS_CVE_data" _csv_cache: Dict[str, float] = {} diff --git a/backend/core/cve_mapping/kev_checker.py b/backend/core/cve_mapping/kev_checker.py index 553f2a0..e7cec07 100644 --- a/backend/core/cve_mapping/kev_checker.py +++ b/backend/core/cve_mapping/kev_checker.py @@ -14,7 +14,7 @@ # KEV data locations KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" -KEV_CACHE_PATH = Path("/tmp/iifvdcas_kev_cache.json") +KEV_CACHE_PATH = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/kev_cache.json") KEV_CACHE_TTL = 86400 # 24 hours in seconds # In-memory cache for KEV IDs diff --git a/backend/core/cve_mapping/nvd_lookup.py b/backend/core/cve_mapping/nvd_lookup.py index de24111..6f7ca0b 100644 --- a/backend/core/cve_mapping/nvd_lookup.py +++ b/backend/core/cve_mapping/nvd_lookup.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -DB_PATH = Path("/tmp/iifvdcas_nvd.db") +DB_PATH = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/nvd.db") NVD_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0" OSV_BATCH = "https://api.osv.dev/v1/querybatch" diff --git a/backend/core/firmware_extraction/extractor.py b/backend/core/firmware_extraction/extractor.py index ff1222a..4a3b6fa 100644 --- a/backend/core/firmware_extraction/extractor.py +++ b/backend/core/firmware_extraction/extractor.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) -EXTRACT_BASE = Path("/tmp/iifvdcas_extractions") +EXTRACT_BASE = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/extractions") # ─── Enums matching EMBA detection logic ───────────────────────────────────── @@ -256,6 +256,9 @@ def analyze_firmware_file(fw_path: str) -> FirmwareFileInfo: info.firmware_type = FirmwareType.UBOOT elif "CPIO ARCHIVE" in f: info.firmware_type = FirmwareType.CPIO + elif "ESP IMAGE" in bw or "ESP32" in bw or "XTENSA" in bw.upper(): + info.firmware_type = FirmwareType.RAW_BINARY + info.architecture_hint = "Xtensa" elif any(k in f for k in ["GZIP", "ZIP", "XZ", "BZIP2", "7-ZIP", "TAR", "ISO 9660"]): info.firmware_type = FirmwareType.ARCHIVE info.is_compressed = True @@ -636,7 +639,7 @@ def progress(pct: int, step: str): status=ExtractionStatus.RUNNING, ) - # Output dir: /tmp/iifvdcas_extractions// + # Output dir: /mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/extractions// output_dir = str(EXTRACT_BASE / job_id) os.makedirs(output_dir, exist_ok=True) result.output_dir = output_dir @@ -745,10 +748,17 @@ def progress(pct: int, step: str): log("[P50] binwalk extraction succeeded") progress(50, "binwalk extraction complete") else: - log("[P60] Both unblob and binwalk found nothing — firmware may be raw binary") - result.extractor_used = "none" - result.status = ExtractionStatus.PARTIAL - progress(50, "No extractor succeeded") + log("[P60] Both unblob and binwalk found nothing — copying raw binary for string analysis") + # Copy raw firmware to output dir so S108 can scan it + import shutil + raw_dir = os.path.join(output_dir, "raw_binary") + os.makedirs(raw_dir, exist_ok=True) + raw_copy = os.path.join(raw_dir, Path(fw_path).name) + shutil.copy2(fw_path, raw_copy) + extracted = True + result.extractor_used = "raw_copy" + log(f"[P60] Raw binary copied to {raw_copy} for string-based analysis") + progress(50, "Raw binary ready for analysis") # ── Step 3: Count extracted files ───────────────────────────────── progress(60, "Counting extracted files") diff --git a/backend/core/firmware_extraction/routes.py b/backend/core/firmware_extraction/routes.py index 108c403..aae0b9d 100644 --- a/backend/core/firmware_extraction/routes.py +++ b/backend/core/firmware_extraction/routes.py @@ -15,6 +15,7 @@ import re import shutil import subprocess +import sys import threading from datetime import datetime, timezone from pathlib import Path @@ -30,8 +31,8 @@ router = APIRouter(prefix="/api/v1/firmware", tags=["firmware"]) -UPLOAD_DIR = Path("/tmp/iifvdcas_uploads"); UPLOAD_DIR.mkdir(parents=True, exist_ok=True) -FIRMWARE_STORE = Path("/tmp/iifvdcas_firmware_store"); FIRMWARE_STORE.mkdir(parents=True, exist_ok=True) +UPLOAD_DIR = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/uploads"); UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +FIRMWARE_STORE = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/firmware_store"); FIRMWARE_STORE.mkdir(parents=True, exist_ok=True) extraction_jobs: dict[str, dict] = {} @@ -199,26 +200,29 @@ def _esptool_read_cmd( no_stub: bool = False, ) -> list[str]: """ - Build the correct esptool read_flash command. + Build the correct esptool v5 read-flash command. Key fixes vs previous version: - - 'read_flash' (underscore), NOT 'read-flash' or 'read-flash' + - 'read-flash' (hyphen), as required by esptool v5 - Flash size is an explicit hex integer, NOT the string "ALL" - - --before default_reset (underscore), --after hard_reset (underscore) + - --before default-reset, --after hard-reset - --no-stub flag added when no_stub=True """ size_arg = hex(flash_bytes) if flash_bytes else "0x400000" # default 4 MB + # Use the API process's interpreter so virtual-environment PATH handling + # cannot make the installed esptool command disappear. cmd = [ - "esptool", + sys.executable, + "-m", "esptool", "--port", device_port, "--baud", str(baud), "--chip", chip, - "--before", "default_reset", - "--after", "hard_reset", + "--before", "default-reset", + "--after", "hard-reset", ] if no_stub: cmd.append("--no-stub") - cmd += ["read_flash", "0x0", size_arg, out_path] + cmd += ["read-flash", "0x0", size_arg, out_path] return cmd @@ -247,7 +251,7 @@ async def emit(event: str, **kwargs): await emit("extraction_started") await emit("progress", percent=2, step="Preparing directories") - output_dir = Path("/tmp/iifvdcas_extractions") / job_id + output_dir = Path("/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/extractions") / job_id output_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") chip_label = chip if tool == "esptool" else mcu @@ -346,6 +350,7 @@ def _stream(cmd=cmd, timeout_s=timeout_s): attempt_error = "" corrupt_data = False detected_size = None + last_read_pct = -1 while True: try: @@ -367,9 +372,12 @@ def _stream(cmd=cmd, timeout_s=timeout_s): continue # Parse progress percentage - m_pct = re.search(r"\(\s*(\d+)\s*%\)", value) + m_pct = re.search(r"(\d+(?:\.\d+)?)%", value) if m_pct: - pct = int(m_pct.group(1)) + pct = int(float(m_pct.group(1))) + if pct == last_read_pct: + continue + last_read_pct = pct stub_label = "(--no-stub)" if is_no_stub else "" await emit("progress", percent=5 + int(pct * 0.15), @@ -553,6 +561,17 @@ async def get_file_tree(job_id: str): "flash_bin_size_human": job.get("flash_bin_size_human")} +@router.get("/extract/{job_id}/download") +async def download_firmware(job_id: str): + if job_id not in extraction_jobs: + raise HTTPException(404, "Job not found") + job = extraction_jobs[job_id] + path = job.get("flash_bin_path") + filename = job.get("flash_bin_filename", "flash.bin") + if not path or not Path(path).exists(): + raise HTTPException(404, "Firmware file not found") + return FileResponse(path, media_type="application/octet-stream", filename=filename) + @router.delete("/extract/{job_id}") async def delete_extraction(job_id: str): cleanup_extraction(job_id) diff --git a/backend/core/sbom/avr_component_extractor.py b/backend/core/sbom/avr_component_extractor.py index 059bf4b..fb0b718 100644 --- a/backend/core/sbom/avr_component_extractor.py +++ b/backend/core/sbom/avr_component_extractor.py @@ -64,8 +64,17 @@ None, None, None), ("ESP8266", "esp8266", "espressif", "esp8266_sdk", None, None, None), + # MicroPython (must come before ESP32 generic) + ("MicroPython", "micropython", "micropython", "micropython", + None, None, r"MicroPython[\s/v]+([\d\.]+)"), + ("MPY version", "micropython", "micropython", "micropython", + None, None, r"MPY version\s*:\s*v?([\d\.]+)"), + + # ESP-IDF framework + ("ESP-IDF", "esp-idf", "espressif", "esp-idf", + None, None, r"IDF version\s*:\s*v?([\d\.]+)"), ("ESP32", "esp32", "espressif", "esp-idf", - None, None, None), + None, None, r"ESP-IDF[:\s/v]+([\d\.]+)"), # ── Parsing / Serialization ─────────────────────────────────────────────── ("ArduinoJson", "arduinojson", "bblanchon", "arduinojson", @@ -225,6 +234,18 @@ def detect_avr_components( # Firmware identity fw_name = "custom-avr-firmware" fw_ver = "unknown" + # MicroPython specific version extraction + for s in all_strings: + m = re.search(r'MicroPython[\s/v]+([\d\.]+)', s, re.IGNORECASE) + if m: + fw_name = "micropython" + fw_ver = m.group(1) + break + m2 = re.search(r'MPY version\s*:\s*v?([\d\.]+)', s) + if m2: + fw_name = "micropython" + fw_ver = m2.group(1) + break for s in all_strings: m = re.search(r'([A-Za-z][A-Za-z0-9\-]{3,20})[-_]IoT|([A-Za-z][A-Za-z0-9\-]{3,20})-Firmware', s) if m: diff --git a/backend/core/sbom/generator.py b/backend/core/sbom/generator.py index 4179bc4..00b8587 100644 --- a/backend/core/sbom/generator.py +++ b/backend/core/sbom/generator.py @@ -217,14 +217,20 @@ def run_f02_toolchain(target_dir: str, log_cb) -> dict: bin_files = list(Path(target_dir).rglob("*.bin")) for bf in bin_files[:3]: str_out, _, _ = _run(["strings", "-n", "4", str(bf)], timeout=10) - if "avr-gcc" in str_out.lower() or "avr" in str_out.lower(): + strings_lower = str_out.lower() + if any(marker in strings_lower for marker in ("esp-idf", "esp32", "esp8266", "xtensa")): + toolchain["architecture"] = "Xtensa (ESP32/ESP8266)" + toolchain["compiler_hint"] = "ESP-IDF" + toolchain["libc_type"] = "newlib" + break + if "avr-gcc" in strings_lower or "avr-libc" in strings_lower: toolchain["architecture"] = "AVR (ATmega)" toolchain["compiler_hint"] = "avr-gcc" toolchain["libc_type"] = "avr-libc" break # If still unknown and .bin/.hex files present → AVR if toolchain["architecture"] == "unknown": - if any(Path(target_dir).rglob("*.bin")) or any(Path(target_dir).rglob("*.hex")): + if any(Path(target_dir).rglob("*.hex")): toolchain["architecture"] = "AVR (ATmega)" toolchain["libc_type"] = "avr-libc" log_cb(f"[F02] Arch: {toolchain['architecture']} | libc: {toolchain['libc_type']} | compiler: {toolchain['compiler_hint']}") @@ -525,7 +531,7 @@ def progress(pct: int, step: str): status="running", ) prior = prior_analysis or {} - output_dir = Path(f"/tmp/iifvdcas_sbom/{job_id}") + output_dir = Path(f"/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/sbom/{job_id}") output_dir.mkdir(parents=True, exist_ok=True) if not os.path.isdir(target_dir): @@ -546,13 +552,31 @@ def progress(pct: int, step: str): cfg_comps = _detect_config_components(target_dir, log) all_comps = elf_comps + cfg_comps + if result.toolchain.get("architecture", "").startswith("Xtensa"): + esp_strings = "" + for bf in list(Path(target_dir).rglob("*.bin"))[:3]: + out, _, _ = _run(["strings", "-n", "4", str(bf)], timeout=15) + esp_strings += out + "\n" + match = re.search(r"(?:ESP-IDF[^\n]{0,80}|[A-Za-z0-9_-]*idf)[^\n]*?v?(\d+\.\d+\.\d+)", esp_strings, re.I) + if not match: + match = re.search(r"(?:idf|esp|qa-test)[A-Za-z0-9_-]*-v(\d+\.\d+\.\d+)", esp_strings, re.I) + if match: + version = match.group(1) + all_comps.append(SBOMComponent( + bom_ref=str(uuid.uuid4()), component_type="framework", + name="esp-idf", version=version, supplier="espressif", + license="Apache-2.0", source_path="esp-idf-version-scan", + cpe=f"cpe:2.3:a:espressif:esp-idf:{version}:*:*:*:*:*:*:*", + purl=f"pkg:generic/espressif/esp-idf@{version}", + )) + log(f"[ESP-SBOM] ESP-IDF {version} detected from firmware strings") + else: + log("[ESP-SBOM] ESP-IDF detected but no reliable version string was found") + # ── AVR/MCU: Embedded library detection ────────────────────────── # For raw AVR firmware (no ELF/package managers), scan binary # strings for known Arduino/embedded library signatures with CPEs. - is_avr = ( - result.toolchain.get("architecture", "").upper().startswith("AVR") - or not elf_comps - ) + is_avr = result.toolchain.get("architecture", "").upper().startswith("AVR") if is_avr: progress(38, "AVR: Detecting embedded library components") try: diff --git a/backend/core/sbom/routes.py b/backend/core/sbom/routes.py index 3391648..cf451fe 100644 --- a/backend/core/sbom/routes.py +++ b/backend/core/sbom/routes.py @@ -201,13 +201,13 @@ async def generate_sbom(request: SBOMGenerateRequest, background_tasks: Backgrou try: from core.firmware_extraction.routes import extraction_jobs ex_job = extraction_jobs.get(request.extraction_job_id, {}) - target_dir = ex_job.get("output_dir") or f"/tmp/iifvdcas_extractions/{request.extraction_job_id}" + target_dir = ex_job.get("output_dir") or f"/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/extractions/{request.extraction_job_id}" except Exception: - target_dir = f"/tmp/iifvdcas_extractions/{request.extraction_job_id}" + target_dir = f"/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/extractions/{request.extraction_job_id}" if not target_dir: # Default: scan the firmware store - target_dir = "/tmp/iifvdcas_firmware_store" + target_dir = "/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/firmware_store" if not Path(target_dir).exists(): # Fallback: create an empty dir so generation can still run diff --git a/backend/core/static_analysis/analyzer.py b/backend/core/static_analysis/analyzer.py index 5b347ea..8bb4d77 100644 --- a/backend/core/static_analysis/analyzer.py +++ b/backend/core/static_analysis/analyzer.py @@ -941,6 +941,16 @@ def run_s108_embedded_string_analysis( if fp.suffix.lower() in FW_EXTENSIONS or fp.suffix == "": candidate_files.append(fp) + # Also explicitly add extracted_partitions and raw_binary directory files + from pathlib import Path as _Path + for subdir in ["extracted_partitions", "raw_binary"]: + scan_dir = _Path(target_dir) / subdir + if scan_dir.exists(): + for pf in scan_dir.iterdir(): + if pf.is_file() and pf.stat().st_size > 64 and pf not in candidate_files: + candidate_files.append(pf) + log_cb(f"[S108] Added {subdir} file: {pf.name} ({pf.stat().st_size // 1024}KB)") + log_cb(f"[S108] Scanning {len(candidate_files)} binary/firmware files for embedded strings") for fp in candidate_files: diff --git a/backend/core/vex_engine/analyzer.py b/backend/core/vex_engine/analyzer.py index 8cb7796..392e9e4 100644 --- a/backend/core/vex_engine/analyzer.py +++ b/backend/core/vex_engine/analyzer.py @@ -98,7 +98,7 @@ def _load_sbom_data(sbom_job_id: str) -> Dict: logger.debug(f"[VEX] memory SBOM load: {e}") # Disk fallback - base = Path(f"/tmp/iifvdcas_sbom/{sbom_job_id}") + base = Path(f"/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/sbom/{sbom_job_id}") result = {} for name, key in [ ("cyclonedx_sbom.json", "sbom"), @@ -287,9 +287,16 @@ def log(msg: str): device_info_dict = device_info or {} if not cve_findings: - result.status = "failed" - result.error = f"No CVE findings found for job {cve_job_id}. Run Phase 5 first." - emit(100, f"Failed: {result.error}") + result.status = "no_findings" + result.risk_label = "UNKNOWN" + result.error = ( + "Phase 5 found no CVE matches. This is not evidence that the " + "firmware has no vulnerabilities; identifiable component versions " + "are required for VEX triage." + ) + result.completed_at = datetime.now(timezone.utc).isoformat() + log(f"[VEX] {result.error}") + emit(100, "No CVE findings to triage - assessment is inconclusive") return result log(f"[VEX] {len(cve_findings)} CVE findings loaded") @@ -369,7 +376,7 @@ def log(msg: str): emit(92, "Generating CycloneDX VEX document…") # ── CycloneDX VEX document ───────────────────────────────────────────────── - output_dir = Path(f"/tmp/iifvdcas_vex/{job_id}") + output_dir = Path(f"/mnt/d/Uni Work/FYP 2 IOT/IIFVDCAS-main/IIFVDCAS-main/data/vex/{job_id}") output_dir.mkdir(parents=True, exist_ok=True) vex_path = str(output_dir / "vex_document.json") diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b56457..158dfe4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ python-multipart==0.0.9 aiofiles==23.2.1 pydantic==2.7.1 pyserial==3.5 +esptool>=4.7,<6 pyusb==1.2.1 requests==2.31.0 python-dotenv==1.0.1 diff --git a/frontend/package.json b/frontend/package.json index 2916aa7..894120e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "iifvdcas-frontend", "version": "0.1.0", "private": true, - "scripts": { + "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 4c78775..112061b 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,6 +1,6 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Outlet, NavLink } from 'react-router-dom' -import { Cpu, LayoutDashboard, HardDrive, Shield, Package, AlertTriangle, BarChart3, FileCheck, Wifi, Sparkles, Settings } from 'lucide-react' +import { Cpu, LayoutDashboard, HardDrive, Shield, Package, AlertTriangle, BarChart3, FileCheck, Wifi, Sparkles, Settings, Menu, X } from 'lucide-react' import { useWebSocket } from '../hooks/useWebSocket' import { useAI } from '../hooks/useAI' import AIChat from './AIChat' @@ -18,82 +18,174 @@ const nav = [ export default function Layout() { const { connected } = useWebSocket('ws://localhost:8000/ws') + const [completedPhases, setCompletedPhases] = useState(new Set()) + + useEffect(() => { + const checkPhases = async () => { + const done = new Set() + try { + const fw = await fetch('/api/v1/firmware/jobs').then(r => r.json()) + if (fw.jobs?.some(j => j.status === 'completed' || j.status === 'success')) done.add(2) + } catch (_) {} + try { + const an = await fetch('/api/v1/analysis/jobs').then(r => r.json()) + if (an.jobs?.some(j => j.status === 'success')) done.add(3) + } catch (_) {} + try { + const sb = await fetch('/api/v1/sbom/jobs/list').then(r => r.json()) + if (sb.jobs?.some(j => j.status === 'success')) done.add(4) + } catch (_) {} + try { + const cv = await fetch('/api/v1/cve/jobs/list').then(r => r.json()) + if (cv.jobs?.some(j => j.status === 'completed')) done.add(5) + } catch (_) {} + try { + const vx = await fetch('/api/v1/vex/jobs/list').then(r => r.json()) + const DONE = ['completed','success','done','complete'] + if (vx.jobs?.some(j => DONE.includes(j.status))) done.add(6) + } catch (_) {} + try { + const cp = await fetch('/api/v1/compliance/jobs/list').then(r => r.json()) + const DONE2 = ['completed','success','done','complete'] + if (cp.jobs?.some(j => DONE2.includes(j.status))) done.add(7) + } catch (_) {} + setCompletedPhases(done) + } + checkPhases() + const interval = setInterval(checkPhases, 5000) + return () => clearInterval(interval) + }, []) const { isConfigured } = useAI() const [chatOpen, setChatOpen] = useState(false) + const [collapsed, setCollapsed] = useState(false) return ( -
- - -
+ + {/* Main */} +
- {/* Floating AI Chat Side panel */} setChatOpen(false)} />
) diff --git a/frontend/src/index.css b/frontend/src/index.css index 3693f43..c1e95d5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,17 +1,36 @@ @tailwind base; @tailwind components; @tailwind utilities; -@layer base { * { box-sizing:border-box; } body { font-family:'Inter',system-ui,sans-serif; } } + +@layer base { + * { box-sizing: border-box; } + body { + font-family: 'Inter', system-ui, sans-serif; + background-color: #000000; + color: #ffffff; + } +} + @layer components { - .card { @apply bg-gray-900 border border-gray-800 rounded-xl p-5; } - .btn-primary { @apply bg-indigo-600 hover:bg-indigo-700 text-white font-medium px-4 py-2 rounded-lg transition-colors flex items-center gap-2; } - .btn-secondary { @apply bg-gray-800 hover:bg-gray-700 text-gray-200 font-medium px-4 py-2 rounded-lg transition-colors flex items-center gap-2 border border-gray-700; } + .card { + background-color: #191c24; + border: 1px solid #2c2e33; + border-radius: 0.5rem; + padding: 1.25rem; + } + .btn-primary { + @apply bg-green-600 hover:bg-green-700 text-white font-medium px-4 py-2 rounded-lg transition-colors flex items-center gap-2; + } + .btn-secondary { + background-color: #2c2e33; + @apply hover:bg-gray-700 text-gray-200 font-medium px-4 py-2 rounded-lg transition-colors flex items-center gap-2 border border-gray-700; + } .badge { @apply text-xs font-medium px-2 py-0.5 rounded-full; } - .badge-green { @apply bg-green-950 text-green-400 border border-green-800; } - .badge-red { @apply bg-red-950 text-red-400 border border-red-800; } - .badge-amber { @apply bg-amber-950 text-amber-400 border border-amber-800; } - .badge-blue { @apply bg-blue-950 text-blue-400 border border-blue-800; } - .badge-purple { @apply bg-purple-950 text-purple-400 border border-purple-800; } - .badge-gray { @apply bg-gray-800 text-gray-400 border border-gray-700; } - .mono { font-family:'JetBrains Mono',monospace; } + .badge-green { background-color: #00c154; @apply text-white; } + .badge-red { background-color: #e83d44; @apply text-white; } + .badge-amber { background-color: #eb9d00; @apply text-white; } + .badge-blue { background-color: #0084d5; @apply text-white; } + .badge-purple { background-color: #8457d5; @apply text-white; } + .badge-gray { background-color: #2c2e33; @apply text-gray-400; } + .mono { font-family: 'JetBrains Mono', monospace; } } diff --git a/frontend/src/pages/AISettings.jsx b/frontend/src/pages/AISettings.jsx index 95375af..14296e3 100644 --- a/frontend/src/pages/AISettings.jsx +++ b/frontend/src/pages/AISettings.jsx @@ -8,34 +8,25 @@ export default function AISettings() { const [apiKey, setApiKey] = useState('') const [model, setModel] = useState('meta/llama-3.1-70b-instruct') const [baseUrl, setBaseUrl] = useState('https://integrate.api.nvidia.com/v1') - + const [showKey, setShowKey] = useState(false) const [testing, setTesting] = useState(false) - const [testResult, setTestResult] = useState(null) // { success: boolean, msg: string } + const [testResult, setTestResult] = useState(null) const [saveSuccess, setSaveSuccess] = useState(false) - - // Loaded providers and default configs from backend const [availableProviders, setAvailableProviders] = useState([]) useEffect(() => { - // Load existing settings const cfg = loadAIConfig() if (cfg.provider) setProvider(cfg.provider) if (cfg.apiKey) setApiKey(cfg.apiKey) if (cfg.model) setModel(cfg.model) if (cfg.baseUrl) setBaseUrl(cfg.baseUrl) - // Load available providers info from backend aiApi.listProviders() - .then(resp => { - setAvailableProviders(resp.data.providers || []) - }) - .catch(err => { - console.error('Failed to load providers list from backend:', err) - }) + .then(resp => setAvailableProviders(resp.data.providers || [])) + .catch(err => console.error('Failed to load providers list from backend:', err)) }, []) - // Handle provider change to fill defaults const handleProviderChange = (newProvider) => { setProvider(newProvider) const info = availableProviders.find(p => p.id === newProvider) @@ -43,7 +34,6 @@ export default function AISettings() { setModel(info.model || '') setBaseUrl(info.base_url || '') } else { - // Fallbacks if (newProvider === 'nvidia_nim') { setModel('meta/llama-3.1-70b-instruct') setBaseUrl('https://integrate.api.nvidia.com/v1') @@ -73,11 +63,7 @@ export default function AISettings() { const handleTest = async () => { setTesting(true) setTestResult(null) - - // Temporarily save config to localstorage before testing since the test API pulls it from there - const originalConfig = loadAIConfig() saveAIConfig({ provider, apiKey, model, baseUrl }) - try { const resp = await aiApi.test() setTestResult({ @@ -85,12 +71,10 @@ export default function AISettings() { msg: `Connection successful! Provider replied: "${resp.data?.reply || 'OK'}" using model: "${resp.data?.model || model}"` }) } catch (err) { - const errorMsg = err.response?.data?.detail || err.message || 'Verification failed' setTestResult({ success: false, - msg: errorMsg + msg: err.response?.data?.detail || err.message || 'Verification failed' }) - // Restore original config if connection failed (user's choice if they want to undo) } finally { setTesting(false) } @@ -100,34 +84,34 @@ export default function AISettings() { const needsKey = activeProviderInfo ? activeProviderInfo.needs_key : (provider !== 'ollama') const needsBaseUrl = activeProviderInfo ? activeProviderInfo.needs_base_url : (provider === 'nvidia_nim' || provider === 'ollama') + const inputClass = "w-full bg-gray-950 border border-gray-800 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-indigo-500 transition-colors" + return ( -
+
-
- +
+
-
-

AI Assistant Settings

-

Configure your preferred Large Language Model provider. Settings are stored locally in your browser.

+
+

AI Assistant Settings

+

Configure your preferred LLM provider. Settings are stored locally in your browser.

-
- {/* Settings Form */} -
+
+
- +

Provider Configuration

- {/* Provider Dropdown */}
- {/* Base URL (Conditional) */} {needsBaseUrl && (
- + setBaseUrl(e.target.value)} placeholder="https://..." - className="input pl-9 w-full text-sm bg-gray-950 border-gray-800 rounded-lg text-white" + className={`${inputClass} pl-9`} />
-

- {provider === 'nvidia_nim' - ? 'Default NIM API endpoint: https://integrate.api.nvidia.com/v1' - : 'Default Ollama endpoint: http://localhost:11434/v1'} +

+ {provider === 'nvidia_nim' + ? 'Default NIM endpoint (truncated in display)' + : 'Default: http://localhost:11434/v1'}

)} - {/* Model Name */}
setModel(e.target.value)} placeholder="e.g. meta/llama-3.1-70b-instruct" - className="input w-full text-sm bg-gray-950 border-gray-800 rounded-lg text-white" + className={inputClass} /> -

+

Ensure this model exists and is available on your selected provider account.

- {/* API Key (Conditional) */} {needsKey && (
- + setApiKey(e.target.value)} placeholder="Enter your API provider secret key" - className="input pl-9 pr-10 w-full text-sm bg-gray-950 border-gray-800 rounded-lg text-white" + className={`${inputClass} pl-9 pr-10`} />
-

+

Your key is never sent to the IIFVDCAS database. It is stored inside your browser's local storage.

)} - {/* Actions */} -
+
{saveSuccess && ( -
- +
+ AI Configuration saved successfully!
)} {testResult && (
- {testResult.success ? : } -
{testResult.msg}
+ {testResult.success + ? + : } +
{testResult.msg}
)}
- {/* Side Panel: Information */} -
+

How this works

+

The IIFVDCAS AI assistant layers security expertise directly on top of your scan results.

- The IIFVDCAS AI assistant layers security expertise directly on top of your scan results. -

-

- Security context: Whenever you ask the assistant to explain a CVE, justify a VEX state, or audit a compliance failure, the platform automatically feeds relevant technical context (such as the target architecture, bare-metal state, CBOM libraries, and scan findings) directly into the LLM system prompt. -

-

- This turns a generic AI model into a dedicated security consultant who understands your specific IoT firmware's exact details. + Security context: Whenever you ask the assistant to explain a CVE, justify a VEX state, or audit a compliance failure, the platform automatically feeds relevant technical context directly into the LLM system prompt.

-
- Recommended Models: -
    -
  • NVIDIA NIM: meta/llama-3.1-70b-instruct
  • -
  • OpenAI: gpt-4o-mini / gpt-4o
  • -
  • Anthropic: claude-3-5-haiku-20241022
  • -
  • Gemini: gemini-2.0-flash
  • -
  • Ollama: llama3 / mistral
  • +

    This turns a generic AI model into a dedicated security consultant who understands your specific IoT firmware's exact details.

    +
    + Recommended Models: +
      +
    • NVIDIA NIM: meta/llama-3.1-70b-instruct
    • +
    • OpenAI: gpt-4o-mini / gpt-4o
    • +
    • Anthropic: claude-3-5-haiku-20241022
    • +
    • Gemini: gemini-2.0-flash
    • +
    • Ollama: llama3 / mistral
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 2d68c5f..01dd4da 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -12,6 +12,7 @@ const phases = [ { num:6, label:'VEX Risk Analysis', path:'/vex', icon:BarChart3, status:'planned', emba:'KEV · VEX engine' }, { num:7, label:'Compliance', path:'/compliance', icon:FileCheck, status:'planned', emba:'ETSI EN 303 645 · EU CRA' }, ] + const toolList = [ {key:'udevadm',label:'udevadm',phase:1},{key:'nmap',label:'nmap',phase:1},{key:'avrdude',label:'avrdude',phase:1}, {key:'binwalk',label:'binwalk',phase:2},{key:'unblob',label:'unblob',phase:2}, @@ -24,77 +25,118 @@ export default function Dashboard() { const navigate = useNavigate() useEffect(() => { systemApi.info().then(r => setTools(r.data.tools)).catch(()=>{}) }, []) const ready = toolList.filter(t => tools[t.key]).length + + const stats = [ + { label:'Total Phases', value:7, icon:Activity, accent:'#0084d5' }, + { label:'Active Phase', value:'1 of 7', icon:Zap, accent:'#00c154' }, + { label:'Tools Ready', value:`${ready}/${toolList.length}`, icon:CheckCircle2, accent: ready === toolList.length ? '#00c154' : '#eb9d00' }, + { label:'Platform', value:'Web → WSL → Linux', icon:Shield, accent:'#8457d5' }, + ] + return (
+ {/* Header */}

IIFVDCAS Dashboard

-

Intelligent IoT Firmware Vulnerability Detection & Compliance Assessment System

-

Powered by EMBA v2.0.1 · UMT Lahore

+

Intelligent IoT Firmware Vulnerability Detection & Compliance Assessment System

+

Powered by EMBA v2.0.1 · UMT Lahore

+ + {/* Stats */}
- {[ - {label:'Total Phases',value:7,color:'text-white',icon:Activity}, - {label:'Active Phase',value:'1 of 7',color:'text-green-400',icon:Zap}, - {label:'Tools Ready',value:`${ready}/${toolList.length}`,color:ready===toolList.length?'text-green-400':'text-amber-400',icon:CheckCircle2}, - {label:'Platform',value:'Web → Win → Linux',color:'text-indigo-400',icon:Shield}, - ].map(s => ( + {stats.map(s => (
-
{s.label}
-
{s.value}
+
+
+ +
+ {s.label} +
+
{s.value}
))}
+ + {/* Main Grid */}
+ {/* Pipeline */}
-
Analysis Pipeline
+
+ Analysis Pipeline +
{phases.map(p => ( -
navigate(p.path)} className="card flex items-center gap-4 cursor-pointer hover:border-gray-700 transition-colors"> -
- +
navigate(p.path)} + className="card flex items-center gap-4 cursor-pointer transition-all hover:border-gray-600" + style={{borderColor:'#2c2e33'}} + > +
+
Phase {p.num}: {p.label} - {p.status==='active'?'Active':'Planned'} + + {p.status === 'active' ? 'Active' : 'Planned'} +
-
{p.emba}
+
{p.emba}
- +
))}
+ + {/* Tools */}
-
Tool Availability
+
+ Tool Availability +
-
Tools ready{ready}/{toolList.length}
-
-
+
+ Tools ready{ready}/{toolList.length} +
+
+
{toolList.map(t => (
- {tools[t.key] ? : } - {t.label} - P{t.phase} + {tools[t.key] + ? + : } + {t.label} + P{t.phase}
))}
{ready < toolList.length && ( -
-

Install missing tools:

- ./scripts/install.sh +
+

Install missing tools:

+ ./scripts/install.sh
)}
+
-
EMBA Engine
-
- {[['Version','2.0.1'],['Location','~/emba'],['Modules','93 total'],['P-modules','20 (extraction)'],['S-modules','51 (static)'],['L-modules','9 (dynamic)'],['F-modules','7 (reporting)']].map(([k,v]) => ( -
{k}{v}
+
EMBA Engine
+
+ {[['Version','2.0.1'],['Location','~/emba'],['Modules','93 total'],['P-modules','20 (extraction)'],['S-modules','51 (static)'],['F-modules','7 (reporting)']].map(([k,v]) => ( +
+ {k} + {v} +
))}
diff --git a/frontend/src/pages/DeviceDetection.jsx b/frontend/src/pages/DeviceDetection.jsx index 980eb26..24dba9a 100644 --- a/frontend/src/pages/DeviceDetection.jsx +++ b/frontend/src/pages/DeviceDetection.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' +import { useNavigate } from 'react-router-dom' import { Cpu, Scan, Usb, RefreshCw, ChevronRight, Terminal, CheckCircle2, AlertCircle, Network, Info, Wifi, @@ -136,7 +137,7 @@ function DeviceDetail({ device, onClose, onExtract }) { Extract Firmware - + {activeTab === 'upload' && ( + + )} + {activeTab === 'device' && ( + <> + + + + )}
)} @@ -693,7 +729,17 @@ export default function FirmwareExtraction() {
Extracted File Tree - + + {extractionResult?.total_files?.toLocaleString()} files
diff --git a/frontend/src/pages/StaticAnalysis.jsx b/frontend/src/pages/StaticAnalysis.jsx index 378dfdb..9362d1b 100644 --- a/frontend/src/pages/StaticAnalysis.jsx +++ b/frontend/src/pages/StaticAnalysis.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' +import { useLocation } from 'react-router-dom' import { Shield, Play, RefreshCw, CheckCircle2, AlertCircle, AlertTriangle, Lock, Key, FileText, Terminal, ChevronDown, ChevronRight, @@ -285,12 +286,24 @@ export default function StaticAnalysis() { const [jobId, setJobId] = useState(null) const [error, setError] = useState('') const [extractionJobs, setExtractionJobs] = useState([]) + + const { subscribe } = useWebSocket('ws://localhost:8000/ws') - // Fetch extraction jobs for the dropdown + // Fetch extraction jobs and auto-select saved job useEffect(() => { api.get('/firmware/jobs') - .then(r => setExtractionJobs(r.data.jobs || [])) + .then(r => { + const jobs = r.data.jobs || [] + setExtractionJobs(jobs) + const savedJobId = sessionStorage.getItem('fw_extraction_job_id') + if (savedJobId && jobs.find(j => j.job_id === savedJobId)) { + setExtractionJobId(savedJobId) + } else if (savedJobId) { + // job not in list yet, set anyway + setExtractionJobId(savedJobId) + } + }) .catch(() => {}) }, []) @@ -333,7 +346,20 @@ export default function StaticAnalysis() { try { const r = await api.post('/analysis/start', body) - setJobId(r.data.job_id) + const analysisJobId = r.data.job_id + setJobId(analysisJobId) + const poll = setInterval(async () => { + try { + const res = await api.get('/analysis/' + analysisJobId) + if (res.data.status === 'success' || res.data.status === 'failed') { + clearInterval(poll) + setRunning(false) + setProgress(100) + setResult(res.data) + setLogs(prev => [...prev, { ts: new Date().toISOString(), msg: '[DONE] ' + res.data.status + ' — Risk: ' + res.data.risk_score + '/100 (' + res.data.risk_label + ')' }]) + } + } catch (_) {} + }, 1000) } catch (err) { setRunning(false) setError(err.response?.data?.detail || err.message) diff --git a/frontend/src/pages/VEXPage.jsx b/frontend/src/pages/VEXPage.jsx index 0ecdb49..8de5607 100644 --- a/frontend/src/pages/VEXPage.jsx +++ b/frontend/src/pages/VEXPage.jsx @@ -521,7 +521,7 @@ export default function VEXPage() { const r = await vexApi.getStatus(vexJobId) const { status, progress: pct } = r.data setProgress(p => Math.max(p, pct ?? 0)) - if (status === 'success' || status === 'failed') { + if (status === 'success' || status === 'failed' || status === 'no_findings') { clearInterval(pollRef.current) setRunning(false) if (status === 'failed') setError(r.data.error || 'VEX analysis failed') @@ -622,9 +622,11 @@ export default function VEXPage() {
{isDone && (
- + {result.status === 'success' && ( + + )} @@ -719,6 +721,11 @@ export default function VEXPage() { {/* Results */} {isDone && ( <> + {result.status === 'no_findings' && ( +
+ No CVEs were available for VEX triage. This result is inconclusive, not a confirmation that the firmware is vulnerability-free. Generate an ESP-specific SBOM with identifiable component versions, then run Phase 5 again. +
+ )} {/* Risk gauge + stat cards */}
diff --git a/scripts/dev.sh b/scripts/dev.sh old mode 100755 new mode 100644 diff --git a/scripts/install.sh b/scripts/install.sh old mode 100755 new mode 100644 From 9b77406aed0d926d56f0c6c752d3da6b7f4d2342 Mon Sep 17 00:00:00 2001 From: sudoNaji Date: Mon, 20 Jul 2026 17:21:33 +0500 Subject: [PATCH 2/2] Add project supervisor and collaborators to README --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index cee5865..fde8b28 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,19 @@ **A full-stack firmware security assessment platform for IoT devices.** Automated 7-phase pipeline: device detection → firmware extraction → static analysis → SBOM/HBOM/CBOM → CVE mapping → VEX risk triage → compliance report (PDF). +

+ Supervisor
+ Dr. Tahir Mushtaq +

+ +

+ Collaborators
+ Imran Hashmi  •  + Muhammad Moeed  •  + Muhammad Hassan  •  + Zain-Ul-Abedin +

+ [Features](#-features) · [Architecture](#-architecture) · [Quick Start](#-quick-start) · [Pipeline](#-7-phase-pipeline) · [Hardware](#-hardware-support) · [Status](#-project-status)