Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
version: 2

# Dependabot auto-creates PRs against `develop` (not `main`).
# `main` is reserved for releases and is protected — all updates flow through develop.
# Gli aggiornamenti di versione puntano a `develop`. GitHub forza invece le PR di
# sicurezza Dependabot sul branch predefinito (`main`): vanno replicate su `develop`,
# validate e promosse con la normale PR develop -> main; la PR bot viene poi chiusa.
# Configuration strategy:
# - production deps: minor/patch updates auto-applied (fasttrack)
# - dev deps: only minor/patch (no majors — prevents breaking CI changes)
Expand Down
11 changes: 5 additions & 6 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,16 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
with:
languages: ${{ matrix.language }}

- name: Set up Python (for Python analysis)
if: matrix.language == 'python'
uses: actions/setup-python@v7
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.13'
cache: 'pip'
Expand All @@ -49,7 +49,7 @@ jobs:

- name: Set up Node.js (for JavaScript analysis)
if: matrix.language == 'javascript-typescript'
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '20'
cache: 'npm'
Expand All @@ -60,7 +60,6 @@ jobs:
run: cd frontend && npm ci

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
with:
category: "/language:${{matrix.language}}"
upload: ${{ github.ref == 'refs/heads/main' }}
2 changes: 1 addition & 1 deletion .github/workflows/project-automation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:

steps:
- name: "Checkout repository (for file detection)"
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0

Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Set up Python 3.13
uses: actions/setup-python@v7
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.13'
cache: 'pip'
Expand All @@ -56,15 +56,15 @@ jobs:

- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: pytest-results
path: backend/pytest-results.xml
retention-days: 7

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: coverage-report
path: backend/coverage.xml
Expand All @@ -86,10 +86,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '20'
cache: 'npm'
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,52 @@ Features are ordered by implementation priority.

---

## [0.17.3] — 2026-10-25

### Security

- Frontend dependency fixes: `source-map-js` 1.2.1 → 1.2.2, Vite 8.3.1 →
8.3.5, and `follow-redirects` 1.16.0 → 1.16.1 resolve all nine open
Dependabot alerts (denial of service, development-server command/path issues,
and cross-origin credential-header disclosure).
- Updated transitive development dependency Zod 4.3.6 → 4.6.7, resolving an
additional high-severity memory-exhaustion advisory detected by `npm audit`
before GitHub opened a repository alert.
- The URLScan diagnostic endpoint now requires a loopback client or the admin
token, is limited to two requests per minute, and performs its blocking HTTP
diagnostics in a worker thread instead of blocking FastAPI's event loop.
- Redirect-chain analysis now validates the initial URL and every redirect hop,
blocks loopback/private/link-local/reserved destinations, limits redirect
depth, and removes credentials and query tokens from its result detail.
- Replaced the ineffective model-file HMAC (whose public, filename-derived key
provided no authenticity) with a release-anchored SHA-256 digest checked
before `pickle.loads`. Retraining now prints the digest that must be reviewed.
- Pinned every GitHub Action to a reviewed full commit SHA. CodeQL now uploads
SARIF on pull requests instead of reporting a green check without publishing
its findings.

### Fixed

- Reputation hostname extraction now uses `urlparse().hostname` and an exact
`www.` prefix removal. Hosts such as `web.example.com`, URLs with userinfo or
ports, and IPv6 literals are no longer mangled before URLScan, redirect-chain,
crt.sh, CIRCL or SecurityTrails checks.
- Reputation and URL-analysis logs no longer record full URLs, avoiding leakage
of userinfo, query tokens and fragments into application logs.
- Corrected the Dependabot configuration comments: security updates always
target GitHub's default branch, so they must be reproduced and validated on
permanent `develop` before promotion to `main`.

### Tests

- Added regression coverage for hostname normalization and trusted-model digest
validation.
- Backend suite: 213 passed, 1 skipped without the optional RTFDE package.
- `npm audit` and `npm audit --omit=dev` report zero vulnerabilities; the Python
requirements report no known vulnerabilities with `pip-audit`.

---

## [0.17.2] — 2026-10-02

### Fixed
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ Email (.eml / .msg / plain text)

## 🔧 Version

**v0.17.3** — 🔒 Security and correctness release: resolves the open frontend dependency advisories for Vite, `source-map-js`, `follow-redirects` and Zod; protects the URLScan diagnostic endpoint; blocks SSRF in redirect-chain analysis; anchors the bundled NLP model to a reviewed SHA-256 digest; fixes hostname extraction in reputation checks; and restores CodeQL result uploads on pull requests while pinning GitHub Actions to full commit SHAs. The suite contains 214 tests (213 pass locally and 1 optional RTFDE test is skipped).

