From 3cbd61eed024cbe670291eb02bc9b5b024cf165d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:53:30 +0200 Subject: [PATCH 1/6] chore(deps-frontend)(deps): bump lucide-react (#194) Bumps the minor-and-patch group in /frontend with 1 update: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react). Updates `lucide-react` from 1.47.0 to 1.48.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.48.0/packages/lucide-react) --- updated-dependencies: - dependency-name: lucide-react dependency-version: 1.48.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 12aee27..32d3d7c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "0.17.1", "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" @@ -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" diff --git a/frontend/package.json b/frontend/package.json index f2e19a6..04a2ac2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" From 3bcc8d40ecaece0e1acdcb7dadc9b805e1a56bb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:59:37 +0200 Subject: [PATCH 2/6] chore(deps-frontend)(deps-dev): bump vite (#195) Bumps the development-updates group with 1 update in the /frontend directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `vite` from 8.3.0 to 8.3.1 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.3.1/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 146 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 74 insertions(+), 74 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 32d3d7c..6d2d4af 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" ], @@ -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 04a2ac2..bc5d93d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } From 6830435aac175d167f247fd54249155ce16a571a Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:11:17 +0200 Subject: [PATCH 3/6] fix(deps-frontend): update brace-expansion to resolve high severity advisories (#196) npm audit fix bumps the transitive dev dependency brace-expansion past 5.0.11, fixing three DoS advisories (GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p). Lockfile-only change. --- frontend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d2d4af..e8ff6f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": { From e065d055ec48862306d3fef25bdfcc5b4cbf289c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:14:23 +0200 Subject: [PATCH 4/6] chore(deps-backend)(deps): bump the minor-and-patch group in /backend with 3 updates (#193) * chore(deps-backend)(deps): bump the minor-and-patch group Bumps the minor-and-patch group in /backend with 3 updates: [uvicorn](https://github.com/Kludex/uvicorn), [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) and [coverage](https://github.com/coveragepy/coveragepy). Updates `uvicorn` from 0.53.0 to 0.54.0 - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/uvicorn/compare/0.53.0...0.54.0) Updates `sqlalchemy` from 2.0.54 to 2.1.1 - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) Updates `coverage` from 7.16.1 to 7.16.2 - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.16.1...7.16.2) --- updated-dependencies: - dependency-name: uvicorn dependency-version: 0.54.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: sqlalchemy dependency-version: 2.1.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: coverage dependency-version: 7.16.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] * fix(deps): require sqlalchemy[asyncio] extra for SQLAlchemy 2.1 SQLAlchemy 2.1 no longer installs greenlet by default, which broke sqlalchemy.ext.asyncio at import time. Use the asyncio extra and update docs/REQUIREMENTS.md accordingly. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 0verwrite <31691645+overwrite00@users.noreply.github.com> --- backend/requirements.txt | 7 ++++--- docs/REQUIREMENTS.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) 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/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 4aa75ed..cdeef78 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -148,7 +148,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 From 68adf4090c21b44b13a0576c4d3e9021be9416bb Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:18:31 +0200 Subject: [PATCH 5/6] fix(ci): use the workflow token for PR and issue edits in project automation (#198) * fix(ci): use the workflow token for PR and issue edits in project automation The step ran every gh command with GH_PROJECT_TOKEN, a token scoped to Projects, so assignee, reviewer and label edits failed on every pull request. Their errors were discarded by 2>/dev/null and replaced by a generic 'skipped' message, so the failure went unnoticed. Use the workflow's own token (the permissions block already grants it issues/pull-requests write) for repository edits and keep the PAT only for the Projects mutation. Failures are now reported as warnings with gh's error message, and the reviewer request is skipped when the author is the assignee, which GitHub rejects. * fix(ci): assign Dependabot PRs natively and skip edits its token cannot make GitHub gives Dependabot-triggered workflows a read-only GITHUB_TOKEN, so the assignee, reviewer and label edits cannot work for its pull requests and would only produce warnings on every run. Dependabot PRs had never been assigned. Set assignees in dependabot.yml for all three ecosystems (labels were already set there) and make the workflow skip those edits for Dependabot authors with an explicit message. Adding the PR to the Project is unchanged. --- .github/dependabot.yml | 6 ++ .github/workflows/project-automation.yml | 74 ++++++++++++++++-------- 2 files changed, 56 insertions(+), 24 deletions(-) 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 From e16e7ab8c00835bd4fa7ed6aacfa484a1cad1c61 Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:21:58 +0200 Subject: [PATCH 6/6] fix(msg): extract recipients, transport headers and RTF-only bodies from .msg files (#197) * test: add synthetic .msg fixture and enable the .msg parsing test test_msg_parsing_via_email_parser looked for samples/sample.msg, a gitignored directory, so it was skipped everywhere including CI. Add a deterministic synthetic fixture (reserved example domains only) with a generator script, point the test at it with real assertions, and add a check that the committed binary matches the generator output. * fix(msg): extract recipients, transport headers and RTF-only bodies from .msg files python-oxmsg exposes recip.email_address, not .email, and Properties has no .get(), so recipients and the raw transport headers were always empty for every .msg. Both failures were swallowed by bare except clauses. As a result SPF/DKIM/DMARC, Message-ID and the Received chain were never extracted and header scoring ran without authentication data. _parse_msg also copied only 6 of the 18 header-derived ParsedEmail fields. - read recipients and headers with the correct API, drop the bare excepts and report failures through MsgFields.errors - copy every header-derived field via _MSG_HEADER_DERIVED_FIELDS, guarded by a test that fails when a new ParsedEmail field is left unclassified - make the RTF-only path work: the RTFDE import was wrong and its ImportError was reported as 'not installed'; add MS-OXRTFCP decompression and use DeEncapsulator - install RTFDE (optional, LGPL) in the CI test job so its end-to-end test runs - bump version to 0.17.2 and update CHANGELOG, README and docs * docs: note native Dependabot assignment in the 0.17.2 changelog --- .github/workflows/tests.yml | 4 + CHANGELOG.md | 75 +++++ README.md | 2 + backend/core/analysis/email_parser.py | 31 ++- backend/core/analysis/msg_backends.py | 210 ++++++++++---- backend/tests/fixtures/make_sample_msg.py | 324 ++++++++++++++++++++++ backend/tests/fixtures/sample.msg | Bin 0 -> 9216 bytes backend/tests/test_core.py | 176 ++++++++++-- backend/utils/config.py | 2 +- docs/INSTALLATION.md | 6 +- docs/REQUIREMENTS.md | 1 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- start.bat | 2 +- 14 files changed, 742 insertions(+), 97 deletions(-) create mode 100644 backend/tests/fixtures/make_sample_msg.py create mode 100644 backend/tests/fixtures/sample.msg 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/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 0000000000000000000000000000000000000000..41a2a5f345ed89a6108d47f9c56252e249dd3ed1 GIT binary patch literal 9216 zcmeHM>uwWA6h519DU?#mt(0Co4ZU!Q?IeVz368}jp+ah^iU0yt2szFsHnqLh?gmJu zN`007?T@N`lJ)`mqkWK)e&6hjdLA{_~G_{{H#*)Bi|M zDa)`t+c+Tmy2d@+v+8n09(n1@`I=t#$PRd@-vSdDRDF;c! zAVPVFUmK_au0GmW>S!a3KGyvR{Q#1UOF_nxSGHWoWXNN@S{b%H?9=(pJR^Q2YqErE zEUO0RA+W}9f7P@%QaPQMp$;}>NG0rgz`4e`!_p7`qD;Bqdz`Hl;i7+L`!ilX)9C5=mt;;yKoSSL>wo%c5I%9Iv zI1)cZrl}&&v~j;()_Evrx~!4d#Jnyu;(1i%B&tbi_K{Od{GH}MuEpB;j`sf)^!JQ( zY4)e(>3IkLcd%XU|2eQvIpI&Z;CtMcWYPaD=s5>i`s;74(0BfM+!Jwsi5M=gf19|Dzf8FMxhG-^{Jtr;QH?a#fZTmGXh-!n28wtbt6cse-#h(C!s zaR1Sn`>y&=0-x(OOMm^{_TOB~-bHoAzXV!0{Fjsex%VHOf2cdxf7ZVHugvzJ0zT_* z724gF-Qt;rkHu!2<-Oga-3dPTuKa$q=RCbFzv;QRPCNf$-sk$mHGnb2wV1KZ`M`O> zyuiH39K}3A{`~gKp(@mmQ8{)7?I-4*-$uQ1us@nI&gk5Zo@1{|e+$5Lx-bT8EU>jD z2k-4ehdyS>Vdk>`rS6Gbk!jukX}a+!|I1$<=nwI^GS738)BUrZzW0F7H~T;8_fMPe zQN+a&K0j`R81jzkzWrV^Y&y+N`BoNiHuQ}?#qvAjt~YnE`(4HNAJ4vKAa%0(*JTCg zUlhi9_p*Es>6+l)(z^SwLt?vS%{zn!-N@9e0^8G;1xS=c%q;rb#VxL6;Rz}43oGdb&j-Uls>o^ZH_H#z^9=NN6&bMZMXIo6$rzXC0} zgj#aWZF*q=mRdmU<>jt^X8&Cv&*UElQn9+0Iq*qxLsiFKtZhi#QO$?E?qh7bRV2x3 zTrHRbxq2`saF$bc&MoFPoB6n+C}f{WTx*77gt@1TE`!!Ah4%w+qpmf0lX+uCiui3m zXNz0bLTi`)^EQj+5(}4VakEh_Ed|T#4&4GVw;2agSAjqs?}hv z6{{!pMnf(6Dq4%$erwrZ#zs(~;ns8U7MLVsm;q!!2RnPSm@T4}Z$exVvP zi;w-VzOp`2zN=f+3Z9gTm9hbvPYBgDVANZydbF5`R@8E)N5!z5(EA+EkmST0<37fo z3FKr*_ibe5WLWn{I`q$`NpJgRcwp1(k2{DTMxSf3YpE-JFWsPg+e_cu-|737pSAyx z8L;-Z-`Q#QxBo-sB(cBkTfT$ZUi))x$x?P||9)$jiyS+B-(n2Q-YLfKX8aH9{${-I z(S3dsvgBlcPyev)TYn9Cp6(AiCnz9~Jw<)d!mRojd67FbAK5f3x1GKp>+BmN3v-p^ zD%Z-S%Hnz}uK97jsw#0@sn(i4a%44V#cWrCP|YuVz5atAs&*xe(8PWeM+NH@k^ca1 CW1mI< literal 0 HcmV?d00001 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 cdeef78..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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8ff6f4..673dec2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "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.48.0", diff --git a/frontend/package.json b/frontend/package.json index bc5d93d..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", 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.