diff --git a/api_app/user_events_manager/migrations/0004_user_event_reason.py b/api_app/user_events_manager/migrations/0004_user_event_reason.py new file mode 100644 index 0000000000..70d0c6732e --- /dev/null +++ b/api_app/user_events_manager/migrations/0004_user_event_reason.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.25 on 2025-12-09 08:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user_events_manager", "0003_user_analyzer_event_data_model_id_index"), + ] + + operations = [ + migrations.AddField( + model_name="useranalyzableevent", + name="reason", + field=models.CharField(default="", max_length=256, null=True), + ), + migrations.AddField( + model_name="userdomainwildcardevent", + name="reason", + field=models.CharField(default="", max_length=256, null=True), + ), + migrations.AddField( + model_name="useripwildcardevent", + name="reason", + field=models.CharField(default="", max_length=256, null=True), + ), + ] diff --git a/api_app/user_events_manager/models.py b/api_app/user_events_manager/models.py index 2fa6a710bc..2b8464cee4 100644 --- a/api_app/user_events_manager/models.py +++ b/api_app/user_events_manager/models.py @@ -25,6 +25,7 @@ class UserEvent(models.Model): on_delete=models.CASCADE, ) date = models.DateTimeField(default=now, editable=False, db_index=True) + reason = models.CharField(max_length=256, default="", null=True) data_model: ForeignKey decay_progression = models.IntegerField( diff --git a/frontend/src/components/analyzables/result/analyzablesHistoryTableColumns.jsx b/frontend/src/components/analyzables/result/analyzablesHistoryTableColumns.jsx index 817233b2b2..e5a1b82044 100644 --- a/frontend/src/components/analyzables/result/analyzablesHistoryTableColumns.jsx +++ b/frontend/src/components/analyzables/result/analyzablesHistoryTableColumns.jsx @@ -159,7 +159,7 @@ export const analyzablesHistoryTableColumns = [ } else if (value.type === AnalyzableHistoryTypes.JOB) { text = "Custom Analysis"; } else { - text = value.data_model.related_threats.toString(); + text = value.reason; } return text; }, diff --git a/frontend/src/components/userEvents/UserEventModal.jsx b/frontend/src/components/userEvents/UserEventModal.jsx index 51200f8d6f..08dd6ba7ad 100644 --- a/frontend/src/components/userEvents/UserEventModal.jsx +++ b/frontend/src/components/userEvents/UserEventModal.jsx @@ -128,6 +128,7 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { tags: [], malware_family: "", // advanced fields + reason: "", evaluation: DataModelEvaluations.MALICIOUS, reliability: 10, decay_progression: DecayProgressionTypes.LINEAR, @@ -150,8 +151,8 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { errors[`analyzables-${index}`] = wildcardInputError[analyzable]; } }); - if (values.related_threats[0] === "") { - errors["related_threats-0"] = "Reason is required"; + if (values.reason === "") { + errors.reason = "Reason is required"; } if (!Number.isInteger(values.decay_timedelta_days)) { errors.decay_timedelta_days = "The value must be a number."; @@ -168,34 +169,29 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { }, validateOnMount: true, onSubmit: async () => { - const editedFields = {}; - delete formik.values.basic_evaluation; // not needed in the request - Object.entries(formik.values).forEach(([key, value]) => { - if ( - /* order matters! kill chain also HTML and cannot be converted into JSON - check before the fields and then check if they are different from the default values - */ - !["analyzables", "kill_chain_phase", "tags"].includes(key) && - JSON.stringify(value) !== JSON.stringify(formik.initialValues[key]) - ) { - editedFields[key] = value; - } - // special cases for kill chain: it has a key with html as value - if (key === "kill_chain_phase" && value !== "") { - editedFields.kill_chain_phase = value.value; - } - if (key === "tags" && value.length) { - editedFields.tags = value.map((tag) => tag.value); - } - }); - console.debug("editedFields", editedFields); const evaluation = { + reason: formik.values.reason, decay_progression: formik.values.decay_progression, decay_timedelta_days: formik.values.decay_timedelta_days, data_model_content: { - ...editedFields, evaluation: formik.values.evaluation, reliability: formik.values.reliability, + external_references: + formik.values.external_references[0] !== "" + ? formik.values.external_references + : [], + related_threats: + formik.values.related_threats[0] !== "" + ? formik.values.related_threats + : [], + malware_family: formik.values.malware_family, + kill_chain_phase: + formik.values.kill_chain_phase !== "" + ? formik.values.kill_chain_phase.value + : "", + tags: formik.values.tags.length + ? formik.values.tags.map((tag) => tag.value) + : [], }, }; console.debug("evaluation", evaluation); @@ -772,13 +768,56 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { - + +
+ + + + + + + + + + +
+ + + + + - {formik.errors["related_threats-0"] && - formik.touched["related_threats-0"] && ( - - {formik.errors["related_threats-0"]} - - )} -
+
diff --git a/frontend/src/components/userEvents/userEventsTableColumns.jsx b/frontend/src/components/userEvents/userEventsTableColumns.jsx index dd8c4a1b84..02020d3709 100644 --- a/frontend/src/components/userEvents/userEventsTableColumns.jsx +++ b/frontend/src/components/userEvents/userEventsTableColumns.jsx @@ -129,7 +129,7 @@ export const userEventsTableEndColumns = [ { Header: "Reasons", id: "related_threats", - accessor: (userEvent) => userEvent.data_model.related_threats, + accessor: (userEvent) => userEvent.reason, Cell: ({ value: comments, row }) => comments.length > 0 && ( { rank: null, resolutions: [], }, + reason: "my reason", data_model_object_id: 15, decay_progression: 0, decay_timedelta_days: 3, @@ -199,6 +200,8 @@ describe("test AnalyzableOverview", () => { expect( screen.getByRole("columnheader", { name: "Description" }), ).toBeInTheDocument(); + + expect(screen.getByText("my reason")).toBeInTheDocument(); // cell - job expect(screen.getByRole("cell", { name: "#13" })).toBeInTheDocument(); expect(screen.getByText("#13").href).toContain("/jobs/13/visualizer"); diff --git a/frontend/tests/components/userEvents/UserEventModal.test.jsx b/frontend/tests/components/userEvents/UserEventModal.test.jsx index d1ed393596..2a5075ed4a 100644 --- a/frontend/tests/components/userEvents/UserEventModal.test.jsx +++ b/frontend/tests/components/userEvents/UserEventModal.test.jsx @@ -117,8 +117,17 @@ describe("test UserEventModal component", () => { expect(trusted10).not.toBeChecked(); const reasonInput = screen.getAllByRole("textbox")[1]; expect(reasonInput).toBeInTheDocument(); - expect(reasonInput.id).toBe("related_threats-0"); - const externalReferencesInput = screen.getAllByRole("textbox")[2]; + expect(reasonInput.id).toBe("reason"); + expect(reasonInput.value).toBe(""); + const malwareFamilyInput = screen.getAllByRole("textbox")[2]; + expect(malwareFamilyInput).toBeInTheDocument(); + expect(malwareFamilyInput.id).toBe("malware_family"); + expect(malwareFamilyInput.value).toBe(""); + const relatedThreatsInput = screen.getAllByRole("textbox")[3]; + expect(relatedThreatsInput).toBeInTheDocument(); + expect(relatedThreatsInput.id).toBe("related_threats-0"); + expect(relatedThreatsInput.value).toBe(""); + const externalReferencesInput = screen.getAllByRole("textbox")[4]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); @@ -162,9 +171,14 @@ describe("test UserEventModal component", () => { analyzable: { name: "google.com" }, data_model_content: { evaluation: "malicious", - related_threats: ["my reason"], reliability: 10, + malware_family: "", + kill_chain_phase: "", + related_threats: [], + external_references: [], + tags: [], }, + reason: "my reason", decay_progression: "0", decay_timedelta_days: 120, }, @@ -177,9 +191,14 @@ describe("test UserEventModal component", () => { network: "1.2.3.0/24", data_model_content: { evaluation: "malicious", - related_threats: ["my reason"], reliability: 10, + malware_family: "", + kill_chain_phase: "", + related_threats: [], + external_references: [], + tags: [], }, + reason: "my reason", decay_progression: "0", decay_timedelta_days: 120, }, @@ -192,9 +211,14 @@ describe("test UserEventModal component", () => { query: ".*\\.test.com", data_model_content: { evaluation: "malicious", - related_threats: ["my reason"], reliability: 10, + malware_family: "", + kill_chain_phase: "", + related_threats: [], + external_references: [], + tags: [], }, + reason: "my reason", decay_progression: "0", decay_timedelta_days: 120, }, @@ -296,9 +320,17 @@ describe("test UserEventModal component", () => { expect(trusted10).not.toBeChecked(); const reasonInput = screen.getAllByRole("textbox")[1]; expect(reasonInput).toBeInTheDocument(); - expect(reasonInput.id).toBe("related_threats-0"); + expect(reasonInput.id).toBe("reason"); expect(reasonInput.value).toBe(""); - const externalReferencesInput = screen.getAllByRole("textbox")[2]; + const malwareFamilyInput = screen.getAllByRole("textbox")[2]; + expect(malwareFamilyInput).toBeInTheDocument(); + expect(malwareFamilyInput.id).toBe("malware_family"); + expect(malwareFamilyInput.value).toBe(""); + const relatedThreatsInput = screen.getAllByRole("textbox")[3]; + expect(relatedThreatsInput).toBeInTheDocument(); + expect(relatedThreatsInput.id).toBe("related_threats-0"); + expect(relatedThreatsInput.value).toBe(""); + const externalReferencesInput = screen.getAllByRole("textbox")[4]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); expect(externalReferencesInput.value).toBe(""); @@ -331,7 +363,7 @@ describe("test UserEventModal component", () => { }, ); - test("UserEventModal - set killchain, tags and advanced evaluation", async () => { + test("UserEventModal - advanced fields (killchain, malware family, related threat, external ref, tags and advanced evaluation)", async () => { const user = userEvent.setup(); axios.put.mockImplementation(() => Promise.resolve({ status: 200, data: [""] }), @@ -382,9 +414,17 @@ describe("test UserEventModal component", () => { expect(trusted10).not.toBeChecked(); const reasonInput = screen.getAllByRole("textbox")[1]; expect(reasonInput).toBeInTheDocument(); - expect(reasonInput.id).toBe("related_threats-0"); + expect(reasonInput.id).toBe("reason"); expect(reasonInput.value).toBe(""); - const externalReferencesInput = screen.getAllByRole("textbox")[2]; + const malwareFamilyInput = screen.getAllByRole("textbox")[2]; + expect(malwareFamilyInput).toBeInTheDocument(); + expect(malwareFamilyInput.id).toBe("malware_family"); + expect(malwareFamilyInput.value).toBe(""); + const relatedThreatsInput = screen.getAllByRole("textbox")[3]; + expect(relatedThreatsInput).toBeInTheDocument(); + expect(relatedThreatsInput.id).toBe("related_threats-0"); + expect(relatedThreatsInput.value).toBe(""); + const externalReferencesInput = screen.getAllByRole("textbox")[4]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); const killChainPhaseInput = screen.getAllByRole("combobox")[0]; @@ -423,6 +463,24 @@ describe("test UserEventModal component", () => { // add reason fireEvent.change(reasonInput, { target: { value: "my reason" } }); expect(reasonInput.value).toBe("my reason"); + // add malware family + fireEvent.change(malwareFamilyInput, { target: { value: "ursnif" } }); + expect(malwareFamilyInput.value).toBe("ursnif"); + // add related artifacts + fireEvent.change(relatedThreatsInput, { + target: { value: "anotherArtifact.com" }, + }); + expect(relatedThreatsInput.value).toBe("anotherArtifact.com"); + // add external references + fireEvent.change(externalReferencesInput, { + target: { value: "http://test.com" }, + }); + expect(externalReferencesInput.value).toBe("http://test.com"); + // add killchain phase + await userEvent.click(killChainPhaseInput); + await userEvent.click(screen.getByText("action")); + expect(screen.getByText("action")).toBeInTheDocument(); + expect(screen.queryByText("c2")).not.toBeInTheDocument(); // check other option are not visible // add tags (2 of them) await userEvent.click(tagsInput); await userEvent.click(screen.getByText("phishing")); @@ -432,11 +490,6 @@ describe("test UserEventModal component", () => { await userEvent.click(screen.getByText("malware")); expect(screen.getByText("malware")).toBeInTheDocument(); expect(screen.queryByText("scanner")).not.toBeInTheDocument(); // check other option are not visible - // add kill chain phase - await userEvent.click(killChainPhaseInput); - await userEvent.click(screen.getByText("action")); - expect(screen.getByText("action")).toBeInTheDocument(); - expect(screen.queryByText("c2")).not.toBeInTheDocument(); // check other option are not visible // IMPORTANT - wait for the state change await screen.findByText("artifact"); @@ -452,11 +505,14 @@ describe("test UserEventModal component", () => { analyzable: { name: "test.com" }, data_model_content: { evaluation: "trusted", - related_threats: ["my reason"], reliability: "9", + malware_family: "ursnif", + related_threats: ["anotherArtifact.com"], + external_references: ["http://test.com"], kill_chain_phase: "action", tags: ["phishing", "malware"], }, + reason: "my reason", decay_progression: "0", decay_timedelta_days: 120, }); @@ -514,9 +570,17 @@ describe("test UserEventModal component", () => { expect(trusted10).not.toBeChecked(); const reasonInput = screen.getAllByRole("textbox")[1]; expect(reasonInput).toBeInTheDocument(); - expect(reasonInput.id).toBe("related_threats-0"); + expect(reasonInput.id).toBe("reason"); expect(reasonInput.value).toBe(""); - const externalReferencesInput = screen.getAllByRole("textbox")[2]; + const malwareFamilyInput = screen.getAllByRole("textbox")[2]; + expect(malwareFamilyInput).toBeInTheDocument(); + expect(malwareFamilyInput.id).toBe("malware_family"); + expect(malwareFamilyInput.value).toBe(""); + const relatedThreatsInput = screen.getAllByRole("textbox")[3]; + expect(relatedThreatsInput).toBeInTheDocument(); + expect(relatedThreatsInput.id).toBe("related_threats-0"); + expect(relatedThreatsInput.value).toBe(""); + const externalReferencesInput = screen.getAllByRole("textbox")[4]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); const killChainPhaseInput = screen.getAllByRole("combobox")[0]; @@ -579,9 +643,14 @@ describe("test UserEventModal component", () => { analyzable: { name: "test.com" }, data_model_content: { evaluation: "malicious", - related_threats: ["my reason"], reliability: 7, + kill_chain_phase: "", + malware_family: "", + related_threats: [], + external_references: [], + tags: [], }, + reason: "my reason", decay_progression: "0", decay_timedelta_days: 120, });