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
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ updates:
- "dependencies"
- "lang:python"
- "scope:backend"
assignees:
- "overwrite00"
commit-message:
prefix: "chore(deps-backend)"
include: "scope"
Expand Down Expand Up @@ -60,6 +62,8 @@ updates:
- "dependencies"
- "lang:javascript"
- "scope:frontend"
assignees:
- "overwrite00"
commit-message:
prefix: "chore(deps-frontend)"
include: "scope"
Expand Down Expand Up @@ -113,6 +117,8 @@ updates:
- "dependencies"
- "type:ci"
- "scope:deps"
assignees:
- "overwrite00"
commit-message:
prefix: "chore(ci)"
include: "scope"
Expand Down
74 changes: 50 additions & 24 deletions .github/workflows/project-automation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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() {
Expand Down Expand Up @@ -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..."
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 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.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.
Expand Down
31 changes: 23 additions & 8 deletions backend/core/analysis/email_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}")

Expand Down
Loading