diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 96ae5b1..1609b60 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,6 +21,8 @@ updates: - "dependencies" - "lang:python" - "scope:backend" + assignees: + - "overwrite00" commit-message: prefix: "chore(deps-backend)" include: "scope" @@ -60,6 +62,8 @@ updates: - "dependencies" - "lang:javascript" - "scope:frontend" + assignees: + - "overwrite00" commit-message: prefix: "chore(deps-frontend)" include: "scope" @@ -113,6 +117,8 @@ updates: - "dependencies" - "type:ci" - "scope:deps" + assignees: + - "overwrite00" commit-message: prefix: "chore(ci)" include: "scope" diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index 805b465..965b1e3 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -41,6 +41,10 @@ jobs: PR_NODE_ID: ${{ github.event.pull_request.node_id }} PR_TITLE: ${{ github.event.pull_request.title }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} + # The step-level GH_TOKEN above is the Projects PAT, which cannot edit issues or + # PRs. Assignee, reviewer and label edits use the workflow's own token instead + # (the permissions block grants it issues/pull-requests write). + REPO_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -63,13 +67,26 @@ jobs: echo " Added to Project" } + # Repository edit with the workflow token. A failure is reported as a warning + # with gh's own error message; it never fails the job, but it is never hidden. + repo_edit() { + local description="$1" + shift + local output + if output=$(GH_TOKEN="$REPO_TOKEN" gh "$@" 2>&1); then + echo " $description" + else + echo "::warning::$description failed: $output" + fi + } + detect_files_changed() { local PR_NUM="$1" if [ -z "$PR_NUM" ]; then return fi - gh pr diff "$PR_NUM" --name-only 2>/dev/null | grep -E '.*' || true + GH_TOKEN="$REPO_TOKEN" gh pr diff "$PR_NUM" --name-only || true } detect_scope_and_lang() { @@ -142,7 +159,7 @@ jobs: # Assign echo " - Assigning to $ASSIGNEE..." - gh issue edit "$ISSUE_NUM" --add-assignee "$ASSIGNEE" 2>/dev/null && echo " Assigned" || echo " Assignment skipped" + repo_edit "Assigned" issue edit "$ISSUE_NUM" --add-assignee "$ASSIGNEE" # Add to Project echo " - Adding to Project..." @@ -154,7 +171,7 @@ jobs: if [ -n "$LABELS" ]; then while IFS= read -r label; do if [ -n "$label" ]; then - gh issue edit "$ISSUE_NUM" --add-label "$label" 2>/dev/null && echo " Added label: $label" + repo_edit "Added label: $label" issue edit "$ISSUE_NUM" --add-label "$label" fi done <<< "$LABELS" fi @@ -164,35 +181,44 @@ jobs: echo "Processing PR #$PR_NUM ($PR_ACTION) by @$PR_AUTHOR..." + # GitHub gives Dependabot-triggered workflows a read-only GITHUB_TOKEN, so + # repository edits cannot work for its PRs. Its assignee and labels are set + # natively by Dependabot from .github/dependabot.yml instead. + IS_DEPENDABOT=0 + if [ "$PR_AUTHOR" = "dependabot[bot]" ] || [ "$PR_AUTHOR" = "app/dependabot" ]; then + IS_DEPENDABOT=1 + fi + # Assign echo " - Assigning to $ASSIGNEE..." - gh pr edit "$PR_NUM" --add-assignee "$ASSIGNEE" 2>/dev/null && echo " Assigned" || echo " Assignment skipped" + if [ "$IS_DEPENDABOT" = "1" ]; then + echo " Skipped: Dependabot sets the assignee from dependabot.yml" + else + repo_edit "Assigned" pr edit "$PR_NUM" --add-assignee "$ASSIGNEE" + fi # Add to Project and request reviewer (only on open/reopen) if [ "$PR_ACTION" = "opened" ] || [ "$PR_ACTION" = "reopened" ]; then echo " - Adding to Project..." add_to_project "$PR_NODE_ID" - # Request reviewer + # Request reviewer (GitHub rejects a review request for the PR's own author) echo " - Requesting reviewer..." - gh pr edit "$PR_NUM" --add-reviewer "$ASSIGNEE" 2>/dev/null && echo " Reviewer requested" || echo " Reviewer request skipped" + if [ "$IS_DEPENDABOT" = "1" ]; then + echo " Skipped: the read-only Dependabot token cannot request reviewers" + elif [ "$PR_AUTHOR" = "$ASSIGNEE" ]; then + echo " Reviewer request skipped: @$ASSIGNEE is the PR author" + else + repo_edit "Reviewer requested" pr edit "$PR_NUM" --add-reviewer "$ASSIGNEE" + fi fi # Label on open/reopen if [ "$PR_ACTION" = "opened" ] || [ "$PR_ACTION" = "reopened" ]; then echo " - Adding labels..." - # Special handling for Dependabot - if [ "$PR_AUTHOR" = "dependabot[bot]" ] || [ "$PR_AUTHOR" = "app/dependabot" ]; then - LABELS="dependencies" - # Detect scope/lang from title for Dependabot - if echo "$PR_TITLE" | grep -q "frontend"; then - LABELS=$(printf "%s\nscope:frontend\nlang:javascript" "$LABELS") - elif echo "$PR_TITLE" | grep -q "backend"; then - LABELS=$(printf "%s\nscope:backend\nlang:python" "$LABELS") - elif echo "$PR_TITLE" | grep -q "actions"; then - LABELS=$(printf "%s\ntype:ci\nscope:deps" "$LABELS") - fi + if [ "$IS_DEPENDABOT" = "1" ]; then + echo " Skipped: Dependabot applies the labels from dependabot.yml" else # Detect files changed FILES=$(detect_files_changed "$PR_NUM") @@ -202,14 +228,14 @@ jobs: if [ -n "$FILES" ]; then LABELS=$(printf "%s\n%s" "$LABELS" "$(detect_scope_and_lang "$FILES" 0)") fi - fi - # Add each label - while IFS= read -r label; do - if [ -n "$label" ]; then - gh pr edit "$PR_NUM" --add-label "$label" 2>/dev/null && echo " Added label: $label" || true - fi - done <<< "$LABELS" + # Add each label + while IFS= read -r label; do + if [ -n "$label" ]; then + repo_edit "Added label: $label" pr edit "$PR_NUM" --add-label "$label" + fi + done <<< "$LABELS" + fi fi # Handle closed PRs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 772a671..2e2defd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,6 +45,10 @@ jobs: run: | python -m pip install --upgrade pip pip install -r backend/requirements.txt + # RTFDE (LGPL-3.0) is an optional runtime dependency, deliberately not in + # requirements.txt. Install it for the test job only, so the end-to-end test + # of the RTF-only .msg path runs against the real library instead of skipping. + pip install RTFDE==0.1.2.2 - name: Run pytest with coverage run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e22914..c31b4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,81 @@ Features are ordered by implementation priority. --- +## [0.17.2] — 2026-10-02 + +### Fixed +- **`.msg` files lost their recipients and every transport-header field**: three + independent bugs in `msg_backends.py` / `email_parser._parse_msg`, all hidden by + bare `except:` clauses, so they failed silently: + - recipients were read from `recip.email`, which does not exist in python-oxmsg + (the attribute is `email_address`): `mail_to`/`mail_cc` were always empty; + - the raw transport headers were read with `msg.properties.get(...)`, a method + `Properties` does not have, so they were always empty: SPF/DKIM/DMARC, + `Message-ID` and the `Received` chain were never extracted from a `.msg`, and + header scoring ran with no authentication data at all (the "transport headers + bonus" announced in 0.16.0 never worked); + - even when headers were available, only 6 of the 18 header-derived fields were + copied into `ParsedEmail`: `Reply-To`, `Return-Path`, `X-Mailer`, + `X-Originating-IP`, `X-Campaign-ID`, `List-Unsubscribe`, the raw + authentication/DKIM/SPF/ARC headers were dropped. + All of them are now read correctly, the bare `except:` clauses are gone (failures + are logged and reported in `parse_errors`), and a test fails if a new `ParsedEmail` + field is added without being classified for `.msg` parsing. + Analyses of `.msg` files made before this release keep their old, incomplete + results: use "Ri-analizza" to recompute them. +- **RTF-only `.msg` bodies (Outlook 97-2003) could never be recovered**: the code + imported a class (`from RTFDE import RTFDE`) that does not exist, and reported the + resulting `ImportError` as "RTFDE not installed" even when it was. The compressed + RTF (`PidTagRtfCompressed`) is now decompressed (MS-OXRTFCP, "LZFu" and "MELA") and + de-encapsulated through the real RTFDE API. RTFDE remains an optional dependency. + The RTF fallback no longer fires for `.msg` files that already have an HTML body. +- **SQLAlchemy 2.1 broke the async engine**: 2.1 no longer installs `greenlet`, which + `sqlalchemy.ext.asyncio` needs at import time. `requirements.txt` now pins + `sqlalchemy[asyncio]==2.1.1`. + +### Security +- Frontend development dependency `brace-expansion` 5.0.9 → 5.0.12, resolving three + high-severity denial-of-service advisories (GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, + GHSA-6j4f-fj2g-mc7p). It is pulled in by ESLint and is not part of the production bundle. + +### Dependencies +- Backend: uvicorn 0.52.4 → 0.54.0, SQLAlchemy 2.0.52 → 2.1.1 (now with the `asyncio` + extra), urllib3 2.7.0 → 2.8.0, coverage 7.16.0 → 7.16.2 +- Frontend runtime: lucide-react 1.44.0 → 1.48.0, react-router-dom 7.18.3 → 7.18.4 +- Frontend development: ESLint 10.10.0 → 10.11.0, Vite 8.3.0 → 8.3.1, + eslint-plugin-react-refresh 0.5.6 → 0.5.7 + +### CI +- The test job installs RTFDE (LGPL-3.0, pinned) so the end-to-end RTF-only `.msg` + test runs against the real library instead of being skipped. RTFDE stays out of + `requirements.txt`: it is optional and is not distributed with EMLyzer. +- Dependabot pull requests are now assigned natively (`assignees` in `dependabot.yml`): + they had never been assigned, because GitHub gives Dependabot-triggered workflows a + read-only token. The automation skips the edits that token cannot make, with an + explicit message, instead of failing. +- Dependabot now checks pip and npm monthly instead of weekly. +- Project automation: assignee, reviewer and label assignment had been failing silently + on every pull request (the repository-scoped edits were made with the Projects-only + token and their errors were discarded). Repository edits now use the workflow's own + token, only the Projects mutation uses `GH_PROJECT_TOKEN`, and failures are reported + instead of hidden. + +### Tests +- Added a synthetic, deterministic Outlook `.msg` fixture + (`backend/tests/fixtures/sample.msg`, reserved example domains only) and its + generator. The previous `.msg` end-to-end test looked for `samples/sample.msg`, a + git-ignored directory, so it was skipped everywhere including CI. A test now checks + that the committed binary matches the generator output. +- New tests: full `.msg` field contract, header-analysis results on a `.msg`, an + LZFu reference vector produced by an independent implementation, invalid RTF + streams, and RTF-only `.msg` files with and without RTFDE (the HTML, empty and + failure branches use a stub, so they run everywhere). The placeholder + `test_msg_rtf_only_warning`, which asserted nothing, was removed. +- 199 tests pass in CI. The end-to-end RTFDE test is skipped on a machine without the + optional package (198 pass, 1 skipped). + +--- + ## [0.17.1] — 2026-09-15 ### Fixed diff --git a/README.md b/README.md index 887a0af..2886dc8 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ Email (.eml / .msg / plain text) ## 🔧 Version +**v0.17.2** — 🛠️ Bugfix release for Outlook `.msg` files: recipients, SPF/DKIM/DMARC results, `Message-ID`, the `Received` chain and the other transport-header fields were silently dropped, so header scoring ran without authentication data (re-analyze older `.msg` analyses to recompute them); RTF-only `.msg` bodies (Outlook 97-2003) are now recoverable with the optional RTFDE package. Also: SQLAlchemy 2.1 with the `asyncio` extra, a high-severity dev-dependency advisory (`brace-expansion`) resolved, other dependency updates, and a repaired project-automation workflow. All 199 tests passing ✅ in CI (the one end-to-end test that needs the optional RTFDE package is skipped where it is not installed), zero regressions. + **v0.17.1** — 🐛 Follow-up to the Threat Intelligence release: fixed CERT-AGID bulletins not loading on first visit, added example placeholders and a collapsible mini-guide to the known-campaign form, and added a "Ri-analizza" (Re-analyze) button so an already-analyzed email can pick up a known campaign created or edited afterward. Documented the whole Threat Intelligence panel in `docs/USAGE.md`. **v0.17.0** — 🛰️ Threat Intelligence release: automatic refresh of local IOC feeds (OpenPhish, Spamhaus, URLhaus) with a shared caching/scheduling infrastructure, a curated "Known Campaigns" system with manual CRUD, corpus backtesting, CERT-AGID bulletin board, and internal auto-learning proposals — all backed by a new "Threat Intelligence" UI panel. Fixed several correctness bugs along the way (word-boundary campaign matching, dead `risk_contribution` weight, a spurious mega-cluster in internal clustering, JSON-less rate-limit responses) and hardened write endpoints with a local admin token; the app now binds to `127.0.0.1` by default. All 189 tests passing ✅ (1 skipped), zero regressions. diff --git a/backend/core/analysis/email_parser.py b/backend/core/analysis/email_parser.py index 1f17a4d..37e6ec7 100644 --- a/backend/core/analysis/email_parser.py +++ b/backend/core/analysis/email_parser.py @@ -73,6 +73,19 @@ class ParsedEmail: parse_errors: list[str] = field(default_factory=list) +# ParsedEmail fields that _parse_msg takes from a .msg's transport headers. +# test_core.TestMsgBackend checks this list against ParsedEmail so a new header +# field cannot be silently left out of .msg parsing again. +_MSG_HEADER_DERIVED_FIELDS = ( + "message_id", "return_path", "reply_to", "x_mailer", "x_originating_ip", + "x_campaign_id", "list_unsubscribe", "received_chain", + "spf_result", "dkim_result", "dmarc_result", + "dkim_signatures_raw", "received_spf_raw", "auth_results_raw", + "arc_seal_raw", "arc_message_signature_raw", "arc_authentication_results_raw", + "raw_headers", +) + + def _compute_hashes(data: bytes) -> tuple[str, str, str]: """ Calcola MD5, SHA-1 e SHA-256 di un file binario. @@ -371,17 +384,19 @@ def _parse_msg(raw: bytes, filename: str) -> ParsedEmail: parsed.body_text = f.body_text parsed.body_html = f.body_html - # Bonus: if transport headers available, reuse EML parser for auth headers + # The RFC 822 transport headers of a .msg are parsed exactly like an .eml header block. + # MAPI properties stay authoritative for From/Subject/Date and the recipient table; + # every other header-derived field comes from the transport headers. if f.transport_headers.strip(): try: eml_view = _parse_eml(f.transport_headers.encode("utf-8", "replace"), filename) - parsed.spf_result = eml_view.spf_result - parsed.dkim_result = eml_view.dkim_result - parsed.dmarc_result = eml_view.dmarc_result - parsed.received_chain = eml_view.received_chain - parsed.raw_headers = eml_view.raw_headers - if eml_view.message_id: - parsed.message_id = eml_view.message_id + parsed.parse_errors.extend(eml_view.parse_errors) + for name in _MSG_HEADER_DERIVED_FIELDS: + setattr(parsed, name, getattr(eml_view, name)) + if not parsed.mail_to: + parsed.mail_to = eml_view.mail_to + if not parsed.mail_cc: + parsed.mail_cc = eml_view.mail_cc except Exception as e: parsed.parse_errors.append(f"Transport header parsing: {e}") diff --git a/backend/core/analysis/msg_backends.py b/backend/core/analysis/msg_backends.py index f2e6e29..55f5575 100644 --- a/backend/core/analysis/msg_backends.py +++ b/backend/core/analysis/msg_backends.py @@ -8,10 +8,32 @@ This abstraction decouples EMLyzer from any specific .msg library. """ +import io +import logging +import struct from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional +logger = logging.getLogger(__name__) + +# MAPI property ids (MS-OXPROPS) +PID_TRANSPORT_MESSAGE_HEADERS = 0x007D +PID_RECIPIENT_TYPE = 0x0C15 +PID_RTF_COMPRESSED = 0x1009 + +# PidTagRecipientType values +RECIPIENT_TO, RECIPIENT_CC, RECIPIENT_BCC = 1, 2, 3 + +# MS-OXRTFCP: compressed-RTF header magic values and the LZFu initial dictionary +_RTF_MAGIC_COMPRESSED = b"LZFu" +_RTF_MAGIC_UNCOMPRESSED = b"MELA" +_RTF_INIT_DICT = ( + b"{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss " + b"\\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier" + b"{\\colortbl\\red0\\green0\\blue0\r\n\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx" +) + @dataclass class MsgFields: @@ -57,31 +79,27 @@ def available(self) -> bool: return False def parse(self, raw: bytes) -> MsgFields: - import io - import re from oxmsg import Message out = MsgFields() try: msg = Message.load(io.BytesIO(raw)) - # Core fields (1:1 mapping with extract-msg) out.mail_from = msg.sender or "" out.subject = msg.subject or "" out.date = str(msg.sent_date or "") out.body_text = msg.body or "" out.body_html = msg.html_body or "" - # Transport headers (raw RFC822, if available) + # Raw RFC 822 transport headers (source of SPF/DKIM/DMARC, Message-ID, Received...) try: - out.transport_headers = msg.properties.get("transport_message_headers", "") or "" - except: - pass + out.transport_headers = msg.properties.str_prop_value(PID_TRANSPORT_MESSAGE_HEADERS) or "" + except Exception as e: + logger.warning("Cannot read .msg transport headers: %s", e) + out.errors.append(f"transport headers unreadable: {e}") - # Split To/Cc via MAPI property 0x0C15 (recipient type: 1=To, 2=Cc, 3=Bcc) - out.mail_to, out.mail_cc = _extract_recipients(msg) + out.mail_to, out.mail_cc = _extract_recipients(msg, out.errors) - # Attachments (with mime_type bonus vs extract-msg) try: for att in msg.attachments or []: out.attachments.append({ @@ -90,12 +108,13 @@ def parse(self, raw: bytes) -> MsgFields: "declared_mime": att.mime_type or "application/octet-stream", "size_bytes": len(att.file_bytes or b""), }) - except: - pass + except Exception as e: + logger.warning("Cannot read .msg attachments: %s", e) + out.errors.append(f"attachments unreadable: {e}") - # RTF-only fallback (legacy Outlook <2010) - if not out.body_text.strip(): - _handle_rtf_only(msg, out, re) + # RTF-only fallback (legacy Outlook <2010): no plain-text or HTML body at all + if not out.body_text.strip() and not out.body_html.strip(): + _handle_rtf_only(msg, out) except Exception as e: out.errors.append(f"oxmsg parse error: {e}") @@ -103,55 +122,130 @@ def parse(self, raw: bytes) -> MsgFields: return out -def _extract_recipients(msg): - """Extract To and Cc recipients from .msg via MAPI property 0x0C15.""" - to_list, cc_list = [], [] +def _extract_recipients(msg, errors: list[str]) -> tuple[list[str], list[str]]: + """Split recipients into To / Cc using PidTagRecipientType (1=To, 2=Cc, 3=Bcc). + + A recipient without a type is treated as To. Bcc recipients are skipped: they + are never visible to the other recipients and ParsedEmail has no field for them. + """ + to_list: list[str] = [] + cc_list: list[str] = [] try: - for recip in msg.recipients or []: - try: - # 0x0C15 = recipient type (1=To, 2=Cc, 3=Bcc) - recip_type = recip.properties.int_prop_value(0x0C15) - email = recip.email or "" - if email: - if recip_type == 1: # To - to_list.append(email) - elif recip_type == 2: # Cc - cc_list.append(email) - except: - # If property reading fails, try generic email extraction - email = recip.email or "" - if email: - to_list.append(email) - except: - pass + recipients = msg.recipients or () + except Exception as e: + logger.warning("Cannot read .msg recipient table: %s", e) + errors.append(f"recipients unreadable: {e}") + return to_list, cc_list + + for recip in recipients: + try: + address = recip.email_address + if not address: + continue + kind = recip.properties.int_prop_value(PID_RECIPIENT_TYPE) + if kind == RECIPIENT_BCC: + continue + (cc_list if kind == RECIPIENT_CC else to_list).append(address) + except Exception as e: + logger.warning("Cannot read a .msg recipient: %s", e) + errors.append(f"recipient unreadable: {e}") return to_list, cc_list -def _handle_rtf_only(msg, out: MsgFields, re_module): - """Handle rare RTF-only .msg files (Outlook 97-2003).""" +def decompress_rtf(data: bytes) -> bytes: + """Decompress a PidTagRtfCompressed value (MS-OXRTFCP, "LZFu" or uncompressed "MELA"). + + Raises ValueError when the data is not a valid compressed-RTF stream. + """ + if len(data) < 16: + raise ValueError("compressed RTF header is truncated") + comp_size, raw_size = struct.unpack_from("> 4, (ref & 0xF) + 2 + if offset == write: # end-of-stream marker + return bytes(out[:raw_size]) + for _ in range(length): + byte = window[offset] + out.append(byte) + window[write] = byte + offset = (offset + 1) % 4096 + write = (write + 1) % 4096 + else: + byte = payload[pos] + pos += 1 + out.append(byte) + window[write] = byte + write = (write + 1) % 4096 + except IndexError: + raise ValueError("compressed RTF stream is truncated") from None + + +def _handle_rtf_only(msg, out: MsgFields) -> None: + """Recover the body of an RTF-only .msg (Outlook 97-2003) via the optional RTFDE package.""" + try: + compressed = msg.properties.binary_prop_value(PID_RTF_COMPRESSED) + except Exception as e: + out.errors.append(f"RTF body unreadable: {e}") + return + if not compressed: + return + + try: + rtf = decompress_rtf(compressed) + except ValueError as e: + out.errors.append(f"RTF-only body detected but its RTF could not be decompressed: {e}") + return + + try: + from RTFDE.deencapsulate import DeEncapsulator + except ImportError: + out.errors.append( + "RTF-only body detected (legacy .msg format, Outlook <2010). " + "Plain text unavailable. Install RTFDE for support: pip install RTFDE" + ) + return + + try: + de = DeEncapsulator(rtf) + de.deencapsulate() + if de.content_type == "html" and de.html: + out.body_html = _decode_rtfde(de.html) + elif de.text: + out.body_text = _decode_rtfde(de.text) + else: + out.errors.append("RTF-only body detected but it holds no encapsulated text or HTML") + except Exception as e: + out.errors.append(f"RTF de-encapsulation failed: {e}") + + +def _decode_rtfde(value: bytes) -> str: + """RTFDE returns bytes in the document's ANSI code page; mirror the EML header decoding order.""" + for charset in ("utf-8", "windows-1252"): + try: + return value.decode(charset) + except UnicodeDecodeError: + continue + return value.decode("utf-8", errors="replace") def get_msg_backend() -> Optional[MsgBackend]: diff --git a/backend/requirements.txt b/backend/requirements.txt index 48896ee..bb0d7f2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ fastapi==0.141.1 pydantic==2.13.5 pydantic-settings==2.15.0 -uvicorn[standard]==0.53.0 +uvicorn[standard]==0.54.0 python-multipart==0.0.32 # Email parsing @@ -32,7 +32,8 @@ filetype==1.2.0 python-docx==1.2.0 # Database (SQLAlchemy >= 2.0.36 richiesto per Python 3.13+) -sqlalchemy==2.0.54 +# L'extra [asyncio] installa greenlet, non più dipendenza di default da SQLAlchemy 2.1 +sqlalchemy[asyncio]==2.1.1 aiosqlite==0.22.1 # Security / sanitization @@ -45,7 +46,7 @@ defusedxml==0.7.1 pytest==9.1.1 pytest-asyncio==1.4.0 httpx==0.28.1 -coverage==7.16.1 +coverage==7.16.2 # Language detection langdetect==1.0.9 diff --git a/backend/tests/fixtures/make_sample_msg.py b/backend/tests/fixtures/make_sample_msg.py new file mode 100644 index 0000000..f1bc300 --- /dev/null +++ b/backend/tests/fixtures/make_sample_msg.py @@ -0,0 +1,324 @@ +"""Generate the synthetic Outlook .msg fixture used by the .msg parsing tests. + + python backend/tests/fixtures/make_sample_msg.py + +The output (sample.msg, next to this script) is deterministic: no timestamps, +only reserved example domains (RFC 2606) and a TEST-NET-1 address (RFC 5737). +It carries just the MAPI properties EMLyzer reads and is not meant to be +opened in Outlook. +""" + +from __future__ import annotations + +import datetime as dt +import struct +from pathlib import Path + +SECTOR = 512 +MINI_SECTOR = 64 +MINI_CUTOFF = 4096 +FREESECT = 0xFFFFFFFF +ENDOFCHAIN = 0xFFFFFFFE +FATSECT = 0xFFFFFFFD +NOSTREAM = 0xFFFFFFFF + +PTYP_INT32, PTYP_STRING, PTYP_TIME, PTYP_BINARY = 0x0003, 0x001F, 0x0040, 0x0102 + + +class Node: + def __init__(self, name: str, data: bytes | None = None, children: list["Node"] | None = None): + self.name = name + self.data = data + self.children = children or [] + self.sid = 0 + self.left = self.right = self.child = NOSTREAM + self.color = 1 + self.start = ENDOFCHAIN + self.size = 0 + + @property + def is_stream(self) -> bool: + return self.data is not None + + +def _sort_key(node: Node) -> tuple[int, str]: + return (len(node.name), node.name.upper()) + + +def _link_siblings(storage: Node) -> None: + kids = sorted(storage.children, key=_sort_key) + if not kids: + return + depth_of: dict[int, int] = {} + + def build(lo: int, hi: int, depth: int) -> int: + if lo >= hi: + return NOSTREAM + mid = (lo + hi) // 2 + node = kids[mid] + depth_of[node.sid] = depth + node.left = build(lo, mid, depth + 1) + node.right = build(mid + 1, hi, depth + 1) + return node.sid + + storage.child = build(0, len(kids), 1) + max_depth = max(depth_of.values()) + if len(kids) != 2**max_depth - 1: + for n in kids: + if depth_of[n.sid] == max_depth: + n.color = 0 # red leaves keep the red-black invariant on an incomplete last level + + +def _walk(node: Node, out: list[Node]) -> None: + node.sid = len(out) + out.append(node) + for k in node.children: + _walk(k, out) + + +def build_cfb(root: Node) -> bytes: + entries: list[Node] = [] + _walk(root, entries) + for n in entries: + if not n.is_stream: + _link_siblings(n) + + mini_stream = bytearray() + mini_fat: list[int] = [] + large: list[Node] = [] + for n in entries: + if not n.is_stream: + continue + n.size = len(n.data) + if n.size == 0: + continue + if n.size < MINI_CUTOFF: + count = -(-n.size // MINI_SECTOR) + first = len(mini_fat) + n.start = first + mini_fat.extend(range(first + 1, first + count)) + mini_fat.append(ENDOFCHAIN) + mini_stream += n.data + b"\x00" * (count * MINI_SECTOR - n.size) + else: + large.append(n) + + dir_secs = -(-len(entries) // 4) + minifat_secs = -(-len(mini_fat) // 128) + container_secs = -(-len(mini_stream) // SECTOR) + large_secs = sum(-(-n.size // SECTOR) for n in large) + data_secs = dir_secs + minifat_secs + container_secs + large_secs + fat_secs = 1 + while fat_secs * 128 < data_secs + fat_secs: + fat_secs += 1 + if fat_secs > 109: + raise ValueError("fixture too large for header-only DIFAT") + + fat: list[int] = [FATSECT] * fat_secs + cursor = fat_secs + + def chain(count: int) -> int: + nonlocal cursor + if count == 0: + return ENDOFCHAIN + first = cursor + fat.extend(range(first + 1, first + count)) + fat.append(ENDOFCHAIN) + cursor += count + return first + + dir_start = chain(dir_secs) + minifat_start = chain(minifat_secs) + container_start = chain(container_secs) + for n in large: + n.start = chain(-(-n.size // SECTOR)) + fat.extend([FREESECT] * (fat_secs * 128 - len(fat))) + + root.start = container_start + root.size = len(mini_stream) + + header = struct.pack( + "<8s16sHHHHH6sIIIIIIIII", + b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", b"\x00" * 16, + 0x003E, 0x0003, 0xFFFE, 9, 6, b"\x00" * 6, + 0, fat_secs, dir_start, 0, MINI_CUTOFF, + minifat_start, minifat_secs, ENDOFCHAIN, 0, + ) + difat = list(range(fat_secs)) + [FREESECT] * (109 - fat_secs) + header += struct.pack("<109I", *difat) + + def dir_entry(n: Node | None) -> bytes: + if n is None: + return struct.pack("<64sHBBIII16sIQQIQ", b"", 0, 0, 0, NOSTREAM, NOSTREAM, NOSTREAM, + b"\x00" * 16, 0, 0, 0, 0, 0) + name = n.name.encode("utf-16-le") + b"\x00\x00" + obj_type = 5 if n is root else (2 if n.is_stream else 1) + start = n.start if (n is root or n.is_stream) else 0 # CFB: storages carry start sector 0 + return struct.pack("<64sHBBIII16sIQQIQ", name, len(name), obj_type, n.color, + n.left, n.right, n.child, b"\x00" * 16, 0, 0, 0, start, n.size) + + dir_bytes = b"".join(dir_entry(n) for n in entries) + dir_bytes += dir_entry(None) * (dir_secs * 4 - len(entries)) + + out = bytearray(header) + out += struct.pack(f"<{len(fat)}I", *fat) + out += dir_bytes + minifat_bytes = struct.pack(f"<{len(mini_fat)}I", *mini_fat) + out += minifat_bytes + b"\xff" * (minifat_secs * SECTOR - len(minifat_bytes)) + out += bytes(mini_stream) + b"\x00" * (container_secs * SECTOR - len(mini_stream)) + for n in large: + out += n.data + b"\x00" * (-(-n.size // SECTOR) * SECTOR - n.size) + return bytes(out) + + +# ── MAPI layer ──────────────────────────────────────────────────────────── + +class Props: + def __init__(self) -> None: + self.entries: list[bytes] = [] + self.streams: list[Node] = [] + + def _add(self, ptyp: int, pid: int, payload: bytes) -> None: + self.entries.append(struct.pack(" "Props": + data = text.encode("utf-16-le") + self._add(PTYP_STRING, pid, struct.pack(" "Props": + self._add(PTYP_BINARY, pid, struct.pack(" "Props": + self._add(PTYP_INT32, pid, struct.pack(" "Props": + epoch = dt.datetime(1601, 1, 1, tzinfo=dt.timezone.utc) + self._add(PTYP_TIME, pid, struct.pack(" list[Node]: + return [Node("__properties_version1.0", header + b"".join(self.entries))] + self.streams + + +TRANSPORT_HEADERS = "\r\n".join([ + "Received: from mail.example.com (mail.example.com [192.0.2.10])", + " by mx.example.org with ESMTPS id synthetic0001", + " for ; Mon, 02 Mar 2026 10:14:58 +0000", + "Authentication-Results: mx.example.org;", + " spf=fail smtp.mailfrom=billing@example.com;", + " dkim=none;", + " dmarc=fail header.from=example.com", + 'From: "Example Billing" ', + "To: user@example.org", + "Cc: cc@example.org", + "Subject: Urgent: verify your account", + "Date: Mon, 02 Mar 2026 10:14:55 +0000", + "Message-ID: ", + "Return-Path: ", + "Reply-To: ", + "X-Mailer: Synthetic Sample Generator", + "MIME-Version: 1.0", + "", + "", +]) + +BODY_TEXT = ( + "Dear customer,\r\n\r\n" + "Your account will be suspended within 24 hours. " + "Verify your account now: http://example.com/verify\r\n\r\n" + "Example Billing\r\n" +) + +BODY_HTML = ( + "