**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`.
Expand Down Expand Up @@ -216,5 +218,5 @@ Distributed with MIT License · [View License](LICENSE)

---

*Last updated: 2026-09-15*
*← [Contributing](./CONTRIBUTING.md) | [Docs →](./docs/)*
*Last updated: 2026-10-25*
*← [Contributing](./CONTRIBUTING.md) | [Docs →](./docs/)*
37 changes: 21 additions & 16 deletions backend/api/routes/reputation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import asyncio
import ipaddress
import logging
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Request
from sqlalchemy.ext.asyncio import AsyncSession

from sqlalchemy.orm.attributes import flag_modified
Expand All @@ -35,7 +35,10 @@
run_slow_checks,
finalize_fast_only,
ReputationSummary,
normalize_url_host,
)
from core.intel.auth import require_trusted_client
from core.rate_limiting import limiter
from dataclasses import asdict
import json

Expand Down Expand Up @@ -179,8 +182,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].removeprefix("www.")
hostname = normalize_url_host(url)
except Exception:
hostname = ""

Expand All @@ -193,7 +195,7 @@ def add_url(raw: str, is_suspicious: bool = False) -> None:

# Altrimenti, escludi URL da CDN/trusted domains
if _is_trusted_cdn(hostname):
_logger.debug(f"[FILTRO] URL {url} da CDN legittima - escluso")
_logger.debug("[FILTRO] Host %s da CDN legittima - escluso", hostname)
return

seen_urls.add(url)
Expand All @@ -211,8 +213,7 @@ def add_domain(raw: str) -> None:
if not raw:
return
try:
from urllib.parse import urlparse
hostname = urlparse(raw).netloc.split(":")[0].removeprefix("www.")
hostname = normalize_url_host(raw)
# 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):
Expand Down Expand Up @@ -370,13 +371,12 @@ 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].removeprefix("www.")
hostname = normalize_url_host(url_str)
except Exception:
hostname = ""

if _is_trusted_cdn(hostname):
_logger.debug(f"[FILTRO SLOW] URL {url_str} da trusted CDN - esclusa")
_logger.debug("[FILTRO SLOW] Host %s da trusted CDN - escluso", hostname)
return

# Determina se è sospetta (per logging)
Expand All @@ -394,9 +394,9 @@ def add_url_if_worth_checking(u: dict) -> None:
seen_urls.add(url_str)
urls.append(url_str)
if is_suspicious:
_logger.debug(f"[FILTRO SLOW] URL {url_str} (sospetta, risk_score={u.get('risk_score', 0)}) - inclusa")
_logger.debug("[FILTRO SLOW] Host %s sospetto (risk_score=%s) incluso", hostname, u.get("risk_score", 0))
else:
_logger.debug(f"[FILTRO SLOW] URL {url_str} (normale, non da CDN) - inclusa per coverage")
_logger.debug("[FILTRO SLOW] Host %s non-CDN incluso per coverage", hostname)

# ─────────────────────────────────────────────────────────────────────────
# IP: Sender IP + primi 2 hop (mittente -> intermediari prossimi)
Expand Down Expand Up @@ -451,8 +451,7 @@ def add_url_if_worth_checking(u: dict) -> None:
url_str = u.get("original_url") or u.get("url", "")
if url_str:
try:
from urllib.parse import urlparse
hostname = urlparse(url_str).netloc.split(":")[0].removeprefix("www.")
hostname = normalize_url_host(url_str)
is_suspicious = (
u.get("is_ip_address") or u.get("is_ip") or
u.get("is_shortener") or u.get("is_new_domain") or
Expand Down Expand Up @@ -667,8 +666,7 @@ def to_result(r: dict) -> ReputationResult:
# Phase 2C: Health Check Endpoint per URLScan.io (v0.14.3+)
# ---------------------------------------------------------------------------

@router.get("/test-urlscan")
async def test_urlscan_health():
def _test_urlscan_health_sync() -> dict:
"""
Testa la connessione a URLScan.io e verifica la configurazione dell'API key.
Utile per diagnosticare problemi di autenticazione o rate limiting.
Expand Down Expand Up @@ -736,4 +734,11 @@ async def test_urlscan_health():
else:
result["suggestions"].append("URLSCAN_API_KEY non configurato in .env. Registrati su https://urlscan.io/user/signup e configura la chiave per aumentare il limite a 1000 req/giorno")

return result
return result


@router.get("/test-urlscan", dependencies=[Depends(require_trusted_client)])
@limiter.limit("2/minute")
async def test_urlscan_health(request: Request):
"""Esegue la diagnostica URLScan fuori dall'event loop per client fidati."""
return await asyncio.to_thread(_test_urlscan_health_sync)
57 changes: 13 additions & 44 deletions backend/core/analysis/nlp_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import threading
import pickle
import hashlib
import hmac
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
Expand Down Expand Up @@ -357,55 +356,28 @@ class NLPResult:

_tabular_model = None
_tabular_lock = threading.Lock()
_TABULAR_MODEL_SHA256 = "ee129c78d7ee9347087bf20b24cdd4e58c34f705e87435f0e56465951276304e"


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
def _verify_and_load_pickle(pkl_path: Path, expected_sha256: str):
"""Carica un pickle solo se coincide con il digest della release fidata."""
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
computed_sha256 = hashlib.sha256(pkl_data).hexdigest()
if computed_sha256 != expected_sha256:
raise RuntimeError(
f"Pickle integrity check FAILED for {pkl_path.name}: "
"SHA-256 mismatch. Refusing to deserialize an untrusted model."
)
return pickle.loads(pkl_data)


def _load_tabular_model():
"""
Carica il modello Random Forest serializzato (v0.15.1).

SECURITY NOTE: The pickle file is generated internally by nlp_retrain_tabular_model.py
during the training process. It is NOT loaded from untrusted sources, making it safe
to deserialize. The model contains only a trained sklearn RandomForestClassifier and
StandardScaler with no arbitrary code execution risk.
SECURITY NOTE: pickle can execute code while deserializing. The bundled model is
loaded only after matching the SHA-256 digest anchored in this module. When the
model is intentionally retrained, the digest must be reviewed and updated together.
"""
global _tabular_model

Expand All @@ -423,10 +395,7 @@ def _load_tabular_model():
logger.debug(f"Tabular model not found at {model_path}")
return None

# 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)
model_data = _verify_and_load_pickle(model_path, _TABULAR_MODEL_SHA256)

_tabular_model = model_data
logger.info("Tabular NLP model (Random Forest v0.15.1) loaded successfully")
Expand Down
Loading
Loading