diff --git a/api_app/ingestors_manager/views.py b/api_app/ingestors_manager/views.py index 13b0c732b5..1609b58981 100644 --- a/api_app/ingestors_manager/views.py +++ b/api_app/ingestors_manager/views.py @@ -4,6 +4,7 @@ import logging from rest_framework import status +from rest_framework.decorators import action from rest_framework.response import Response from api_app.ingestors_manager.models import IngestorConfig @@ -16,10 +17,16 @@ class IngestorConfigViewSet(PythonConfigViewSet): serializer_class = IngestorConfigSerializer - def disable_in_org(self, request, pk=None): + @action( + methods=["post"], + detail=True, + url_path="organization", + ) + def disable_in_org(self, request, name=None): return Response(status=status.HTTP_404_NOT_FOUND) - def enable_in_org(self, request, pk=None): + @disable_in_org.mapping.delete + def enable_in_org(self, request, name=None): return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/api_app/views.py b/api_app/views.py index e12d80d572..1e0f019851 100644 --- a/api_app/views.py +++ b/api_app/views.py @@ -7,6 +7,7 @@ from abc import ABCMeta, abstractmethod from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, Q from django.db.models.functions import Trunc from django.http import FileResponse @@ -1454,7 +1455,10 @@ def get_permissions(self): ) def plugin_config(self, request, name=None): logger.info(f"get plugin_config from user {request.user}, name {name}") - obj: PythonConfig = self.get_queryset().get(name=name) + try: + obj: PythonConfig = self.get_queryset().get(name=name) + except ObjectDoesNotExist: + raise NotFound("Requested plugin does not exist.") try: plugin_configs: PluginConfig = PluginConfig.objects.filter( **{obj.snake_case_name: obj.pk} @@ -1494,7 +1498,7 @@ def plugin_config(self, request, name=None): param_obj["exist"] = True org_config.append(copy.deepcopy(param_obj)) # override default config with user config (if any) - print(pc.data) + logger.debug(pc.data) for config in [ config for config in pc.data diff --git a/frontend/src/components/Routes.jsx b/frontend/src/components/Routes.jsx index 0e9c206216..79828a4558 100644 --- a/frontend/src/components/Routes.jsx +++ b/frontend/src/components/Routes.jsx @@ -2,10 +2,10 @@ import React, { Suspense } from "react"; import { FallBackLoading } from "@certego/certego-ui"; import { Navigate, useParams } from "react-router-dom"; -import { format } from "date-fns"; +import { fromZonedTime } from "date-fns-tz"; import AuthGuard from "../wrappers/AuthGuard"; import IfAuthRedirectGuard from "../wrappers/IfAuthRedirectGuard"; -import { datetimeFormatStr, JobResultSections } from "../constants/miscConst"; +import { JobResultSections, localTimezone } from "../constants/miscConst"; const Home = React.lazy(() => import("./home/Home")); const Login = React.lazy(() => import("./auth/Login")); @@ -50,9 +50,9 @@ function CustomRedirect() { return ( diff --git a/frontend/src/components/analyzables/result/AnalyzableOverview.jsx b/frontend/src/components/analyzables/result/AnalyzableOverview.jsx index d2f756b942..d9fb00257c 100644 --- a/frontend/src/components/analyzables/result/AnalyzableOverview.jsx +++ b/frontend/src/components/analyzables/result/AnalyzableOverview.jsx @@ -17,7 +17,7 @@ import { VerticalListVisualizer } from "../../common/visualizer/elements/vertica import { BooleanVisualizer } from "../../common/visualizer/elements/bool"; import { LastEvaluationComponent } from "../../common/engineBadges"; -import { TagsIcons } from "../../../constants/dataModelConst"; +import { DataModelTagsIcons } from "../../../constants/dataModelConst"; import { TagsColors } from "../../../constants/colorConst"; import { getIcon } from "../../common/icon/icons"; import { AnalyzableHistoryTypes } from "../../../constants/miscConst"; @@ -167,14 +167,14 @@ export function AnalyzableOverview({ analyzable }) { value={tag} id={`tags-${index}`} icon={ - Object.keys(TagsIcons).includes(tag) ? ( - getIcon(TagsIcons?.[tag]) + Object.keys(DataModelTagsIcons).includes(tag) ? ( + getIcon(DataModelTagsIcons?.[tag]) ) : ( ) } activeColor={ - Object.keys(TagsIcons).includes(tag) + Object.keys(DataModelTagsIcons).includes(tag) ? TagsColors?.[tag] : "secondary" } diff --git a/frontend/src/components/common/engineBadges.jsx b/frontend/src/components/common/engineBadges.jsx index 1dd0904e25..8481528e3c 100644 --- a/frontend/src/components/common/engineBadges.jsx +++ b/frontend/src/components/common/engineBadges.jsx @@ -6,7 +6,10 @@ import { VscFile } from "react-icons/vsc"; import { TbWorld } from "react-icons/tb"; import classnames from "classnames"; import { EvaluationColors, TagsColors } from "../../constants/colorConst"; -import { EvaluationIcons, TagsIcons } from "../../constants/dataModelConst"; +import { + DataModelEvaluationIcons, + DataModelTagsIcons, +} from "../../constants/dataModelConst"; import { getIcon } from "./icon/icons"; export function EvaluationBadge(props) { @@ -14,7 +17,7 @@ export function EvaluationBadge(props) { const color = EvaluationColors?.[evaluation]; const divClass = classnames(`bg-${color}`, className); - const icon = EvaluationIcons?.[evaluation]; + const icon = DataModelEvaluationIcons?.[evaluation]; return ( { - if (Object.keys(TagsIcons).includes(tag)) { + if (Object.keys(DataModelTagsIcons).includes(tag)) { tags.push(tag); } else { customTags.push(tag); diff --git a/frontend/src/components/investigations/table/InvestigationsTable.jsx b/frontend/src/components/investigations/table/InvestigationsTable.jsx index 85765aec22..824a0dea98 100644 --- a/frontend/src/components/investigations/table/InvestigationsTable.jsx +++ b/frontend/src/components/investigations/table/InvestigationsTable.jsx @@ -21,10 +21,10 @@ import { import useTitle from "react-use/lib/useTitle"; -import { format } from "date-fns-tz"; +import { format, fromZonedTime } from "date-fns-tz"; import { INVESTIGATION_BASE_URI } from "../../../constants/apiURLs"; import { investigationTableColumns } from "./investigationTableColumns"; -import { datetimeFormatStr } from "../../../constants/miscConst"; +import { datetimeFormatStr, localTimezone } from "../../../constants/miscConst"; import { TimePicker } from "../../common/TimePicker"; // constants @@ -90,12 +90,18 @@ export function InvestigationTable({ // this update the value after some times, this give user time to pick the datetime useDebounceInput( - { name: "start_time__gte", value: fromDateType }, + { + name: "start_time__gte", + value: fromZonedTime(fromDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); useDebounceInput( - { name: "start_time__lte", value: toDateType }, + { + name: "start_time__lte", + value: fromZonedTime(toDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); diff --git a/frontend/src/components/jobs/table/JobsTable.jsx b/frontend/src/components/jobs/table/JobsTable.jsx index 46285be233..945664b6a5 100644 --- a/frontend/src/components/jobs/table/JobsTable.jsx +++ b/frontend/src/components/jobs/table/JobsTable.jsx @@ -3,6 +3,7 @@ import React from "react"; import { Container, Row, Col, UncontrolledTooltip } from "reactstrap"; import { MdInfoOutline } from "react-icons/md"; +import { fromZonedTime } from "date-fns-tz"; import { Loader, SyncButton, @@ -16,7 +17,7 @@ import { format } from "date-fns"; import { jobTableColumns } from "./jobTableColumns"; import { JOB_BASE_URI } from "../../../constants/apiURLs"; import { usePluginConfigurationStore } from "../../../stores/usePluginConfigurationStore"; -import { datetimeFormatStr } from "../../../constants/miscConst"; +import { datetimeFormatStr, localTimezone } from "../../../constants/miscConst"; import { TimePicker } from "../../common/TimePicker"; // constants @@ -75,12 +76,18 @@ export function JobsTable({ searchFromDateValue, searchToDateValue }) { // this update the value after some times, this give user time to pick the datetime useDebounceInput( - { name: "received_request_time__gte", value: fromDateType }, + { + name: "received_request_time__gte", + value: fromZonedTime(fromDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); useDebounceInput( - { name: "received_request_time__lte", value: toDateType }, + { + name: "received_request_time__lte", + value: fromZonedTime(toDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); diff --git a/frontend/src/components/userEvents/UserEventModal.jsx b/frontend/src/components/userEvents/UserEventModal.jsx index 56bb10fe40..a8c0877a40 100644 --- a/frontend/src/components/userEvents/UserEventModal.jsx +++ b/frontend/src/components/userEvents/UserEventModal.jsx @@ -12,6 +12,7 @@ import { Input, FormFeedback, UncontrolledTooltip, + Badge, } from "reactstrap"; import PropTypes from "prop-types"; import { useFormik, FormikProvider, FieldArray } from "formik"; @@ -21,10 +22,13 @@ import { MdInfoOutline } from "react-icons/md"; import { ArrowToggleIcon, + MultiSelectCreatableInput, addToast, + selectStyles, useDebounceInput, } from "@certego/certego-ui"; +import ReactSelect from "react-select"; import { USER_EVENT_ANALYZABLE, USER_EVENT_IP_WILDCARD, @@ -32,11 +36,12 @@ import { } from "../../constants/apiURLs"; import { - Evaluations, + DataModelEvaluations, DataModelKillChainPhases, + DataModelKillChainPhasesDescriptions, + DataModelTags, } from "../../constants/dataModelConst"; import { ListInput } from "../common/form/ListInput"; -import { TagSelectInput } from "../common/form/TagSelectInput"; import { DecayProgressionTypes, DecayProgressionDescription, @@ -50,6 +55,12 @@ import { URL_REGEX, HASH_REGEX, } from "../../constants/regexConst"; +import { TagsColors } from "../../constants/colorConst"; + +const evaluationOptions = [ + { evaluation: DataModelEvaluations.MALICIOUS, reliability: 10 }, + { evaluation: DataModelEvaluations.TRUSTED, reliability: 6 }, +]; export function UserEventModal({ analyzables, toggle, isOpen }) { console.debug("UserEventModal rendered!"); @@ -114,12 +125,23 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { const editedFields = {}; Object.entries(formik.values).forEach(([key, value]) => { if ( - JSON.stringify(value) !== JSON.stringify(formik.initialValues[key]) && - key !== "analyzables" + /* 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 = { decay_progression: formik.values.decay_progression, decay_timedelta_days: formik.values.decay_timedelta_days, @@ -128,6 +150,7 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { reliability: formik.values.reliability, }, }; + console.debug("evaluation", evaluation); const failed = []; Promise.allSettled( @@ -185,19 +208,24 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { }); React.useEffect(() => { + // this useEffect populate initial state in case the model is accessed from previously searched analyzables const obj = {}; - formik.initialValues.analyzables.forEach((analyzable) => { - if (analyzable !== "") { - obj[analyzable] = { type: UserEventTypes.ANALYZABLE }; + analyzables.forEach((analyzable) => { + if (analyzable.name !== "") { + obj[analyzable.name] = { + type: UserEventTypes.ANALYZABLE, + eventId: analyzable.id, + }; } }); setInputState({ ...inputState, ...obj }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [formik.initialValues.analyzables]); + }, [analyzables]); useDebounceInput(inputValue, 1000, setWildcard); React.useEffect(() => { + // this useEffect detect the type of user event while typing if (wildcard !== "") { // check ip wildcard if ( @@ -288,6 +316,9 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [wildcard]); + console.debug("userEventModal - formik values", formik.values); + console.debug("userEventModal - inputState", inputState); + return ( - + { + const evCode = event.target.value; + formik.setFieldValue("evaluation", evCode, false); + formik.setFieldValue( + "reliability", + evaluationOptions.find( + (element) => element.evaluation === evCode, + ).reliability, + // second set must trigger the validate or update evaluation with pre-populated form won't work + true, + ); + }} className="bg-darker border-dark" invalid={ formik.touched.evaluation && @@ -479,22 +521,58 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { } > Select... - {[Evaluations.MALICIOUS, Evaluations.TRUSTED] - .sort() - .map((value) => ( - - {value.toUpperCase()} - - ))} + {evaluationOptions.map((value) => ( + + {value.evaluation.toUpperCase()} + + ))} Evaluation is required - + + + + + + Reliability: + + + + 10 + } + className="bg-darker border-0" + /> + + The reliability value must be a number between 1 and 10 + + + + + Reliability indicates how much you are confident about + your evaluation. Higher is the values, higher is the + confidence. + + + + + + + @@ -553,28 +631,39 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { Kill chain phase: - - + ({ + value: killChainPhase, + label: ( + + + {killChainPhase} + + {DataModelKillChainPhasesDescriptions[ + killChainPhase.toUpperCase() + ] || ""} + + + + ), + }), + )} + styles={selectStyles} value={formik.values.kill_chain_phase} - onBlur={formik.handleBlur} - onChange={formik.handleChange} - className="bg-darker border-dark" - > - Select... - {Object.values(DataModelKillChainPhases) - .sort() - .map((value) => ( - - {value.toUpperCase()} - - ))} - + onChange={(killChainPhase) => + formik.setFieldValue( + "kill_chain_phase", + killChainPhase, + false, + ) + } + /> @@ -584,12 +673,16 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { Tags: - - formik.setFieldValue("tags", selectedTags, false) - } + ({ + value: tag, + label: {tag}, + }))} + value={formik.values.tags} + styles={selectStyles} + onChange={(tag) => formik.setFieldValue("tags", tag, false)} + isClearable /> @@ -612,35 +705,6 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { {isOpenAdvancedFields && ( <> - - - - - Reliability: - - - - 10 - } - className="bg-darker border-0" - /> - - The reliability value must be a number between 1 and 10 - - - - - @@ -743,7 +807,7 @@ export function UserEventModal({ analyzables, toggle, isOpen }) { } UserEventModal.propTypes = { - analyzables: PropTypes.arrayOf(Object), + analyzables: PropTypes.array, toggle: PropTypes.func.isRequired, isOpen: PropTypes.bool.isRequired, }; diff --git a/frontend/src/components/userEvents/UserEventsTable.jsx b/frontend/src/components/userEvents/UserEventsTable.jsx index 5971d5b5e6..47528e5d3d 100644 --- a/frontend/src/components/userEvents/UserEventsTable.jsx +++ b/frontend/src/components/userEvents/UserEventsTable.jsx @@ -13,8 +13,8 @@ import { import useTitle from "react-use/lib/useTitle"; -import { format } from "date-fns-tz"; -import { datetimeFormatStr } from "../../constants/miscConst"; +import { format, fromZonedTime } from "date-fns-tz"; +import { datetimeFormatStr, localTimezone } from "../../constants/miscConst"; import { TimePicker } from "../common/TimePicker"; import { JsonEditor } from "../common/JsonEditor"; @@ -108,12 +108,18 @@ export function UserEventsTable({ // this update the value after some times, this give user time to pick the datetime useDebounceInput( - { name: "event_date__gte", value: fromDateType }, + { + name: "event_date__gte", + value: fromZonedTime(fromDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); useDebounceInput( - { name: "event_date__lte", value: toDateType }, + { + name: "event_date__lte", + value: fromZonedTime(toDateType, localTimezone).toISOString(), + }, 1000, onChangeFilter, ); diff --git a/frontend/src/constants/dataModelConst.js b/frontend/src/constants/dataModelConst.js index 0de76c7b4a..92090474cd 100644 --- a/frontend/src/constants/dataModelConst.js +++ b/frontend/src/constants/dataModelConst.js @@ -1,10 +1,19 @@ /* eslint-disable id-length */ -export const EvaluationIcons = Object.freeze({ +export const DataModelEvaluationIcons = Object.freeze({ trusted: "like", malicious: "malware", }); -export const TagsIcons = Object.freeze({ +export const DataModelTags = Object.freeze({ + PHISHING: "phishing", + MALWARE: "malware", + SOCIAL_ENGINEERING: "social_engineering", + ANONYMIZER: "anonymizer", + TOR_EXIT_NODE: "tor_exit_node", + ABUSED: "abused", +}); + +export const DataModelTagsIcons = Object.freeze({ phishing: "hook", anonymizer: "incognito", malware: "malware", @@ -14,7 +23,7 @@ export const TagsIcons = Object.freeze({ ip_only: "networkNode", }); -export const Evaluations = Object.freeze({ +export const DataModelEvaluations = Object.freeze({ TRUSTED: "trusted", MALICIOUS: "malicious", }); @@ -28,3 +37,19 @@ export const DataModelKillChainPhases = Object.freeze({ C2: "c2", ACTION: "action", }); + +export const DataModelKillChainPhasesDescriptions = Object.freeze({ + RECONNAISSANCE: + "Attackers research their target to identify vulnerabilities, gathering information on security defenses, corporate structure, and potential entry points.", + WEAPONIZATION: + "The attacker crafts a malicious payload, such as a virus or malware, tailored to exploit the identified weaknesses.", + DELIVERY: + "The weaponized payload is sent to the target, often through methods like phishing emails or malicious links.", + EXPLOITATION: + "The malicious code runs on the target system, exploiting the vulnerability and gaining access.", + INSTALLATION: + "The malware establishes persistent access on the compromised system, often by installing backdoors or trojans.", + C2: "Attackers establish a remote communication channel with the compromised system to issue commands and control their operation.", + ACTION: + "The final phase where attackers achieve their ultimate goals, such as stealing data exfiltrating them, encrypting files for ransom, or disrupting services.", +}); diff --git a/frontend/src/constants/miscConst.js b/frontend/src/constants/miscConst.js index 9770f14abe..8ccace65ea 100644 --- a/frontend/src/constants/miscConst.js +++ b/frontend/src/constants/miscConst.js @@ -23,6 +23,7 @@ export const HTTPMethods = Object.freeze({ }); export const datetimeFormatStr = "yyyy-MM-dd'T'HH:mm:ss"; +export const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; export const HistoryPages = Object.freeze({ JOBS: "jobs", diff --git a/frontend/tests/components/userEvents/UserEventModal.test.jsx b/frontend/tests/components/userEvents/UserEventModal.test.jsx index 5ac37767e0..a4a4f4fbd8 100644 --- a/frontend/tests/components/userEvents/UserEventModal.test.jsx +++ b/frontend/tests/components/userEvents/UserEventModal.test.jsx @@ -11,6 +11,7 @@ import { USER_EVENT_DOMAIN_WILDCARD, } from "../../../src/constants/apiURLs"; import { mockedUseTagsStore, mockedUseAuthStore } from "../../mock"; +import { ReliabilityBar } from "../../../src/components/common/engineBadges"; jest.mock("axios"); jest.mock("../../../src/stores/useAuthStore", () => ({ @@ -69,20 +70,14 @@ describe("test UserEventModal component", () => { expect(screen.getByText("Type:")).toBeInTheDocument(); expect(screen.getByText("Matches:")).toBeInTheDocument(); expect(screen.getByText("supported only for wildcard")).toBeInTheDocument(); - const evaluationInput = screen.getByRole("combobox", { - name: /Evaluation:/i, - }); - expect(evaluationInput).toBeInTheDocument(); + expect(screen.getByText("Evaluation:")).toBeInTheDocument(); const commentsInput = screen.getAllByRole("textbox")[1]; expect(commentsInput).toBeInTheDocument(); expect(commentsInput.id).toBe("related_threats-0"); const externalReferencesInput = screen.getAllByRole("textbox")[2]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); - const killChainPhaseInput = screen.getByRole("combobox", { - name: /Kill chain phase:/i, - }); - expect(killChainPhaseInput).toBeInTheDocument(); + expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); expect(screen.getByText("Tags:")).toBeInTheDocument(); // advanced fields @@ -185,9 +180,7 @@ describe("test UserEventModal component", () => { expect( screen.getByText("supported only for wildcard"), ).toBeInTheDocument(); - const evaluationInput = screen.getByRole("combobox", { - name: /Evaluation:/i, - }); + const evaluationInput = screen.getAllByRole("combobox")[0]; expect(evaluationInput).toBeInTheDocument(); const commentsInput = screen.getAllByRole("textbox")[1]; expect(commentsInput).toBeInTheDocument(); @@ -196,10 +189,7 @@ describe("test UserEventModal component", () => { const externalReferencesInput = screen.getAllByRole("textbox")[2]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); - const killChainPhaseInput = screen.getByRole("combobox", { - name: /Kill chain phase:/i, - }); - expect(killChainPhaseInput).toBeInTheDocument(); + expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); expect(screen.getByText("Tags:")).toBeInTheDocument(); const advancedFields = screen.getByRole("button", { name: /Advanced fields/i, @@ -241,9 +231,9 @@ describe("test UserEventModal component", () => { payload: { analyzable: { name: "google.com" }, data_model_content: { - evaluation: "malicious", + evaluation: "trusted", related_threats: ["my comment"], - reliability: 10, + reliability: 6, }, decay_progression: "0", decay_timedelta_days: 120, @@ -257,9 +247,9 @@ describe("test UserEventModal component", () => { payload: { network: "1.2.3.0/24", data_model_content: { - evaluation: "malicious", + evaluation: "trusted", related_threats: ["my comment"], - reliability: 10, + reliability: 6, }, decay_progression: "0", decay_timedelta_days: 120, @@ -273,9 +263,9 @@ describe("test UserEventModal component", () => { payload: { query: ".*\\.test.com", data_model_content: { - evaluation: "malicious", + evaluation: "trusted", related_threats: ["my comment"], - reliability: 10, + reliability: 6, }, decay_progression: "0", decay_timedelta_days: 120, @@ -314,9 +304,7 @@ describe("test UserEventModal component", () => { expect( screen.getByText("supported only for wildcard"), ).toBeInTheDocument(); - const evaluationInput = screen.getByRole("combobox", { - name: /Evaluation:/i, - }); + const evaluationInput = screen.getAllByRole("combobox")[0]; expect(evaluationInput).toBeInTheDocument(); const commentsInput = screen.getAllByRole("textbox")[1]; expect(commentsInput).toBeInTheDocument(); @@ -325,10 +313,7 @@ describe("test UserEventModal component", () => { const externalReferencesInput = screen.getAllByRole("textbox")[2]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); - const killChainPhaseInput = screen.getByRole("combobox", { - name: /Kill chain phase:/i, - }); - expect(killChainPhaseInput).toBeInTheDocument(); + expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); expect(screen.getByText("Tags:")).toBeInTheDocument(); const advancedFields = screen.getByRole("button", { name: /Advanced fields/i, @@ -342,8 +327,8 @@ describe("test UserEventModal component", () => { fireEvent.change(analyzablesInput, { target: { value: input } }); expect(analyzablesInput.value).toBe(input); // add evaluation - fireEvent.change(evaluationInput, { target: { value: "malicious" } }); - expect(screen.getByText("MALICIOUS")).toBeInTheDocument(); + fireEvent.change(evaluationInput, { target: { value: "trusted" } }); + expect(screen.getByText("TRUSTED")).toBeInTheDocument(); // add comment fireEvent.change(commentsInput, { target: { value: "my comment" } }); expect(commentsInput.value).toBe("my comment"); @@ -386,20 +371,14 @@ describe("test UserEventModal component", () => { expect(screen.getByText("artifact")).toBeInTheDocument(); expect(screen.getByText("Matches:")).toBeInTheDocument(); expect(screen.getByText("supported only for wildcard")).toBeInTheDocument(); - const evaluationInput = screen.getByRole("combobox", { - name: /Evaluation:/i, - }); - expect(evaluationInput).toBeInTheDocument(); + expect(screen.getByText("Evaluation:")).toBeInTheDocument(); const commentsInput = screen.getAllByRole("textbox")[1]; expect(commentsInput).toBeInTheDocument(); expect(commentsInput.id).toBe("related_threats-0"); const externalReferencesInput = screen.getAllByRole("textbox")[2]; expect(externalReferencesInput).toBeInTheDocument(); expect(externalReferencesInput.id).toBe("external_references-0"); - const killChainPhaseInput = screen.getByRole("combobox", { - name: /Kill chain phase:/i, - }); - expect(killChainPhaseInput).toBeInTheDocument(); + expect(screen.getByText("Kill chain phase:")).toBeInTheDocument(); expect(screen.getByText("Tags:")).toBeInTheDocument(); // advanced fields @@ -422,4 +401,108 @@ describe("test UserEventModal component", () => { expect(saveButton).toBeInTheDocument(); expect(saveButton.className).toContain("disabled"); }); + + test("UserEventModal - form (killchain, tags and custom reliability)", async () => { + const user = userEvent.setup(); + axios.put.mockImplementation(() => + Promise.resolve({ status: 200, data: [""] }), + ); + axios.get.mockImplementation(() => + Promise.resolve({ status: 200, data: { count: 0 } }), + ); + render( + + jest.fn()} isOpen /> + , + ); + + const modalTitle = screen.getByRole("heading", { + name: /Add your evaluation/i, + }); + expect(modalTitle).toBeInTheDocument(); + + const analyzablesInput = screen.getAllByRole("textbox")[0]; + expect(analyzablesInput).toBeInTheDocument(); + expect(analyzablesInput.id).toBe("analyzables-0"); + expect(analyzablesInput.value).toBe(""); + expect(screen.getByText("Type:")).toBeInTheDocument(); + expect(screen.getByText("Matches:")).toBeInTheDocument(); + expect(screen.getByText("supported only for wildcard")).toBeInTheDocument(); + const evaluationInput = screen.getAllByRole("combobox")[0]; + expect(evaluationInput).toBeInTheDocument(); + const commentsInput = screen.getAllByRole("textbox")[1]; + expect(commentsInput).toBeInTheDocument(); + expect(commentsInput.id).toBe("related_threats-0"); + expect(commentsInput.value).toBe(""); + const externalReferencesInput = screen.getAllByRole("textbox")[2]; + expect(externalReferencesInput).toBeInTheDocument(); + expect(externalReferencesInput.id).toBe("external_references-0"); + const killChainPhaseInput = screen.getAllByRole("combobox")[1]; + expect(killChainPhaseInput).toBeInTheDocument(); + const tagsInput = screen.getAllByRole("combobox")[2]; + expect(tagsInput).toBeInTheDocument(); + const advancedFieldsButton = screen.getByRole("button", { + name: /Advanced fields/i, + }); + expect(advancedFieldsButton).toBeInTheDocument(); + const saveButton = screen.getByRole("button", { name: /Save/i }); + expect(saveButton).toBeInTheDocument(); + expect(saveButton.className).toContain("disabled"); + + // add analyzable + fireEvent.change(analyzablesInput, { target: { value: "test.com" } }); + expect(analyzablesInput.value).toBe("test.com"); + // add evaluation + fireEvent.change(evaluationInput, { target: { value: "malicious" } }); + expect(screen.getByText("MALICIOUS")).toBeInTheDocument(); + // add comment + fireEvent.change(commentsInput, { target: { value: "my comment" } }); + expect(commentsInput.value).toBe("my comment"); + // add tags (2 of them) + await userEvent.click(tagsInput); + await userEvent.click(screen.getByText("phishing")); + expect(screen.getByText("phishing")).toBeInTheDocument(); + expect(screen.queryByText("malware")).not.toBeInTheDocument(); // check other option are not visible + await userEvent.click(tagsInput); + await userEvent.click(screen.getByText("malware")); + expect(screen.getByText("malware")).toBeInTheDocument(); + expect(screen.queryByText("abused")).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 + // change reliability + await userEvent.click(advancedFieldsButton); + const reliabilityInput = screen.getByRole("spinbutton", { + name: /reliability/i, + }); + expect(reliabilityInput).toBeInTheDocument(); + userEvent.clear(reliabilityInput); // remove previous text, or type() works in append + userEvent.type(reliabilityInput, "9"); + + // IMPORTANT - wait for the state change + await screen.findByText("artifact"); + + expect(saveButton.className).not.toContain("disabled"); + + await user.click(saveButton); + await waitFor(() => { + expect(axios.get).toHaveBeenCalledWith( + `${`${USER_EVENT_ANALYZABLE}?username=test&analyzable_name=test.com`}`, + ); + expect(axios.post).toHaveBeenCalledWith(`${USER_EVENT_ANALYZABLE}`, { + analyzable: { name: "test.com" }, + data_model_content: { + evaluation: "malicious", + related_threats: ["my comment"], + reliability: 9, + kill_chain_phase: "action", + tags: ["phishing", "malware"], + }, + decay_progression: "0", + decay_timedelta_days: 120, + }); + }); + }); }); diff --git a/tests/api_app/analyzers_manager/test_views.py b/tests/api_app/analyzers_manager/test_views.py index 5c27343d06..01362a2fd4 100644 --- a/tests/api_app/analyzers_manager/test_views.py +++ b/tests/api_app/analyzers_manager/test_views.py @@ -176,6 +176,102 @@ def test_delete(self): response = self.client.delete(f"{self.URL}/{ac1.name}") self.assertEqual(response.status_code, 204) + def test_get(self): + # 1 - existing analyzer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Quad9_DNS") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 30}, + "description": "Retrieve current domain resolution with Quad9 DoH (DNS over " + "HTTPS)", + "disabled": True, + "docker_based": False, + "id": 101, + "mapping_data_model": {}, + "maximum_tlp": "AMBER", + "name": "Quad9_DNS", + "not_supported_filetypes": [], + "observable_supported": ["domain", "url"], + "parameters": { + "query_type": { + "description": "Query type against the chosen " "DNS resolver.", + "id": 206, + "is_secret": False, + "required": False, + "type": "str", + "value": None, + } + }, + "python_module": "dns.dns_resolvers.quad9_dns_resolver.Quad9DNSResolver", + "run_hash": False, + "run_hash_type": "", + "supported_filetypes": [], + "type": "observable", + }, + ) + # 2 - missing analyzer + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No AnalyzerConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing analyzer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Quad9_DNS/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + result = response.json() + result["user_config"][0].pop( + "updated_at" + ) # auto filled by the model and hard to mock + self.assertEqual( + result, + { + "organization_config": [], + "user_config": [ + { + "analyzer_config": "Quad9_DNS", + "attribute": "query_type", + "connector_config": None, + "description": "Query type against the chosen DNS resolver.", + "exist": True, + "for_organization": False, + "id": 159, + "ingestor_config": None, + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 206, + "pivot_config": None, + "required": False, + "type": "str", + "value": "A", + "visualizer_config": None, + } + ], + }, + ) + # 2 - existing analyzer, no config + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Quad9_Malicious_Detector/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), {"organization_config": [], "user_config": []} + ) + # 3 - missing analyzer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/missing_analyzer/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"analyzer config": "Requested plugin does not exist."}}, + ) + class AnalyzerActionViewSetTests(CustomViewSetTestCase, PluginActionViewsetTestCase): fixtures = [ diff --git a/tests/api_app/connectors_manager/test_views.py b/tests/api_app/connectors_manager/test_views.py index e9cf499a0b..575c83e497 100644 --- a/tests/api_app/connectors_manager/test_views.py +++ b/tests/api_app/connectors_manager/test_views.py @@ -48,6 +48,96 @@ def test_health_check(self): pc1.delete() pc2.delete() + def test_get(self): + # 1 - existing connector + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Slack") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 60}, + "description": "Send the analysis link to a slack channel", + "disabled": True, + "id": 3, + "maximum_tlp": "RED", + "name": "Slack", + "python_module": 3, + "run_on_failure": True, + }, + ) + # 2 - missing connector + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No ConnectorConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing connector + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Slack/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + result = response.json() + # auto filled by the model and hard to mock + for user_config in result["user_config"]: + user_config.pop("updated_at", "") + self.assertEqual( + result, + { + "organization_config": [], + "user_config": [ + { + "analyzer_config": None, + "attribute": "slack_username", + "connector_config": "Slack", + "description": "Slack username to tag on the message", + "exist": True, + "for_organization": False, + "id": 8, + "ingestor_config": None, + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 12, + "pivot_config": None, + "required": False, + "type": "str", + "value": "", + "visualizer_config": None, + }, + { + "attribute": "token", + "description": "Slack token for authentication", + "exist": False, + "is_secret": True, + "parameter": 13, + "required": True, + "type": "str", + "value": None, + }, + { + "attribute": "channel", + "description": "Slack channel to send messages", + "exist": False, + "is_secret": True, + "parameter": 14, + "required": True, + "type": "str", + "value": None, + }, + ], + }, + ) + # 2 - missing connector + response = self.client.get(f"{self.URL}/missing_connector/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"connector config": "Requested plugin does not exist."}}, + ) + class ConnectorActionViewSetTests(CustomViewSetTestCase, PluginActionViewsetTestCase): fixtures = [ diff --git a/tests/api_app/ingestors_manager/test_views.py b/tests/api_app/ingestors_manager/test_views.py new file mode 100644 index 0000000000..c89630fc82 --- /dev/null +++ b/tests/api_app/ingestors_manager/test_views.py @@ -0,0 +1,195 @@ +# This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl +# See the file 'LICENSE' for copying permission. + +from api_app.ingestors_manager.models import IngestorConfig +from certego_saas.apps.organization.organization import Membership, Organization +from tests import CustomViewSetTestCase +from tests.api_app.test_views import AbstractConfigViewSetTestCaseMixin + + +class IngestorConfigViewSetTestCase( + AbstractConfigViewSetTestCaseMixin, CustomViewSetTestCase +): + URL = "/api/ingestor" + + @classmethod + @property + def model_class(cls) -> IngestorConfig: + return IngestorConfig + + def test_organization_disable(self): + org, _ = Organization.objects.get_or_create(name="test") + Membership.objects.get_or_create( + user=self.user, organization=org, is_owner=False + ) + response = self.client.post(f"{self.URL}/GreedyBear/organization") + self.assertEqual(response.status_code, 404) + + def test_organization_enable(self): + org, _ = Organization.objects.get_or_create(name="test") + Membership.objects.get_or_create( + user=self.user, organization=org, is_owner=False + ) + response = self.client.delete(f"{self.URL}/GreedyBear/organization") + self.assertEqual(response.status_code, 404) + + def test_get(self): + # 1 - existing ingestor + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/GreedyBear") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 60}, + "delay": "00:00:00", + "description": "Queries feeds which are generated by the [GreedyBear " + "Project](https://intelowlproject.github.io/docs/GreedyBear/Introduction/).", + "disabled": True, + "health_check_status": True, + "health_check_task": None, + "id": 4, + "maximum_jobs": 50, + "name": "GreedyBear", + "playbooks_choice": ["Popular_IP_Reputation_Services"], + "python_module": 215, + "routing_key": "ingestor", + "schedule": { + "day_of_month": "*", + "day_of_week": "*", + "hour": "0", + "minute": "0", + "month_of_year": "*", + }, + "soft_time_limit": 60, + }, + ) + # 2 - missing ingestor + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No IngestorConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing ingestor + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/GreedyBear/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + result = response.json() + # auto filled by the model and hard to mock + for user_config in result["user_config"]: + user_config.pop("updated_at", "") + self.assertEqual( + result, + { + "organization_config": [], + "user_config": [ + { + "analyzer_config": None, + "attribute": "url", + "connector_config": None, + "description": "API endpoint", + "exist": True, + "for_organization": False, + "id": 353, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 538, + "pivot_config": None, + "required": False, + "type": "str", + "value": "https://greedybear.honeynet.org", + "visualizer_config": None, + }, + { + "analyzer_config": None, + "attribute": "limit", + "connector_config": None, + "description": "Max number of results.", + "exist": True, + "for_organization": False, + "id": 354, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 539, + "pivot_config": None, + "required": False, + "type": "int", + "value": 50, + "visualizer_config": None, + }, + { + "analyzer_config": None, + "attribute": "feed_type", + "connector_config": None, + "description": "The available feed types are log4j, cowrie, " + "and all.", + "exist": True, + "for_organization": False, + "id": 355, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 540, + "pivot_config": None, + "required": False, + "type": "str", + "value": "all", + "visualizer_config": None, + }, + { + "analyzer_config": None, + "attribute": "attack_type", + "connector_config": None, + "description": "The available attack_type are scanner, " + "payload_request, and all.", + "exist": True, + "for_organization": False, + "id": 356, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 541, + "pivot_config": None, + "required": False, + "type": "str", + "value": "all", + "visualizer_config": None, + }, + { + "analyzer_config": None, + "attribute": "age", + "connector_config": None, + "description": "The available age are recent and persistent.", + "exist": True, + "for_organization": False, + "id": 357, + "ingestor_config": "GreedyBear", + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 542, + "pivot_config": None, + "required": False, + "type": "str", + "value": "recent", + "visualizer_config": None, + }, + ], + }, + ) + # 2 - missing ingestor + response = self.client.get(f"{self.URL}/missing_ingestor/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"ingestor config": "Requested plugin does not exist."}}, + ) diff --git a/tests/api_app/pivots_manager/test_views.py b/tests/api_app/pivots_manager/test_views.py index af3871fc51..ac9f08f855 100644 --- a/tests/api_app/pivots_manager/test_views.py +++ b/tests/api_app/pivots_manager/test_views.py @@ -208,3 +208,90 @@ def test_delete(self): self.client.force_authenticate(m_user.user) response = self.client.delete(f"{self.URL}/{pc1.name}") self.assertEqual(response.status_code, 204) + + def test_get(self): + # 1 - existing pivot + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/AbuseIpToSubmission") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 60}, + "delay": "00:00:00", + "description": "This Plugin leverages results from the Abusix analyzer to " + "extract the abuse contacts of an IP address to pivot to the " + "AbuseSubmitter connector.", + "disabled": True, + "health_check_status": True, + "health_check_task": None, + "id": 1, + "name": "AbuseIpToSubmission", + "parameters": { + "field_to_compare": { + "description": "Dotted path to the field", + "id": 315, + "is_secret": False, + "required": True, + "type": "str", + "value": None, + } + }, + "playbooks_choice": ["Send_Abuse_Email"], + "python_module": "compare.Compare", + "related_analyzer_configs": ["Abusix"], + "related_configs": ["Abusix"], + "related_connector_configs": [], + "routing_key": "default", + "soft_time_limit": 60, + }, + ) + # 2 - missing pivot + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual(result, {"detail": "No PivotConfig matches the given query."}) + + def test_get_config(self): + # 1 - existing pivot + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/AbuseIpToSubmission/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + result = response.json() + result["user_config"][0].pop( + "updated_at" + ) # auto filled by the model and hard to mock + self.assertEqual( + result, + { + "organization_config": [], + "user_config": [ + { + "analyzer_config": None, + "attribute": "field_to_compare", + "connector_config": None, + "description": "Dotted path to the field", + "exist": True, + "for_organization": False, + "id": 291, + "ingestor_config": None, + "is_secret": False, + "organization": None, + "owner": None, + "parameter": 315, + "pivot_config": "AbuseIpToSubmission", + "required": True, + "type": "str", + "value": "abuse_contacts.0", + "visualizer_config": None, + } + ], + }, + ) + # 3 - missing pivot + response = self.client.get(f"{self.URL}/missing_pivot/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"pivot config": "Requested plugin does not exist."}}, + ) diff --git a/tests/api_app/playbooks_manager/test_views.py b/tests/api_app/playbooks_manager/test_views.py index 96147c57c7..14f2fceb4c 100644 --- a/tests/api_app/playbooks_manager/test_views.py +++ b/tests/api_app/playbooks_manager/test_views.py @@ -164,3 +164,63 @@ def test_create(self): pc.delete() finally: ac.delete() + + def test_get(self): + # 1 - existing visualizer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Dns") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "analyzers": [ + "AdGuard", + "Classic_DNS", + "CloudFlare_DNS", + "CloudFlare_Malicious_Detector", + "DNS0_EU", + "DNS0_EU_Malicious_Detector", + "Google_DNS", + "Quad9_DNS", + "Quad9_Malicious_Detector", + "UltraDNS_DNS", + "UltraDNS_Malicious_Detector", + ], + "connectors": [], + "description": "Retrieve information from DNS about the domain", + "disabled": False, + "for_organization": False, + "id": 1, + "is_editable": False, + "name": "Dns", + "owner": None, + "pivots": [], + "runtime_configuration": { + "analyzers": {}, + "connectors": {}, + "pivots": {}, + "visualizers": {}, + }, + "scan_check_time": "1:00:00:00", + "scan_mode": 2, + "starting": True, + "tags": [], + "tlp": "AMBER", + "type": ["domain"], + "visualizers": ["DNS"], + "weight": 0, + }, + ) + # 2 - missing playbook + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No PlaybookConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing playbook + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/Dns/plugin_config") + self.assertEqual(response.status_code, 404, response.content) diff --git a/tests/api_app/visualizers_manager/test_views.py b/tests/api_app/visualizers_manager/test_views.py index b79fb8ebea..0d5d523916 100644 --- a/tests/api_app/visualizers_manager/test_views.py +++ b/tests/api_app/visualizers_manager/test_views.py @@ -16,3 +16,45 @@ class VisualizerConfigViewSetTestCase( @property def model_class(cls) -> Type[VisualizerConfig]: return VisualizerConfig + + def test_get(self): + # 1 - existing visualizer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/DNS") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), + { + "config": {"queue": "default", "soft_time_limit": 60}, + "description": "Visualize information about DNS resolvers and DNS malicious " + "detectors", + "disabled": True, + "id": 1, + "name": "DNS", + "playbooks": ["Dns"], + "python_module": 128, + }, + ) + # 2 - missing visualizer + response = self.client.get(f"{self.URL}/non_existing") + self.assertEqual(response.status_code, 404, response.content) + result = response.json() + self.assertEqual( + result, {"detail": "No VisualizerConfig matches the given query."} + ) + + def test_get_config(self): + # 1 - existing visualizer + self.client.force_authenticate(user=self.user) + response = self.client.get(f"{self.URL}/DNS/plugin_config") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + response.json(), {"organization_config": [], "user_config": []} + ) + # 2 - missing visualizer + response = self.client.get(f"{self.URL}/missing_visualizer/plugin_config") + self.assertEqual(response.status_code, 404, response.content) + self.assertEqual( + response.json(), + {"errors": {"visualizer config": "Requested plugin does not exist."}}, + )