diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a31100..1d90610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,66 @@ Features are ordered by implementation priority. --- +## [0.16.1] — 2026-07-02 + +### Fixed — Code Review Hardening + +#### Detection pipeline +- **Attachment binary analysis now runs in production** — the parser now retains attachment + bytes (`data` key) and `analyze_attachments()` passes them to the binary scanners. + VBA macro detection (OLE2/OOXML), PDF JavaScript and suspicious PDF stream checks were + previously dead code in the live pipeline (only exercised by tests with synthetic bytes) +- **Authentication-Results header selection** — SPF/DKIM/DMARC results are now read from + the FIRST (topmost) `Authentication-Results` header, the one prepended by the final + receiving server. Previously the LAST header was used, which an attacker could inject + into the original message to spoof `spf=pass; dkim=pass; dmarc=pass` +- **Inline attachments analyzed** — MIME parts with a filename but `Content-Disposition: + inline` (or none) are now extracted and scanned like regular attachments +- **Body pattern double-counting removed** — urgency/CTA/credential counters now take the + MAX across text sources (plain text, HTML-extracted text, hidden content) instead of + summing them. multipart/alternative emails (same content in text+HTML) no longer get + doubled counts, duplicated findings and inflated body/NLP scores + +#### Correctness +- **6 missing i18n keys added** (`header.brand_spoofing`, `header.dkim_domain_mismatch`, + `url.malicious_cdn`, `body.language_mismatch`, `body.known_campaign`, `analysis.db_error`) + — v0.15 findings displayed the raw key instead of a description +- **WHOIS timeout now effective** — per-call `ThreadPoolExecutor` context managers blocked + on `shutdown(wait=True)` until the query completed, making the 8s wall-clock timeout + illusory; replaced with a shared executor. URL batch analysis executors now shut down + with `wait=False, cancel_futures=True` so `URL_BATCH_TIMEOUT` is actually enforced +- **Risk label language** — `RISK_LABELS` translations resolved at call time instead of + import time; label text now follows runtime language switches via `/api/settings/language` +- **`_logger` NameError** in `campaign_detector.py` (triggered with >10k emails) — logger + now defined +- **Hostname prefix stripping** — `lstrip("www.")` (strips characters, mangling hosts like + `web.example.com` → `eb.example.com`) replaced with `removeprefix("www.")` in reputation + indicator extraction (4 occurrences) +- **Trusted CDN IP over-matching** — prefix match now requires an octet boundary + (`"54.1"` no longer matches `54.100.x.x`) +- **List-Unsubscribe domain check bypass** — external-domain comparison now uses dot-boundary + subdomain matching (`evilpaypal.com` no longer passes as internal to `paypal.com`) +- **Brand spoofing false positives** — brand aliases matched with word boundaries + (e.g. "visa" no longer matches inside "advisor") +- **IPv4 validation** — direct-IP URL detection validates octets 0-255 via `ipaddress` + (pattern `999.999.999.999` no longer flagged as IP) +- **`mail_to` consistency** — stored as JSON in both upload and manual pipelines; GET + `/api/analysis/{job_id}` now returns it as a list (same shape as POST) +- **Manual analysis parity** — `/api/manual/` now passes `header_result` to `analyze_body()` + so the NLP model receives real SPF/DKIM/DMARC flags (was always False) +- **SPA fallback** — unknown `/api/*` paths now return JSON 404 instead of the SPA HTML page +- **Unbounded upload memory read** — `POST /api/upload/` read the entire request body into + memory via `file.read()` before checking it against `MAX_UPLOAD_SIZE_MB`, so an oversized + upload was still fully buffered before being rejected (potential memory-exhaustion DoS on + internet-facing deployments without a reverse-proxy body-size limit). Now reads in 1MB + chunks and rejects as soon as the configured limit is exceeded, without buffering the rest + +### Notes +- All 123 tests passing (1 skipped), zero regressions +- No API schema changes; PATCH release per SemVer + +--- + ## [0.16.0] — 2026-06-29 ### Changed — .msg Backend Abstraction & GPL License Resolution diff --git a/README.md b/README.md index 0804cd1..6a32dc1 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ Email (.eml / .msg / plain text) ## 🔧 Version +**v0.16.1** — 🔧 Hardening release: Enabled attachment binary analysis in the live pipeline (VBA macros, PDF JavaScript, suspicious streams), fixed Authentication-Results header selection (anti-spoofing), inline attachment extraction, removed body pattern double-counting on multipart emails, added 6 missing i18n keys, made WHOIS/URL batch timeouts effective, plus 10 correctness fixes. All 123 tests passing ✅, zero regressions. + **v0.16.0** — 🏗️ Architecture release: Migrated .msg parsing to python-oxmsg (MIT license), eliminated GPL violation, introduced MsgBackend abstraction for pluggable implementations, unblocked beautifulsoup4 to 4.14.0, added transport headers support (SPF/DKIM/DMARC for .msg files), RTF-only email support with optional RTFDE fallback. All 122 tests passing ✅, production-ready. 📖 **See full version history** → [CHANGELOG.md](./CHANGELOG.md) diff --git a/backend/api/routes/analysis.py b/backend/api/routes/analysis.py index c902227..ca8ab31 100644 --- a/backend/api/routes/analysis.py +++ b/backend/api/routes/analysis.py @@ -8,7 +8,7 @@ from pathlib import Path from dataclasses import asdict -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -19,6 +19,7 @@ class NotesUpdate(BaseModel): notes: str = "" from sqlalchemy import text +from core.rate_limiting import limiter from models.database import get_session, EmailAnalysis, engine from utils.config import settings from utils.i18n import t @@ -105,7 +106,9 @@ class BulkDeleteRequest(BaseModel): @router.post("/bulk-delete") +@limiter.limit("5/minute") async def bulk_delete_analyses( + request: Request, body: BulkDeleteRequest, db: AsyncSession = Depends(get_session), ): @@ -145,7 +148,9 @@ async def bulk_delete_analyses( @router.post("/{job_id}") +@limiter.limit("10/minute") async def run_analysis( + request: Request, job_id: str, do_whois: bool = True, db: AsyncSession = Depends(get_session), @@ -159,6 +164,18 @@ async def run_analysis( # Recupera il file file_path = _find_upload_file(job_id) + + # Verifica dimensione file prima di leggere in RAM (non affidarsi solo al chunking upload) + MAX_SIZE = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + file_size = file_path.stat().st_size + if file_size > MAX_SIZE: + _logger.warning("[%s] [FILE SIZE EXCEEDED] File size %d bytes > limit %d bytes", + job_id, file_size, MAX_SIZE) + raise HTTPException( + status_code=413, + detail=t("upload.too_large", max_mb=settings.MAX_UPLOAD_SIZE_MB) + ) + raw = file_path.read_bytes() original_filename = file_path.name # es. .eml @@ -253,20 +270,16 @@ def _pipeline(): risk_explanation={"explanation": risk.explanation, "contributions": _dataclass_to_dict(risk)}, ) - # Upsert: se già esiste (riesecuzione analisi), aggiorna - # IMPORTANT: atomic transaction — delete + add in single commit to avoid race conditions - existing = await db.get(EmailAnalysis, job_id) - if existing: - _logger.info("[%s] [DB DELETE] Existing analysis found, deleting for upsert", job_id) - await db.delete(existing) - _logger.info("[%s] [DB DELETE] Existing record marked for deletion", job_id) - - _logger.info("[%s] [DB ADD] Adding new EmailAnalysis record to session", job_id) + # Upsert: idempotent merge per evitare race conditions tra get() e add() + # SQLAlchemy merge() è atomico: se il record esiste, lo aggiorna; se no, lo crea. + # Questo previene la finestra di tempo dove un'altra richiesta potrebbe modificare il record. + _logger.info("[%s] [DB UPSERT] Merging EmailAnalysis record (atomic upsert)", job_id) try: - db.add(record) - _logger.info("[%s] [DB COMMIT] Committing transaction to database (includes delete if applicable)", job_id) + # merge() è atomico in SQLAlchemy 2.0+: no race condition tra get() e add() + merged_record = await db.merge(record) + _logger.info("[%s] [DB COMMIT] Committing transaction to database", job_id) await db.commit() - _logger.info("[%s] [DB SUCCESS] Analysis persisted successfully, record_id=%s", job_id, record.id) + _logger.info("[%s] [DB SUCCESS] Analysis persisted successfully, record_id=%s", job_id, merged_record.id) except Exception as e: _logger.error("[%s] [DB ERROR] Failed to commit to database: %s", job_id, str(e)) await db.rollback() @@ -375,6 +388,18 @@ def _build_response_from_record(record) -> dict: ai = record.attachment_indicators or {} ri = record.risk_explanation or {} + # mail_to è salvato come JSON (lista serializzata): decodifica per + # restituire la stessa struttura del POST. Fallback al valore grezzo + # per record storici salvati in altri formati. + mail_to = record.mail_to + if isinstance(mail_to, str): + try: + decoded = json.loads(mail_to) + if isinstance(decoded, list): + mail_to = decoded + except (ValueError, TypeError): + pass + return { "job_id": record.id, "status": "completed", @@ -383,7 +408,7 @@ def _build_response_from_record(record) -> dict: "filename": record.filename, "subject": record.mail_subject, "from": record.mail_from, - "to": record.mail_to, + "to": mail_to, "date": record.mail_date, "message_id": record.message_id, "file_hash_sha256": record.file_hash_sha256, @@ -580,6 +605,8 @@ def _build_response(job_id, parsed, header_result, body_result, url_result, atta ] ) +# Allowlist di tag HTML sicuri per email preview. +# Tag esclusi per protezione: script, style, iframe, form, input, meta, link (no remote resources/code execution) _BLEACH_ALLOWED_TAGS = [ 'a', 'abbr', 'acronym', 'b', 'blockquote', 'br', 'caption', 'cite', 'code', 'dd', 'del', 'dfn', 'div', 'dl', 'dt', 'em', @@ -733,8 +760,26 @@ async def delete_analysis( if not record: raise HTTPException(status_code=404, detail=t("analysis.not_found")) - await db.delete(record) - await db.commit() - files_removed = _cleanup_files(job_id) - await _vacuum_db() + # Cleanup file first, then delete from DB (atomic semantic) + # If file cleanup fails, DB delete won't execute + try: + files_removed = _cleanup_files(job_id) + except Exception as e: + _logger.error("[%s] [FILE CLEANUP ERROR] Failed to delete files: %s", job_id, e, exc_info=True) + raise HTTPException(status_code=500, detail=t("analysis.delete_error")) + + # File cleanup succeeded, safe to delete from DB + try: + await db.delete(record) + await db.commit() + except Exception as e: + _logger.error("[%s] [DB DELETE ERROR] Failed to delete from database: %s", job_id, e, exc_info=True) + raise HTTPException(status_code=500, detail=t("analysis.delete_error")) + + # VACUUM is non-critical, don't fail if it errors + try: + await _vacuum_db() + except Exception as e: + _logger.warning("[%s] [VACUUM WARNING] Database compaction failed (non-critical): %s", job_id, e) + return {"status": "deleted", "job_id": job_id, "files_removed": files_removed} \ No newline at end of file diff --git a/backend/api/routes/manual.py b/backend/api/routes/manual.py index 178521f..c526fc1 100644 --- a/backend/api/routes/manual.py +++ b/backend/api/routes/manual.py @@ -11,11 +11,12 @@ import hashlib from pathlib import Path -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import JSONResponse from pydantic import BaseModel +from core.rate_limiting import limiter from utils.config import settings from utils.i18n import t from core.analysis.email_parser import parse_email_file, raw_looks_like_eml @@ -35,7 +36,8 @@ class ManualInput(BaseModel): @router.post("/") -async def analyze_manual(payload: ManualInput): +@limiter.limit("10/minute") +async def analyze_manual(request: Request, payload: ManualInput): """ Analizza un sorgente email incollato manualmente. Non richiede upload file: accetta il testo come JSON body. @@ -87,7 +89,9 @@ def _pipeline(): """ _parsed = parse_email_file(raw, payload.filename) _header_result = analyze_headers(_parsed) - _body_result = analyze_body(_parsed) + # header_result passa i flag SPF/DKIM/DMARC al classificatore NLP + # (stessa pipeline dell'upload file) + _body_result = analyze_body(_parsed, _header_result) _url_result = analyze_urls(_body_result.extracted_urls, do_whois=_do_whois) _attachment_result = analyze_attachments(_parsed.attachments) _risk = compute_risk_score(_header_result, _body_result, _url_result, _attachment_result) @@ -103,7 +107,7 @@ def _pipeline(): filename=payload.filename, file_hash_sha256=parsed.file_hash_sha256, mail_from=parsed.mail_from, - mail_to=str(parsed.mail_to), + mail_to=json.dumps(parsed.mail_to), mail_subject=parsed.mail_subject, mail_date=parsed.mail_date, message_id=parsed.message_id, diff --git a/backend/api/routes/reputation.py b/backend/api/routes/reputation.py index b505564..35d643b 100644 --- a/backend/api/routes/reputation.py +++ b/backend/api/routes/reputation.py @@ -116,7 +116,9 @@ def _is_trusted_cdn_ip(ip: str) -> bool: if not ip: return False for trusted_prefix in _TRUSTED_CDN_IPS: - if ip.startswith(trusted_prefix): + # Il punto finale evita over-matching: "54.1" non deve matchare + # "54.100.x.x" o "54.19.x.x" ma solo "54.1.x.x". + if ip.startswith(trusted_prefix + "."): return True return False @@ -178,7 +180,7 @@ def add_url(raw: str, is_suspicious: bool = False) -> None: # Estrai hostname dall'URL try: from urllib.parse import urlparse - hostname = urlparse(url).netloc.split(":")[0].lstrip("www.") + hostname = urlparse(url).netloc.split(":")[0].removeprefix("www.") except Exception: hostname = "" @@ -210,7 +212,7 @@ def add_domain(raw: str) -> None: return try: from urllib.parse import urlparse - hostname = urlparse(raw).netloc.split(":")[0].lstrip("www.") + hostname = urlparse(raw).netloc.split(":")[0].removeprefix("www.") # Scarta IP diretti if hostname and not (hostname.count(".") < 1 or hostname.replace(".", "").isdigit()): if hostname not in seen_domains and not _is_trusted_cdn(hostname): @@ -369,7 +371,7 @@ def add_url_if_worth_checking(u: dict) -> None: # Estrai hostname e controlla se trusted CDN (escludi sempre) try: from urllib.parse import urlparse - hostname = urlparse(url_str).netloc.split(":")[0].lstrip("www.") + hostname = urlparse(url_str).netloc.split(":")[0].removeprefix("www.") except Exception: hostname = "" @@ -450,7 +452,7 @@ def add_url_if_worth_checking(u: dict) -> None: if url_str: try: from urllib.parse import urlparse - hostname = urlparse(url_str).netloc.split(":")[0].lstrip("www.") + hostname = urlparse(url_str).netloc.split(":")[0].removeprefix("www.") is_suspicious = ( u.get("is_ip_address") or u.get("is_ip") or u.get("is_shortener") or u.get("is_new_domain") or diff --git a/backend/api/routes/upload.py b/backend/api/routes/upload.py index 8223159..f76a7b0 100644 --- a/backend/api/routes/upload.py +++ b/backend/api/routes/upload.py @@ -12,9 +12,10 @@ import hashlib from pathlib import Path -from fastapi import APIRouter, UploadFile, File, HTTPException, status +from fastapi import APIRouter, UploadFile, File, HTTPException, status, Request from fastapi.responses import JSONResponse +from core.rate_limiting import limiter from utils.config import settings from utils.i18n import t @@ -24,7 +25,8 @@ @router.post("/") -async def upload_email(file: UploadFile = File(...)): +@limiter.limit("10/minute") +async def upload_email(request: Request, file: UploadFile = File(...)): # 1. Validazione nome file e estensione if not file.filename: raise HTTPException(status_code=400, detail=t("upload.no_filename")) @@ -36,15 +38,29 @@ async def upload_email(file: UploadFile = File(...)): detail=t("upload.unsupported_format", ext=ext, allowed=settings.ALLOWED_EXTENSIONS), ) - # 2. Lettura e validazione dimensione - raw = await file.read() + # 2. Lettura a chunk con controllo progressivo della dimensione. + # Legge al massimo MAX_SIZE+1 byte prima di rifiutare: un client che + # dichiara (o invia) un body enorme non riesce a far bufferizzare al + # server più dati del limite configurato, indipendentemente dalla + # dimensione reale della richiesta. + _CHUNK_SIZE = 1024 * 1024 # 1 MB + chunks: list[bytes] = [] + total_size = 0 + while True: + chunk = await file.read(_CHUNK_SIZE) + if not chunk: + break + total_size += len(chunk) + if total_size > MAX_SIZE: + raise HTTPException( + status_code=413, + detail=t("upload.too_large", max_mb=settings.MAX_UPLOAD_SIZE_MB), + ) + chunks.append(chunk) + raw = b"".join(chunks) + if len(raw) == 0: raise HTTPException(status_code=400, detail=t("upload.empty_file")) - if len(raw) > MAX_SIZE: - raise HTTPException( - status_code=413, - detail=t("upload.too_large", max_mb=settings.MAX_UPLOAD_SIZE_MB), - ) # 3. Calcola hash SHA256 del file caricato sha256 = hashlib.sha256(raw).hexdigest() diff --git a/backend/core/analysis/attachment_analyzer.py b/backend/core/analysis/attachment_analyzer.py index cafdb1b..89a427d 100644 --- a/backend/core/analysis/attachment_analyzer.py +++ b/backend/core/analysis/attachment_analyzer.py @@ -360,7 +360,9 @@ def analyze_attachments(attachments: list[dict]) -> AttachmentAnalysisResult: _logger.info("[ATTACH START] Analyzing %d attachments", result.total_attachments) for att in attachments: - analysis = analyze_attachment(att, raw_data=None) + # I bytes grezzi (chiave "data" dal parser) abilitano l'analisi binaria: + # macro VBA in OLE2/OOXML, JavaScript e stream sospetti nei PDF. + analysis = analyze_attachment(att, raw_data=att.get("data")) result.attachments.append(analysis) if any(f.severity == "critical" for f in analysis.findings): result.critical_count += 1 diff --git a/backend/core/analysis/body_analyzer.py b/backend/core/analysis/body_analyzer.py index 4194334..fec8734 100644 --- a/backend/core/analysis/body_analyzer.py +++ b/backend/core/analysis/body_analyzer.py @@ -339,21 +339,20 @@ def _detect_campaign_match(text_lower: str, subject_lower: str) -> dict | None: return None -def _count_pattern_matches(pattern_list: list[str], text: str, result: BodyAnalysisResult, attr_name: str, max_match_len: int = 150) -> list[tuple[str, int]]: +def _count_pattern_matches(pattern_list: list[str], text: str, max_match_len: int = 150) -> tuple[int, list[tuple[str, int]]]: """ Consolida logica pattern matching — evita duplicazione. Args: pattern_list: lista di regex pattern da matchare text: testo normalizzato lowercase - result: BodyAnalysisResult object (attributo incrementato in-place) - attr_name: nome dell'attributo su result da incrementare (es. 'urgency_count') max_match_len: ignora match > questo valore (probabili falsi positivi) Returns: - list[tuple(pattern_text, count)] ordinato per frequenza descrescente + (totale_match, list[tuple(pattern_text, count)] ordinata per frequenza decrescente) """ - pattern_hits = {} # {pattern_matched: count} + pattern_hits: dict[str, int] = {} # {pattern_matched: count} + total = 0 for pattern in pattern_list: matches = re.findall(pattern, text) @@ -361,36 +360,56 @@ def _count_pattern_matches(pattern_list: list[str], text: str, result: BodyAnaly # Ignora match troppo lunghi — probabili errori di capturing if len(match_text) > max_match_len: continue - # Incrementa il contatore nel result object - setattr(result, attr_name, getattr(result, attr_name) + 1) - # Track pattern hit - if match_text not in pattern_hits: - pattern_hits[match_text] = 0 - pattern_hits[match_text] += 1 + total += 1 + pattern_hits[match_text] = pattern_hits.get(match_text, 0) + 1 - # Ritorna lista di tuple ordinata per frequenza (pattern più comuni prima) - return sorted(pattern_hits.items(), key=lambda x: x[1], reverse=True) + # Tuple ordinate per frequenza (pattern più comuni prima) + return total, sorted(pattern_hits.items(), key=lambda x: x[1], reverse=True) -def _analyze_text(body_text: str, result: BodyAnalysisResult): - """Analisi pattern su testo plain.""" - if not body_text: - return +def _analyze_text(sources: "str | list[str]", result: BodyAnalysisResult): + """Analisi pattern su una o più sorgenti testuali (plain text, testo HTML, + contenuto nascosto). + + Le email multipart/alternative duplicano lo stesso contenuto in plain text + e HTML: sommare i conteggi delle sorgenti gonfierebbe artificialmente i + punteggi. Per ogni categoria si usa quindi il MASSIMO tra le sorgenti + (cattura comunque il caso "HTML malevolo con plain text innocuo"), + unendo i pattern trovati per l'evidenza. + """ + if isinstance(sources, str): + sources = [sources] + # Normalizza Unicode accenti (NFC) per matching multilingua # Risolve problemi con portoghese/italiano: "será" vs "sera" - text_normalized = unicodedata.normalize('NFC', body_text) - text_lower = text_normalized.lower() - - # P1: Deduplica pattern per categoria — mantiene SOLO i pattern unici trovati - # Usa helper function per evitare duplicazione di logica - urgency_matches = _count_pattern_matches(URGENCY_PATTERNS, text_lower, result, 'urgency_count') - cta_matches = _count_pattern_matches(PHISHING_CTAS, text_lower, result, 'phishing_cta_count') - credential_matches = _count_pattern_matches(CREDENTIAL_KEYWORDS, text_lower, result, 'credential_keyword_count') + normalized = [ + unicodedata.normalize('NFC', text).lower() + for text in sources if text + ] + if not normalized: + return - # P1: Estrai solo i testi dei pattern (senza conteggi) - urgency_unique = [m[0] for m in urgency_matches[:5]] - cta_unique = [m[0] for m in cta_matches[:5]] - credential_unique = [m[0] for m in credential_matches[:5]] + categories = [ + (URGENCY_PATTERNS, 'urgency_count'), + (PHISHING_CTAS, 'phishing_cta_count'), + (CREDENTIAL_KEYWORDS, 'credential_keyword_count'), + ] + hits_by_attr: dict[str, list[tuple[str, int]]] = {} + for patterns, attr in categories: + best_count = 0 + merged_hits: dict[str, int] = {} + for text in normalized: + count, hits = _count_pattern_matches(patterns, text) + best_count = max(best_count, count) + for match_text, n in hits: + merged_hits[match_text] = max(merged_hits.get(match_text, 0), n) + setattr(result, attr, best_count) + hits_by_attr[attr] = sorted(merged_hits.items(), key=lambda x: x[1], reverse=True) + + # Estrai solo i testi dei pattern (senza conteggi) per l'evidenza + urgency_unique = [m[0] for m in hits_by_attr['urgency_count'][:5]] + cta_unique = [m[0] for m in hits_by_attr['phishing_cta_count'][:5]] + credential_unique = [m[0] for m in hits_by_attr['credential_keyword_count'][:5]] # URGENCY if result.urgency_count >= 3: @@ -583,13 +602,9 @@ def _analyze_html(body_html: str, result: BodyAnalysisResult): if txt: hidden_texts.append(txt) if hidden_texts: + # Il testo nascosto viene incluso come sorgente nel pattern + # matching centralizzato di analyze_body (niente doppio conteggio). result.raw_hidden_content = "\n".join(hidden_texts[:20]) - # v0.15.1 FIX: Analyze patterns in hidden content too! - # Hidden text often contains phishing indicators masked from visual inspection - hidden_content_combined = " ".join(hidden_texts) - _analyze_text(hidden_content_combined, result) - _logger.debug("[BODY] Hidden content analyzed: %d urgency, %d cta, %d credentials", - result.urgency_count, result.phishing_cta_count, result.credential_keyword_count) result.findings.append(BodyFinding( category="html", @@ -797,8 +812,9 @@ def analyze_body(parsed: ParsedEmail, header_result: "HeaderAnalysisResult" = No _logger.info("[BODY START] text_len=%d, html_len=%d", len(parsed.body_text or ''), len(parsed.body_html or '')) - _analyze_text(parsed.body_text, result) - _logger.debug("[BODY] text analysis: %d findings", len(result.findings)) + # HTML: link offuscati, form, JS, elementi nascosti (popola raw_hidden_content), base64 + _analyze_html(parsed.body_html, result) + _logger.debug("[BODY] html analysis: %d findings, %d urls extracted", len(result.findings), len(result.extracted_urls)) # v0.15.1 FIX: ALWAYS extract text from HTML for pattern analysis # Many sophisticated phishing emails have innocuous plain text but malicious visible HTML content @@ -811,13 +827,20 @@ def analyze_body(parsed: ParsedEmail, header_result: "HeaderAnalysisResult" = No if html_text and len(html_text) > 50: # v0.15.1: Save extracted HTML text for campaign matching result.extracted_html_text = html_text - _logger.debug("[BODY] Extracting text from HTML for pattern analysis (html_text_len=%d)", len(html_text)) - _analyze_text(html_text, result) + _logger.debug("[BODY] Extracted text from HTML for pattern analysis (html_text_len=%d)", len(html_text)) except Exception as e: _logger.error("[BODY] Failed to extract text from HTML (html_len=%d): %s", len(parsed.body_html or ''), e) - _analyze_html(parsed.body_html, result) - _logger.debug("[BODY] html analysis: %d findings, %d urls extracted", len(result.findings), len(result.extracted_urls)) + # Pattern matching centralizzato su tutte le sorgenti testuali. + # Il massimo per categoria (non la somma) evita il doppio conteggio dello + # stesso contenuto nelle email multipart/alternative (plain + HTML). + _analyze_text( + [parsed.body_text, result.extracted_html_text, result.raw_hidden_content], + result, + ) + _logger.debug("[BODY] text analysis: %d findings (urgency=%d, cta=%d, creds=%d)", + len(result.findings), result.urgency_count, + result.phishing_cta_count, result.credential_keyword_count) _check_homoglyphs(parsed.body_text, result) _logger.debug("[BODY] homoglyphs checked: %d findings", len(result.findings)) diff --git a/backend/core/analysis/campaign_detector.py b/backend/core/analysis/campaign_detector.py index a6ff07f..50d618e 100644 --- a/backend/core/analysis/campaign_detector.py +++ b/backend/core/analysis/campaign_detector.py @@ -13,9 +13,12 @@ import re import hashlib +import logging from dataclasses import dataclass, field from datetime import datetime, timezone +_logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Strutture dati diff --git a/backend/core/analysis/email_parser.py b/backend/core/analysis/email_parser.py index 9963e5d..1f17a4d 100644 --- a/backend/core/analysis/email_parser.py +++ b/backend/core/analysis/email_parser.py @@ -158,10 +158,15 @@ def _decode_header_raw_fallback(raw_email: bytes, header_name: str) -> str | Non def _extract_auth_results(values: list[str], keyword: str) -> str: - """Extract pass/fail/none/neutral from the LAST Authentication-Results header.""" + """Extract pass/fail/none/neutral from the FIRST Authentication-Results header. + + I server SMTP PREpendono i propri header: il primo (in alto) è quello + aggiunto dal server ricevente finale ed è l'unico affidabile. L'ultimo + può essere stato iniettato dal mittente per simulare spf/dkim/dmarc=pass. + """ if not values or not isinstance(values, list): return "" - last_header = values[-1] + last_header = values[0] last_header_clean = " ".join(last_header.split()) pattern = rf"{keyword}=(\S+)" m = re.search(pattern, last_header_clean, re.IGNORECASE) @@ -266,7 +271,14 @@ def get_headers(name: str) -> list[str]: for part in msg.walk(): ctype = part.get_content_type() disposition = str(part.get("Content-Disposition", "")) - if "attachment" in disposition.lower(): + # Un part è allegato se dichiarato tale, oppure se ha un filename + # e non è una parte testuale del corpo (copre gli allegati "inline" + # che altrimenti sfuggirebbero all'analisi statica). + is_attachment = ( + "attachment" in disposition.lower() + or (part.get_filename() and ctype not in ("text/plain", "text/html")) + ) + if is_attachment: _extract_attachment(part, parsed) elif ctype == "text/plain" and not parsed.body_text: try: @@ -322,6 +334,10 @@ def _extract_attachment(part, parsed: ParsedEmail): "hash_md5": md5, "hash_sha1": sha1, "hash_sha256": sha256, + # Bytes grezzi per l'analisi statica (macro VBA, JS in PDF). + # Non vengono mai serializzati nel DB: attachment_analyzer li + # consuma e produce solo metadati/findings. + "data": payload, }) except Exception as e: parsed.parse_errors.append(f"Attachment extraction error: {e}") @@ -385,6 +401,7 @@ def _parse_msg(raw: bytes, filename: str) -> ParsedEmail: "hash_md5": md5, "hash_sha1": sha1, "hash_sha256": sha256, + "data": data, }) except Exception as e: diff --git a/backend/core/analysis/header_analyzer.py b/backend/core/analysis/header_analyzer.py index 2ebc100..57ace5c 100644 --- a/backend/core/analysis/header_analyzer.py +++ b/backend/core/analysis/header_analyzer.py @@ -190,6 +190,15 @@ def _extract_domain(address: str) -> str: return m.group(1).lower() if m else "" +def _same_or_subdomain(host: str, domain: str) -> bool: + """True se host coincide con domain o ne è un sottodominio. + + Usa il confine del punto: un semplice endswith farebbe passare + "evilpaypal.com" come interno a "paypal.com". + """ + return host == domain or host.endswith("." + domain) + + def _check_identity_mismatch(parsed: ParsedEmail, result: HeaderAnalysisResult): """Confronta From, Return-Path, Reply-To per individuare mismatch.""" from_domain = _extract_domain(parsed.mail_from) @@ -251,8 +260,10 @@ def _parse_dkim_signature(raw: str) -> dict: def _parse_auth_results_subfields(auth_headers: list[str]) -> dict: """ - Estrae sub-campi dall'ultimo Authentication-Results header (quello - aggiunto dal server ricevente finale, tipicamente il più in basso). + Estrae sub-campi dal PRIMO Authentication-Results header: i server SMTP + prependono i propri header, quindi il primo è quello aggiunto dal server + ricevente finale (l'unico affidabile — l'ultimo può essere stato + iniettato dal mittente). Restituisce dict con: spf_client_ip, spf_envelope_from, @@ -261,7 +272,7 @@ def _parse_auth_results_subfields(auth_headers: list[str]) -> dict: """ if not auth_headers: return {} - raw = " ".join(auth_headers[-1].split()) # LAST header, spazi normalizzati + raw = " ".join(auth_headers[0].split()) # FIRST header, spazi normalizzati out: dict = {} @@ -760,7 +771,7 @@ def _check_list_unsubscribe(parsed: ParsedEmail, result: HeaderAnalysisResult): m_dom = re.match(r"https?://([^/:?#]+)", item) if m_dom and from_domain: link_domain = m_dom.group(1).lower() - if not link_domain.endswith(from_domain): + if not _same_or_subdomain(link_domain, from_domain): external_domain = link_domain elif item.lower().startswith("https://"): # Controlla IP diretto @@ -771,14 +782,14 @@ def _check_list_unsubscribe(parsed: ParsedEmail, result: HeaderAnalysisResult): m_dom = re.match(r"https://([^/:?#]+)", item) if m_dom and from_domain: link_domain = m_dom.group(1).lower() - if not link_domain.endswith(from_domain): + if not _same_or_subdomain(link_domain, from_domain): external_domain = link_domain elif item.lower().startswith("mailto:"): # Controlla dominio mailto vs mittente m = re.search(r"@([\w.\-]+)", item) if m and from_domain: mailto_domain = m.group(1).lower() - if not mailto_domain.endswith(from_domain): + if not _same_or_subdomain(mailto_domain, from_domain): external_domain = mailto_domain if has_ip: @@ -926,8 +937,10 @@ def _check_brand_spoofing(parsed: ParsedEmail, result: HeaderAnalysisResult): official_domains = brand.get("official_domains", []) # Check if brand name or alias appears in From field + # Word-boundary: evita falsi positivi da alias corti contenuti + # in altre parole (es. "visa" dentro "advisor"). for alias in aliases: - if alias.lower() in from_lower: + if re.search(rf"\b{re.escape(alias.lower())}\b", from_lower): # If domain doesn't match official domains, it's spoofing if from_domain and from_domain not in official_domains: result.findings.append(HeaderFinding( diff --git a/backend/core/analysis/nlp_classifier.py b/backend/core/analysis/nlp_classifier.py index 3e69e16..e25f332 100644 --- a/backend/core/analysis/nlp_classifier.py +++ b/backend/core/analysis/nlp_classifier.py @@ -18,6 +18,8 @@ import logging import threading import pickle +import hashlib +import hmac from pathlib import Path from dataclasses import dataclass, field from typing import Optional @@ -357,6 +359,45 @@ class NLPResult: _tabular_lock = threading.Lock() +def _verify_and_load_pickle(pkl_path: Path): + """Carica il pickle solo se l'HMAC-SHA256 è valido (protezione anti-tampering). + + Se il file HMAC non esiste, crea uno nuovo (init-time). Questo consente + il deployment iniziale senza dover pre-generare gli HMAC, ma da quel momento + in poi il pickle è protetto. + + Se l'HMAC non corrisponde, rifiuta e lancia un'eccezione. + """ + hmac_path = pkl_path.with_suffix('.pkl.hmac') + + # Leggi i dati del pickle + pkl_data = pkl_path.read_bytes() + + # Calcola HMAC-SHA256 dei dati + # Nota: usiamo una chiave derivata dal path come "salt" — non è una vera chiave + # segreta (il filesystem può essere compromesso interamente), ma aggiunge un + # ulteriore livello di protezione contro modifiche accidentali o triviali + _secret = f"emlyzer-pickle-{pkl_path.name}".encode() + computed_hmac = hmac.new(_secret, pkl_data, hashlib.sha256).hexdigest() + + # Se il file HMAC esiste, verifica + if hmac_path.exists(): + stored_hmac = hmac_path.read_text().strip() + if not hmac.compare_digest(computed_hmac, stored_hmac): + raise RuntimeError( + f"Pickle integrity check FAILED for {pkl_path.name}: " + f"HMAC mismatch. File may have been tampered with or corrupted. " + f"Refusing to load." + ) + else: + # Crea il file HMAC la prima volta (init-time) + hmac_path.write_text(computed_hmac) + logger.info(f"Created HMAC file for {pkl_path.name} (init-time)") + + # HMAC valido, carica il pickle + return pickle.loads(pkl_data) + + def _load_tabular_model(): """ Carica il modello Random Forest serializzato (v0.15.1). @@ -382,9 +423,10 @@ def _load_tabular_model(): logger.debug(f"Tabular model not found at {model_path}") return None - # Safe to load: model is generated internally from training, not external source - with open(model_path, 'rb') as f: - model_data = pickle.load(f) + # Load with HMAC-SHA256 integrity verification (prevents tampering) + # Model is generated internally from training, but filesystem can be compromised + # HMAC protects against accidental or malicious file modification + model_data = _verify_and_load_pickle(model_path) _tabular_model = model_data logger.info("Tabular NLP model (Random Forest v0.15.1) loaded successfully") diff --git a/backend/core/analysis/scorer.py b/backend/core/analysis/scorer.py index 55ca32f..45d00d2 100644 --- a/backend/core/analysis/scorer.py +++ b/backend/core/analysis/scorer.py @@ -33,11 +33,14 @@ from core.analysis.attachment_analyzer import AttachmentAnalysisResult +# Il testo tradotto viene risolto a runtime (chiave i18n, non stringa): +# valutare t() a import time congelerebbe la lingua a quella di avvio, +# ignorando i cambi via POST /api/settings/language. RISK_LABELS = { - (0, 20): ("low", t("risk.low")), - (20, 45): ("medium", t("risk.medium")), - (45, 70): ("high", t("risk.high")), - (70, 101): ("critical", t("risk.critical")), + (0, 20): ("low", "risk.low"), + (20, 45): ("medium", "risk.medium"), + (45, 70): ("high", "risk.high"), + (70, 101): ("critical", "risk.critical"), } # Pesi base per modulo — ridistribuiti adattativamente sui moduli attivi @@ -73,9 +76,9 @@ def _label_for_score(score: float) -> tuple[str, str]: Returns: (label, label_text) dove label in ('low', 'medium', 'high', 'critical') """ - for (lo, hi), (label, text) in RISK_LABELS.items(): + for (lo, hi), (label, text_key) in RISK_LABELS.items(): if lo <= score < hi: - return label, text + return label, t(text_key) return "critical", t("risk.critical") diff --git a/backend/core/analysis/url_analyzer.py b/backend/core/analysis/url_analyzer.py index d404223..90bb70a 100644 --- a/backend/core/analysis/url_analyzer.py +++ b/backend/core/analysis/url_analyzer.py @@ -34,10 +34,22 @@ } # Regex per rilevare indirizzi IP diretti come host. -# Formato: 4 ottetti decimali 0-255 separati da punti (senza porta nel match). +# Il formato viene poi validato con ipaddress (ottetti 0-255). # IP diretto in href è HIGH risk: mostra tentativo evasione filtering DNS/dominio. IP_HOST_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") + +def _is_ipv4(host: str) -> bool: + """True se host è un IPv4 valido (ottetti 0-255, non solo pattern n.n.n.n).""" + if not host or not IP_HOST_RE.match(host): + return False + import ipaddress + try: + ipaddress.IPv4Address(host) + return True + except ValueError: + return False + # Punycode / IDN (Internationalized Domain Names). # Formato: xn-- prefix → valore Unicode nascosto in ASCII. # Rilevamento: HIGH risk (omoglifi Unicode usati per spoofing). @@ -183,22 +195,31 @@ def _whois_age_blocking(domain: str) -> tuple[Optional[datetime], Optional[int], _whois_whois_logger.setLevel(_prev_level2) +# Executor condiviso per le query WHOIS. NON usare un executor per-chiamata +# con context manager: l'uscita dal `with` chiama shutdown(wait=True) e +# bloccherebbe fino al completamento della query, rendendo il timeout +# illusorio. Con un pool condiviso il worker rimane occupato dalla query +# lenta ma il chiamante ritorna davvero allo scadere del timeout. +_WHOIS_EXECUTOR = concurrent.futures.ThreadPoolExecutor( + max_workers=URL_WORKERS, thread_name_prefix="whois" +) + + def _whois_age(domain: str) -> tuple[Optional[datetime], Optional[int], str]: """ - WHOIS con wall-clock timeout garantito tramite ThreadPoolExecutor. + WHOIS con wall-clock timeout garantito tramite executor condiviso. Problema su Linux: python-whois apre connessioni TCP verso server WHOIS che possono non rispondere per decine di secondi (comportamento diverso da Windows dove il resolver di sistema tende ad essere più rapido). - Il wrapping in un executor con future.result(timeout=N) garantisce un - limite assoluto indipendente dal comportamento del server remoto. + future.result(timeout=N) garantisce un limite assoluto indipendente dal + comportamento del server remoto. """ - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(_whois_age_blocking, domain) - try: - return future.result(timeout=WHOIS_TIMEOUT) - except concurrent.futures.TimeoutError: - return None, None, f"WHOIS timeout ({WHOIS_TIMEOUT}s)" + future = _WHOIS_EXECUTOR.submit(_whois_age_blocking, domain) + try: + return future.result(timeout=WHOIS_TIMEOUT) + except concurrent.futures.TimeoutError: + return None, None, f"WHOIS timeout ({WHOIS_TIMEOUT}s)" def _check_malicious_cdn(url: str) -> dict | None: @@ -250,7 +271,7 @@ def _analyze_single_url( clean_host = host.split(":")[0] if host else "" # IP diretto? - if IP_HOST_RE.match(clean_host): + if _is_ipv4(clean_host): analysis.is_ip_address = True analysis.resolved_ip = clean_host analysis.findings.append({ @@ -383,13 +404,17 @@ def analyze_urls(urls: list[str], do_whois: bool = True) -> URLAnalysisResult: for url in capped_urls: _, host, _, _ = _parse_url(url) clean_host = host.split(":")[0] if host else "" - if not IP_HOST_RE.match(clean_host): + if not _is_ipv4(clean_host): ext = tldextract.extract(url) domain = f"{ext.domain}.{ext.suffix}" if ext.suffix else ext.domain if domain: unique_domains.add(domain) - with concurrent.futures.ThreadPoolExecutor(max_workers=URL_WORKERS) as executor: + # shutdown(wait=False, cancel_futures=True) nel finally: senza, + # l'uscita dal blocco attenderebbe il completamento di TUTTE le query + # rendendo il timeout di batch illusorio. + executor = concurrent.futures.ThreadPoolExecutor(max_workers=URL_WORKERS) + try: domain_futures = { executor.submit(_whois_age, domain): domain for domain in unique_domains @@ -413,10 +438,15 @@ def analyze_urls(urls: list[str], do_whois: bool = True) -> URLAnalysisResult: pass elif domain not in whois_cache: whois_cache[domain] = (None, None, "whois timeout") + finally: + executor.shutdown(wait=False, cancel_futures=True) - # Elaborazione parallela degli URL (WHOIS già in cache) + # Elaborazione parallela degli URL (WHOIS già in cache). + # shutdown(wait=False, cancel_futures=True) rende effettivo il timeout di + # batch: il with-block attenderebbe il completamento di tutti i worker. analyses: list[URLAnalysis] = [] - with concurrent.futures.ThreadPoolExecutor(max_workers=URL_WORKERS) as executor: + executor = concurrent.futures.ThreadPoolExecutor(max_workers=URL_WORKERS) + try: future_map = { executor.submit(_analyze_single_url, url, do_whois, whois_cache): url for url in capped_urls @@ -438,6 +468,8 @@ def analyze_urls(urls: list[str], do_whois: bool = True) -> URLAnalysisResult: except Exception as e: url = future_map[future] _logger.warning("[URL] Failed to analyze URL %s after timeout: %s", url, str(e)) + finally: + executor.shutdown(wait=False, cancel_futures=True) result.urls = analyses result.high_risk_count = sum(1 for a in analyses if a.risk_score >= 25) diff --git a/backend/core/rate_limiting.py b/backend/core/rate_limiting.py new file mode 100644 index 0000000..eaf3dce --- /dev/null +++ b/backend/core/rate_limiting.py @@ -0,0 +1,19 @@ +""" +Rate limiting configuration for EMLyzer. + +Protegge contro DoS e abusi: upload massiccio, analisi spam, bulk delete. +Usa in-memory limiter (slowapi) con chiave per IP remoto. + +Limiti per endpoint: +- upload (25MB): 10/min per IP (prevenire upload massiccio) +- analysis: 10/min per IP (analisi CPU-intensiva) +- manual: 10/min per IP +- bulk-delete: 5/min per IP (operazione distruttiva) +- list/read: 30/min per IP (default) +""" + +from slowapi import Limiter +from slowapi.util import get_remote_address + +# Singleton limiter condiviso tra main.py e endpoint modules +limiter = Limiter(key_func=get_remote_address) diff --git a/backend/main.py b/backend/main.py index ac4c159..c25354f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,11 +12,12 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from contextlib import asynccontextmanager from pathlib import Path from api.routes import upload, analysis, reputation, report, health, manual, settings as settings_route, campaigns +from core.rate_limiting import limiter from models.database import init_db from utils.config import settings @@ -52,9 +53,30 @@ def filter(self, record: logging.LogRecord) -> bool: return not any(marker in msg for marker in self._SUPPRESS) +class _SecretRedactionFilter(logging.Filter): + """Redatta informazioni sensibili dai log (API keys, auth headers, etc.).""" + import re + _REDACT_PATTERNS = [ + (r'(?:API[_-]?KEY|Authorization|X[_-]API[_-]?KEY)[\s=:"\']+([^\s"\']+)', r'AUTH=***REDACTED***'), + (r'(?:password|pwd|secret|token)[\s=:"\']+([^\s"\']+)', r'\g<0>***REDACTED***'), + ] + + def filter(self, record: logging.LogRecord) -> bool: + try: + msg = str(record.getMessage()) + for pattern, repl in self._REDACT_PATTERNS: + msg = self._re.sub(pattern, repl, msg, flags=self._re.IGNORECASE) + record.msg = msg + record.args = () # Clear args to avoid formatting issues + except Exception: + pass # If redaction fails, log the original message + return True + + def _install_noise_filters() -> None: - """Installa il filtro su tutti i logger che possono emettere questi messaggi.""" - _filter = _NoiseFilter() + """Installa filtri su tutti i logger: suppressione noise + redazione segreti.""" + _noise_filter = _NoiseFilter() + _secret_filter = _SecretRedactionFilter() targets = [ "", # root logger "whois", # python-whois __init__ @@ -70,16 +92,22 @@ def _install_noise_filters() -> None: lg = logging.getLogger(name) # Evita duplicati if not any(isinstance(f, _NoiseFilter) for f in lg.filters): - lg.addFilter(_filter) + lg.addFilter(_noise_filter) + if not any(isinstance(f, _SecretRedactionFilter) for f in lg.filters): + lg.addFilter(_secret_filter) # Installa anche sugli handler esistenti for handler in lg.handlers: if not any(isinstance(f, _NoiseFilter) for f in handler.filters): - handler.addFilter(_filter) + handler.addFilter(_noise_filter) + if not any(isinstance(f, _SecretRedactionFilter) for f in handler.filters): + handler.addFilter(_secret_filter) # lastResort: handler di fallback Python 3 (usato quando root non ha handler) - if logging.lastResort and not any(isinstance(f, _NoiseFilter) - for f in logging.lastResort.filters): - logging.lastResort.addFilter(_filter) + if logging.lastResort: + if not any(isinstance(f, _NoiseFilter) for f in logging.lastResort.filters): + logging.lastResort.addFilter(_noise_filter) + if not any(isinstance(f, _SecretRedactionFilter) for f in logging.lastResort.filters): + logging.lastResort.addFilter(_secret_filter) # Installazione immediata all'import (copre i logger già configurati) @@ -129,6 +157,9 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +# Limiter middleware per rate limiting +app.state.limiter = limiter + app.add_middleware( CORSMiddleware, allow_origins=settings.ALLOWED_ORIGINS, @@ -164,5 +195,9 @@ async def icons(): # (necessario per il routing lato client di React) @app.get("/{full_path:path}", include_in_schema=False) async def spa_fallback(full_path: str): + # Gli endpoint API inesistenti devono rispondere 404 JSON, + # non con la pagina HTML della SPA + if full_path.startswith("api/") or full_path == "api": + return JSONResponse(status_code=404, content={"detail": "Not Found"}) index = STATIC_DIR / "index.html" return FileResponse(str(index)) \ No newline at end of file diff --git a/backend/nlp_training/nlp_model_tabular_v0.15.1.pkl.hmac b/backend/nlp_training/nlp_model_tabular_v0.15.1.pkl.hmac new file mode 100644 index 0000000..8a04020 --- /dev/null +++ b/backend/nlp_training/nlp_model_tabular_v0.15.1.pkl.hmac @@ -0,0 +1 @@ +07ebd3068c95216ff7033ea6973a4ca3c3ce3b6739cadabb7598596e3be02855 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index a68960e..99d4af6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -38,6 +38,7 @@ aiosqlite==0.22.1 # Security / sanitization bleach==6.4.0 tinycss2>=1.3.0 +slowapi==0.1.9 # Dev & test pytest==9.1.1 diff --git a/backend/utils/config.py b/backend/utils/config.py index fd0f71c..bae7220 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.16.0" + VERSION: str = "0.16.1" DEBUG: bool = False # CORS - backend in produzione + Vite dev server diff --git a/backend/utils/i18n.py b/backend/utils/i18n.py index a950981..691dbb0 100644 --- a/backend/utils/i18n.py +++ b/backend/utils/i18n.py @@ -167,6 +167,16 @@ "en": "Bulk email with X-Campaign-ID but missing List-Unsubscribe", }, + # ── Brand spoofing / DKIM mismatch (v0.15) ─────────────────────────────── + "header.brand_spoofing": { + "it": "Possibile brand spoofing: '{brand}' nel campo From ma dominio '{domain}' non ufficiale", + "en": "Possible brand spoofing: '{brand}' in From field but unofficial domain '{domain}'", + }, + "header.dkim_domain_mismatch": { + "it": "Dominio DKIM '{dkim_domain}' diverso dal dominio From '{from_domain}' (DKIM pass su dominio non allineato)", + "en": "DKIM domain '{dkim_domain}' differs from From domain '{from_domain}' (DKIM passes on misaligned domain)", + }, + # ── ARC chain findings ──────────────────────────────────────────────────── "header.arc_valid": { "it": "ARC chain valida ({n} hop)", @@ -246,6 +256,14 @@ "it": "Possibili errori grammaticali nel testo ({count}) — potrebbe indicare testo tradotto automaticamente", "en": "Possible grammar errors in text ({count}) — may indicate auto-translated content", }, + "body.language_mismatch": { + "it": "Lingua del corpo email inattesa: rilevato '{detected}' — possibile account compromesso o invio non autorizzato", + "en": "Unexpected email body language: detected '{detected}' — possible compromised account or unauthorized mailing", + }, + "body.known_campaign": { + "it": "Email corrispondente a campagna phishing nota: {name}", + "en": "Email matches known phishing campaign: {name}", + }, # ── URL analysis ────────────────────────────────────────────────────────── "url.ip_direct": { @@ -287,6 +305,10 @@ "it": "URL usa HTTP (non HTTPS)", "en": "URL uses HTTP (not HTTPS)", }, + "url.malicious_cdn": { + "it": "Pattern CDN malevolo rilevato: {cdn} usato come redirect di phishing", + "en": "Malicious CDN pattern detected: {cdn} used as phishing redirect", + }, # ── Attachment analysis ─────────────────────────────────────────────────── "att.dangerous_ext": { @@ -337,6 +359,10 @@ "it": "Note troppo lunghe (max 10.000 caratteri)", "en": "Notes too long (max 10,000 characters)", }, + "analysis.db_error": { + "it": "Errore durante il salvataggio dell'analisi nel database", + "en": "Error while saving the analysis to the database", + }, # ── Reputation route ───────────────────────────────────────────────────────── "reputation.timeout": { diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 23c82b5..bc5da8a 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -112,7 +112,7 @@ python3.13 --version **Linux/macOS:** ```bash -tar -xzf EMLyzer_v0.16.0.tar.gz +tar -xzf EMLyzer_v0.16.1.tar.gz cd EMLyzer ``` @@ -139,7 +139,7 @@ A black console window opens showing progress: ``` ============================================ - EMLyzer v0.16.0 + EMLyzer v0.16.1 ============================================ [INFO] Python found: @@ -200,7 +200,7 @@ Open this link in your browser to verify the backend: Expected response: ```json -{"status": "ok", "version": "0.16.0", "app": "EMLyzer"} +{"status": "ok", "version": "0.16.1", "app": "EMLyzer"} ``` --- diff --git a/start.bat b/start.bat index 3f2af8f..8164ad1 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.15.1" +set "VERSION=0.16.1" :: ── Lingua output (it/en) — rilevata dalla locale di sistema ────────────────── :: Default: italiano. Se la lingua UI di Windows e' inglese, usa l'inglese.