Dear customer,

" + "

Your account will be suspended within 24 hours. " + 'Verify now

' + "

Example Billing

" +) + +ATTACHMENT_NAME = "invoice.txt" +ATTACHMENT_DATA = b"Synthetic attachment content for EMLyzer parser tests.\n" + + +def build_sample() -> bytes: + sent = dt.datetime(2026, 3, 2, 10, 14, 55, tzinfo=dt.timezone.utc) + recipients = [("Example User", "user@example.org", 1), ("Example Cc", "cc@example.org", 2)] + + root_props = ( + Props() + .string(0x001A, "IPM.Note") + .string(0x0037, "Urgent: verify your account") + .time(0x0039, sent) + .string(0x0C1A, "Example Billing") + .string(0x0C1F, "billing@example.com") + .string(0x5D01, "billing@example.com") + .string(0x007D, TRANSPORT_HEADERS) + .string(0x1000, BODY_TEXT) + .binary(0x1013, BODY_HTML.encode("ascii")) + ) + header = struct.pack("<8x4I8x", len(recipients), 1, len(recipients), 1) + children = root_props.nodes(header) + + for i, (name, addr, kind) in enumerate(recipients): + p = ( + Props() + .int32(0x0C15, kind) + .string(0x3001, name) + .string(0x3002, "SMTP") + .string(0x3003, addr) + .string(0x39FE, addr) + ) + children.append(Node(f"__recip_version1.0_#{i:08X}", children=p.nodes(b"\x00" * 8))) + + attach = ( + Props() + .int32(0x3705, 1) + .string(0x3703, ".txt") + .string(0x3704, ATTACHMENT_NAME) + .string(0x3707, ATTACHMENT_NAME) + .string(0x370E, "text/plain") + .binary(0x3701, ATTACHMENT_DATA) + ) + children.append(Node("__attach_version1.0_#00000000", children=attach.nodes(b"\x00" * 8))) + + return build_cfb(Node("Root Entry", children=children)) + + +_RTF_INIT_DICT_LEN = 207 # size of the LZFu initial dictionary (MS-OXRTFCP) + + +def lzfu_literals_only(rtf: bytes) -> bytes: + """Wrap `rtf` in a valid LZFu stream that uses literals only, plus the end marker.""" + marker = struct.pack(">H", ((_RTF_INIT_DICT_LEN + len(rtf)) % 4096) << 4) + payload = bytearray() + for start in range(0, len(rtf), 8): + chunk = rtf[start:start + 8] + if len(chunk) < 8: # end marker follows the last literal inside the same control group + payload += bytes([1 << len(chunk)]) + chunk + marker + break + payload += b"\x00" + chunk + else: # no partial group: the end marker opens a new control group + payload += b"\x01" + marker + return struct.pack(" bytes: + """Outlook 97-2003 style .msg: only PidTagRtfCompressed, no plain-text or HTML body.""" + props = ( + Props() + .string(0x001A, "IPM.Note") + .string(0x0037, "RTF only message") + .binary(0x1009, lzfu_literals_only(rtf)) + ) + header = struct.pack("<8x4I8x", 0, 0, 0, 0) + return build_cfb(Node("Root Entry", children=props.nodes(header))) + + +if __name__ == "__main__": + target = Path(__file__).with_name("sample.msg") + target.write_bytes(build_sample()) + print(f"wrote {target} ({target.stat().st_size} bytes)") diff --git a/backend/tests/fixtures/sample.msg b/backend/tests/fixtures/sample.msg new file mode 100644 index 0000000..41a2a5f Binary files /dev/null and b/backend/tests/fixtures/sample.msg differ diff --git a/backend/tests/test_core.py b/backend/tests/test_core.py index 57ff7f9..58fb5f3 100644 --- a/backend/tests/test_core.py +++ b/backend/tests/test_core.py @@ -7,6 +7,7 @@ import sys import os +import hashlib from pathlib import Path # Aggiungi backend al path @@ -1193,32 +1194,161 @@ def test_msg_backend_parse_empty_bytes(self): assert isinstance(result.body_text, str) def test_msg_parsing_via_email_parser(self): - """Full .msg parsing via parse_email_file (if sample available).""" - samples_dir = Path(__file__).parent.parent.parent / "samples" - msg_sample = samples_dir / "sample.msg" - - if not msg_sample.exists(): - pytest.skip(f"Sample .msg not found: {msg_sample}") - - raw = msg_sample.read_bytes() + """Full .msg parsing via parse_email_file against the synthetic fixture.""" + raw = (Path(__file__).parent / "fixtures" / "sample.msg").read_bytes() parsed = parse_email_file(raw, "sample.msg") - # Should not crash and should return ParsedEmail assert isinstance(parsed, ParsedEmail) - # Core fields should be populated (may be empty if .msg is minimal) - assert isinstance(parsed.mail_from, str) - assert isinstance(parsed.body_text, str) + assert parsed.parse_errors == [] + assert "billing@example.com" in parsed.mail_from + assert parsed.mail_subject == "Urgent: verify your account" + assert "suspended within 24 hours" in parsed.body_text + assert "http://example.com/verify" in parsed.body_html + + # Recipients come from the MAPI recipient table (type 1 = To, 2 = Cc) + assert parsed.mail_to == ["user@example.org"] + assert parsed.mail_cc == ["cc@example.org"] + + # Everything below lives only in the RFC 822 transport headers of the .msg + assert parsed.spf_result == "fail" + assert parsed.dkim_result == "none" + assert parsed.dmarc_result == "fail" + assert parsed.message_id == "" + assert parsed.return_path == "" + assert parsed.reply_to == "" + assert parsed.x_mailer == "Synthetic Sample Generator" + assert len(parsed.received_chain) == 1 + assert len(parsed.auth_results_raw) == 1 + assert "authentication-results" in parsed.raw_headers + + expected = b"Synthetic attachment content for EMLyzer parser tests.\n" + assert len(parsed.attachments) == 1 + att = parsed.attachments[0] + assert att["filename"] == "invoice.txt" + assert att["declared_mime"] == "text/plain" + assert att["data"] == expected + assert att["size_bytes"] == len(expected) + assert att["hash_sha256"] == hashlib.sha256(expected).hexdigest() + + def test_msg_authentication_failures_reach_header_analysis(self): + """Auth results found in a .msg's transport headers must drive header scoring.""" + raw = (Path(__file__).parent / "fixtures" / "sample.msg").read_bytes() + result = analyze_headers(parse_email_file(raw, "sample.msg")) + + assert (result.spf_result, result.dkim_result, result.dmarc_result) == ("fail", "none", "fail") + assert not result.spf_ok and not result.dkim_ok and not result.dmarc_ok + assert len(result.identity_mismatches) > 0 # Reply-To domain differs from From + + @staticmethod + def _msg_generator(): + import importlib.util + + path = Path(__file__).parent / "fixtures" / "make_sample_msg.py" + spec = importlib.util.spec_from_file_location("make_sample_msg", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def test_msg_sample_matches_generator(self): + """The committed binary fixture must be exactly what its generator produces.""" + sample = Path(__file__).parent / "fixtures" / "sample.msg" + assert sample.read_bytes() == self._msg_generator().build_sample() + + def test_msg_header_derived_fields_cover_parsed_email(self): + """Every ParsedEmail field must be classified, so none is silently dropped for .msg files.""" + import dataclasses + from core.analysis.email_parser import _MSG_HEADER_DERIVED_FIELDS + + from_mapi_file_or_body = { + "filename", "file_size_bytes", "file_hash_md5", "file_hash_sha1", "file_hash_sha256", + "mail_from", "mail_to", "mail_cc", "mail_subject", "mail_date", + "body_text", "body_html", "attachments", "parse_errors", + } + all_fields = {f.name for f in dataclasses.fields(ParsedEmail)} + assert all_fields == set(_MSG_HEADER_DERIVED_FIELDS) | from_mapi_file_or_body - def test_msg_rtf_only_warning(self): - """RTF-only .msg should populate errors with RTF warning (if RTFDE not installed).""" - from core.analysis.msg_backends import get_msg_backend, OxMsgBackend + RTF_TEXT = (rb"{\rtf1\ansi\ansicpg1252\fromtext \deff0{\fonttbl{\f0\fnil\fcharset0 Calibri;}}" + rb"\pard Hello RTF body\par }") - # This test verifies the warning mechanism - # A real RTF-only .msg would trigger the warning - backend = get_msg_backend() - if backend is None or not isinstance(backend, OxMsgBackend): - pytest.skip("OxMsgBackend not available") + def test_msg_rtf_decompression_reference_vector(self): + """LZFu stream with back-references, produced by the independent `compressed-rtf` package.""" + from core.analysis.msg_backends import decompress_rtf + + vector = bytes.fromhex( + "74000000380100004c5a4675b94cdcdc03000a0072637067313235e23203437465780541010301f713" + "02a7020063680ac0736574c83020430740696205100280c27d0af32048656c090007f0c854462006e0" + "64790aa30291ff0d1f0e2f0f3f104f115f0900128f0d1f7f0e2f0f3f104f115f0900128d2070" + ) + assert decompress_rtf(vector) == self.RTF_TEXT * 3 + + def test_msg_rtf_decompression_rejects_invalid_streams(self): + from core.analysis.msg_backends import decompress_rtf + + good = self._msg_generator().lzfu_literals_only(self.RTF_TEXT) + assert decompress_rtf(good) == self.RTF_TEXT + with pytest.raises(ValueError): + decompress_rtf(b"short") + with pytest.raises(ValueError): + decompress_rtf(good[:8] + b"XXXX" + good[12:]) # unknown signature + with pytest.raises(ValueError): + decompress_rtf(good[:-6]) # truncated before the end marker + + def test_msg_rtf_only_without_rtfde_reports_clear_error(self, monkeypatch): + """RTFDE is an optional dependency: its absence must be reported, not hidden.""" + monkeypatch.setitem(sys.modules, "RTFDE", None) # makes `import RTFDE...` raise ImportError + raw = self._msg_generator().build_rtf_only_sample(self.RTF_TEXT) + parsed = parse_email_file(raw, "rtf_only.msg") + + assert parsed.body_text == "" and parsed.body_html == "" + assert any("Install RTFDE" in e for e in parsed.parse_errors) + + def test_msg_rtf_only_body_recovered_with_rtfde(self): + """Against the real RTFDE package (installed in CI; skipped locally if absent).""" + pytest.importorskip("RTFDE", reason="optional LGPL dependency, installed only by the CI test job") + raw = self._msg_generator().build_rtf_only_sample(self.RTF_TEXT) + parsed = parse_email_file(raw, "rtf_only.msg") + + assert parsed.parse_errors == [] + assert "Hello RTF body" in parsed.body_text + + @staticmethod + def _stub_rtfde(monkeypatch, *, content_type="text", text=None, html=None, error=None): + """Replace RTFDE with a stub so our own handling is tested whether or not it is installed.""" + import types + + class DeEncapsulator: + def __init__(self, rtf): + self.content_type, self.text, self.html = content_type, text, html + + def deencapsulate(self): + if error: + raise error + + module = types.ModuleType("RTFDE.deencapsulate") + module.DeEncapsulator = DeEncapsulator + monkeypatch.setitem(sys.modules, "RTFDE", types.ModuleType("RTFDE")) + monkeypatch.setitem(sys.modules, "RTFDE.deencapsulate", module) + + def test_msg_rtf_only_html_body_is_decoded(self, monkeypatch): + # b"\xe9" is not valid UTF-8: the Windows-1252 fallback must kick in + self._stub_rtfde(monkeypatch, content_type="html", html=b"

caf\xe9

") + raw = self._msg_generator().build_rtf_only_sample(self.RTF_TEXT) + parsed = parse_email_file(raw, "rtf_only.msg") + + assert parsed.body_html == "

café

" + assert parsed.body_text == "" + assert parsed.parse_errors == [] + + def test_msg_rtf_only_without_content_reports_error(self, monkeypatch): + self._stub_rtfde(monkeypatch, content_type="text", text=b"") + raw = self._msg_generator().build_rtf_only_sample(self.RTF_TEXT) + parsed = parse_email_file(raw, "rtf_only.msg") + + assert any("no encapsulated text or HTML" in e for e in parsed.parse_errors) + + def test_msg_rtf_deencapsulation_failure_is_reported(self, monkeypatch): + self._stub_rtfde(monkeypatch, error=RuntimeError("boom")) + raw = self._msg_generator().build_rtf_only_sample(self.RTF_TEXT) + parsed = parse_email_file(raw, "rtf_only.msg") - # We can't easily create a synthetic RTF-only .msg without OleFile manipulation - # This test documents the expected behavior - # In practice, RTF-only warnings are caught during real .msg parsing + assert any("de-encapsulation failed: boom" in e for e in parsed.parse_errors) diff --git a/backend/utils/config.py b/backend/utils/config.py index a0220a5..ec10d22 100644 --- a/backend/utils/config.py +++ b/backend/utils/config.py @@ -14,7 +14,7 @@ class Settings(BaseSettings): # App APP_NAME: str = "EMLyzer" - VERSION: str = "0.17.1" + VERSION: str = "0.17.2" DEBUG: bool = False # CORS - backend in produzione + Vite dev server diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index afdc24e..05a3c6a 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -112,7 +112,7 @@ python3.13 --version **Linux/macOS:** ```bash -tar -xzf EMLyzer_v0.17.1.tar.gz +tar -xzf EMLyzer_v0.17.2.tar.gz cd EMLyzer ``` @@ -139,7 +139,7 @@ A black console window opens showing progress: ``` ============================================ - EMLyzer v0.17.1 + EMLyzer v0.17.2 ============================================ [INFO] Python found: @@ -201,7 +201,7 @@ Open this link in your browser to verify the backend: Expected response: ```json -{"status": "ok", "version": "0.17.1", "app": "EMLyzer"} +{"status": "ok", "version": "0.17.2", "app": "EMLyzer"} ``` --- diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 4aa75ed..757ac19 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -133,6 +133,7 @@ All dependencies are installed **automatically** during first run via `start.bat - **mail-parser** (3.15.0+) — RFC 5322 parser for .eml files - **python-oxmsg** (0.0.2+) — Microsoft Outlook .msg parser (MIT license, Unstructured-IO maintained) - **beautifulsoup4** (4.14.0+) — HTML parsing and analysis +- **RTFDE** (optional, LGPL-3.0) — recovers the body of RTF-only Outlook 97-2003 `.msg` files. Not installed by default; run `pip install RTFDE` only if you receive such files. Without it, EMLyzer analyzes the rest of the message and reports that the body could not be read ### 🌐 URL & Domain Analysis - **tldextract** (5.1.0+) — Domain and TLD extraction @@ -148,7 +149,7 @@ All dependencies are installed **automatically** during first run via `start.bat - **aiohttp** (3.9.0+) — Async HTTP for parallel requests ### 💾 Data Persistence -- **sqlalchemy** (2.0.34+) — ORM with async support +- **sqlalchemy[asyncio]** (2.1.1+) — ORM with async support (the `asyncio` extra pulls in `greenlet`, which SQLAlchemy 2.1 no longer installs by default) - **aiosqlite** (3.1.0+) — Async SQLite driver ### 📄 Report Generation diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 12aee27..673dec2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,15 +1,15 @@ { "name": "frontend", - "version": "0.17.1", + "version": "0.17.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.17.1", + "version": "0.17.2", "dependencies": { "axios": "^1.20.0", - "lucide-react": "^1.47.0", + "lucide-react": "^1.48.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-router-dom": "^7.18.4" @@ -23,7 +23,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.7", "globals": "^17.12.0", - "vite": "^8.3.0" + "vite": "^8.3.1" } }, "node_modules/@babel/code-frame": { @@ -511,19 +511,19 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.147.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", - "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", - "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", "cpu": [ "arm" ], @@ -538,9 +538,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", - "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "cpu": [ "arm64" ], @@ -555,9 +555,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", - "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "cpu": [ "arm64" ], @@ -572,9 +572,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", - "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "cpu": [ "x64" ], @@ -589,9 +589,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", - "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "cpu": [ "x64" ], @@ -606,9 +606,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", - "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "cpu": [ "arm" ], @@ -623,9 +623,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", - "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "cpu": [ "arm64" ], @@ -643,9 +643,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", - "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "cpu": [ "arm64" ], @@ -663,9 +663,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", - "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "cpu": [ "ppc64" ], @@ -683,9 +683,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", - "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "cpu": [ "s390x" ], @@ -703,9 +703,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", - "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "cpu": [ "x64" ], @@ -723,9 +723,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", - "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", - "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", - "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "cpu": [ "arm64" ], @@ -777,9 +777,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", - "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "cpu": [ "x64" ], @@ -959,9 +959,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2078,9 +2078,9 @@ } }, "node_modules/lucide-react": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", - "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2376,13 +2376,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", - "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.147.0", + "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2392,21 +2392,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.6", - "@rolldown/binding-android-arm64": "1.2.6", - "@rolldown/binding-darwin-arm64": "1.2.6", - "@rolldown/binding-darwin-x64": "1.2.6", - "@rolldown/binding-freebsd-x64": "1.2.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", - "@rolldown/binding-linux-arm64-gnu": "1.2.6", - "@rolldown/binding-linux-arm64-musl": "1.2.6", - "@rolldown/binding-linux-ppc64-gnu": "1.2.6", - "@rolldown/binding-linux-s390x-gnu": "1.2.6", - "@rolldown/binding-linux-x64-gnu": "1.2.6", - "@rolldown/binding-linux-x64-musl": "1.2.6", - "@rolldown/binding-openharmony-arm64": "1.2.6", - "@rolldown/binding-win32-arm64-msvc": "1.2.6", - "@rolldown/binding-win32-x64-msvc": "1.2.6" + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, "node_modules/scheduler": { @@ -2526,16 +2526,16 @@ } }, "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", - "rolldown": "~1.2.6", + "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/frontend/package.json b/frontend/package.json index f2e19a6..504e175 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.17.1", + "version": "0.17.2", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ }, "dependencies": { "axios": "^1.20.0", - "lucide-react": "^1.47.0", + "lucide-react": "^1.48.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-router-dom": "^7.18.4" @@ -29,6 +29,6 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.7", "globals": "^17.12.0", - "vite": "^8.3.0" + "vite": "^8.3.1" } } diff --git a/start.bat b/start.bat index 64f1a24..cb89dcf 100644 --- a/start.bat +++ b/start.bat @@ -8,7 +8,7 @@ set "VENV_DIR=%~dp0.venv" set "VENV_PYTHON=%~dp0.venv\Scripts\python.exe" set "FOUND_PYTHON=" set "FOUND_VER=" -set "VERSION=0.17.1" +set "VERSION=0.17.2" :: ── Lingua output (it/en) — rilevata dalla locale di sistema ────────────────── :: Default: italiano. Se la lingua UI di Windows e' inglese, usa l'inglese.