Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Files rescan and pivot #121

Open
wants to merge 15 commits into
base: old/develop
Choose a base branch
from
1 change: 1 addition & 0 deletions api_app/serializers/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ class Meta:
"playbook",
"status",
"received_request_time",
"is_sample",
]

playbook = rfs.SlugRelatedField(
Expand Down
33 changes: 32 additions & 1 deletion api_app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet

from api_app.choices import ScanMode
from api_app.websocket import JobConsumer
from certego_saas.apps.organization.permissions import (
IsObjectOwnerOrSameOrgPermission as IsObjectUserOrSameOrgPermission,
Expand Down Expand Up @@ -452,7 +453,7 @@ def get_permissions(self):
- List of applicable permissions.
"""
permissions = super().get_permissions()
if self.action in ["destroy", "kill"]:
if self.action in ["destroy", "kill", "rescan"]:
permissions.append(IsObjectUserOrSameOrgPermission())
return permissions

Expand Down Expand Up @@ -541,6 +542,36 @@ def retry(self, request, pk=None):
job.retry()
return Response(status=status.HTTP_204_NO_CONTENT)

@action(detail=True, methods=["post"])
def rescan(self, request, pk=None):
logger.info(f"rescan request for job: {pk}")
existing_job: Job = self.get_object()
Comment on lines +545 to +548
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add a permission check before allowing job rescan [Security, importance: 8]

Suggested change
@action(detail=True, methods=["post"])
def rescan(self, request, pk=None):
logger.info(f"rescan request for job: {pk}")
existing_job: Job = self.get_object()
@action(detail=True, methods=["post"])
def rescan(self, request, pk=None):
logger.info(f"rescan request for job: {pk}")
existing_job: Job = self.get_object()
if not request.user.has_perm('api_app.rescan_job', existing_job):
raise PermissionDenied("You do not have permission to rescan this job.")

Comment on lines +545 to +548
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add a permission check before allowing job rescan [Security, importance: 8]

Suggested change
@action(detail=True, methods=["post"])
def rescan(self, request, pk=None):
logger.info(f"rescan request for job: {pk}")
existing_job: Job = self.get_object()
@action(detail=True, methods=["post"])
def rescan(self, request, pk=None):
logger.info(f"rescan request for job: {pk}")
existing_job: Job = self.get_object()
if not request.user.has_perm('api_app.rescan_job', existing_job):
raise PermissionDenied("You do not have permission to rescan this job.")

# create a new job
data = {
"tlp": existing_job.tlp,
"runtime_configuration": existing_job.runtime_configuration,
"scan_mode": ScanMode.FORCE_NEW_ANALYSIS,
}
if existing_job.playbook_requested:
data["playbook_requested"] = existing_job.playbook_requested
else:
data["analyzers_requested"] = existing_job.analyzers_requested.all()
data["connectors_requested"] = existing_job.connectors_requested.all()
if existing_job.is_sample:
data["file"] = existing_job.file
data["file_name"] = existing_job.file_name
job_serializer = FileJobSerializer(data=data, context={"request": request})
else:
data["observable_classification"] = existing_job.observable_classification
data["observable_name"] = existing_job.observable_name
job_serializer = ObservableAnalysisSerializer(
data=data, context={"request": request}
)
job_serializer.is_valid(raise_exception=True)
new_job = job_serializer.save(send_task=True)
Comment on lines +570 to +571
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add error handling for job serializer operations [Error handling, importance: 7]

Suggested change
job_serializer.is_valid(raise_exception=True)
new_job = job_serializer.save(send_task=True)
try:
job_serializer.is_valid(raise_exception=True)
new_job = job_serializer.save(send_task=True)
except serializers.ValidationError as e:
logger.error(f"Validation error during job rescan: {e}")
raise ValidationError(f"Failed to create new job: {e}")
except Exception as e:
logger.error(f"Unexpected error during job rescan: {e}")
raise ValidationError("An unexpected error occurred while creating the new job.")

Comment on lines +570 to +571
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add error handling for job serializer operations [Error handling, importance: 7]

Suggested change
job_serializer.is_valid(raise_exception=True)
new_job = job_serializer.save(send_task=True)
try:
job_serializer.is_valid(raise_exception=True)
new_job = job_serializer.save(send_task=True)
except serializers.ValidationError as e:
logger.error(f"Validation error during job rescan: {e}")
raise ValidationError(f"Failed to create new job: {e}")
except Exception as e:
logger.error(f"Unexpected error during job rescan: {e}")
raise ValidationError("An unexpected error occurred while creating the new job.")

logger.info(f"rescan request for job: {pk} generated job: {new_job.pk}")
return Response(data={"id": new_job.pk}, status=status.HTTP_202_ACCEPTED)

@add_docs(
description="Kill running job by closing celery tasks and marking as killed",
request=None,
Expand Down
2 changes: 2 additions & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Ignore artifacts:
.coverage
7 changes: 5 additions & 2 deletions frontend/src/components/investigations/flow/CustomJobNode.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ function CustomJobNode({ data }) {
id="investigation-pivotbtn"
className="mx-1 p-2"
size="sm"
href={`/scan?parent=${data.id}&observable=${data.name}`}
href={`/scan?parent=${data.id}&${
data.is_sample ? "isSample=true" : `observable=${data.name}`
}`}
target="_blank"
rel="noreferrer"
>
Expand All @@ -67,7 +69,8 @@ function CustomJobNode({ data }) {
placement="top"
fade={false}
>
Analyze the same observable again
Analyze the same observable again. CAUTION! Samples require to
select again the file.
</UncontrolledTooltip>
{data.isFirstLevel && <RemoveJob data={data} />}
</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/investigations/flow/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function addJobNode(
investigation: investigationId,
children: job.children || [],
status: job.status,
is_sample: job.is_sample,
refetchTree,
refetchInvestigation,
isFirstLevel: isFirstLevel || false,
Expand Down
34 changes: 5 additions & 29 deletions frontend/src/components/jobs/result/bar/JobActionBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ import { ContentSection, IconButton, addToast } from "@certego/certego-ui";

import { SaveAsPlaybookButton } from "./SaveAsPlaybooksForm";

import { downloadJobSample, deleteJob } from "../jobApi";
import { createJob } from "../../../scan/scanApi";
import { ScanModesNumeric } from "../../../../constants/advancedSettingsConst";
import { downloadJobSample, deleteJob, rescanJob } from "../jobApi";
import { JobResultSections } from "../../../../constants/miscConst";
import {
DeleteIcon,
Expand Down Expand Up @@ -53,33 +51,11 @@ export function JobActionsBar({ job }) {
};

const handleRetry = async () => {
if (job.is_sample) {
addToast(
"Rescan File!",
"It's not possible to repeat a sample analysis",
"warning",
false,
2000,
);
} else {
addToast("Retrying the same job...", null, "spinner", false, 2000);
const response = await createJob(
[job.observable_name],
job.observable_classification,
job.playbook_requested,
job.analyzers_requested,
job.connectors_requested,
job.runtime_configuration,
job.tags.map((optTag) => optTag.label),
job.tlp,
ScanModesNumeric.FORCE_NEW_ANALYSIS,
0,
);
addToast("Retrying the same job...", null, "spinner", false, 2000);
const newJobId = await rescanJob(job.id);
if (newJobId) {
setTimeout(
() =>
navigate(
`/jobs/${response.jobIds[0]}/${JobResultSections.VISUALIZER}/`,
),
() => navigate(`/jobs/${newJobId}/${JobResultSections.VISUALIZER}/`),
1000,
);
}
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/components/jobs/result/jobApi.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,33 @@ export async function deleteJob(jobId) {
return success;
}

export async function rescanJob(jobId) {
try {
const response = await axios.post(`${JOB_BASE_URI}/${jobId}/rescan`);
const newJobId = response.data.id;
if (response.status === 202) {
addToast(
<span>
Sent rescan request for job #{jobId}. Created job #{newJobId}.
</span>,
null,
"success",
2000,
);
}
return newJobId;
} catch (error) {
addToast(
<span>
Failed. Operation: <em>rescan job #{jobId}</em>
</span>,
error.parsedMsg,
"warning",
);
return null;
}
}

export async function killPlugin(jobId, plugin) {
const sure = await areYouSureConfirmDialog(
`kill ${plugin.type} '${plugin.name}'`,
Expand Down
51 changes: 25 additions & 26 deletions frontend/src/components/scan/ScanForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function DangerErrorMessage(fieldName) {
export default function ScanForm() {
const [searchParams, _] = useSearchParams();
const observableParam = searchParams.get(JobTypes.OBSERVABLE);
const isSampleParam = searchParams.get("isSample") === "true";
const investigationIdParam = searchParams.get("investigation") || null;
const parentIdParam = searchParams.get("parent");
const { guideState, setGuideState } = useGuideContext();
Expand Down Expand Up @@ -416,6 +417,23 @@ export default function ScanForm() {
}))
.filter((item) => !item.isDisabled && item.starting);

const selectObservableType = (value) => {
formik.setFieldValue("observableType", value, false);
formik.setFieldValue(
"classification",
value === JobTypes.OBSERVABLE
? ObservableClassifications.GENERIC
: JobTypes.FILE,
);
formik.setFieldValue("observable_names", [""], false);
formik.setFieldValue("files", [""], false);
formik.setFieldValue("analysisOptionValues", ScanTypes.playbooks, false);
setScanType(ScanTypes.playbooks);
formik.setFieldValue("playbook", "", false); // reset
formik.setFieldValue("analyzers", [], false); // reset
formik.setFieldValue("connectors", [], false); // reset
};

const updateAdvancedConfig = (
tags,
tlp,
Expand Down Expand Up @@ -535,9 +553,11 @@ export default function ScanForm() {
if (observableParam) {
updateSelectedObservable(observableParam, 0);
if (formik.playbook) updateSelectedPlaybook(formik.playbook);
} else if (isSampleParam) {
selectObservableType(JobTypes.FILE);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [observableParam, playbooksLoading]);
}, [observableParam, playbooksLoading, isSampleParam]);

/* With the setFieldValue the validation and rerender don't work properly: the last update seems to not trigger the validation
and leaves the UI with values not valid, for this reason the scan button is disabled, but if the user set focus on the UI the last
Expand Down Expand Up @@ -614,30 +634,9 @@ export default function ScanForm() {
type="radio"
name="observableType"
value={jobType}
onClick={(event) => {
formik.setFieldValue(
"observableType",
event.target.value,
false,
);
formik.setFieldValue(
"classification",
event.target.value === JobTypes.OBSERVABLE
? ObservableClassifications.GENERIC
: JobTypes.FILE,
);
formik.setFieldValue("observable_names", [""], false);
formik.setFieldValue("files", [""], false);
formik.setFieldValue(
"analysisOptionValues",
ScanTypes.playbooks,
false,
);
setScanType(ScanTypes.playbooks);
formik.setFieldValue("playbook", "", false); // reset
formik.setFieldValue("analyzers", [], false); // reset
formik.setFieldValue("connectors", [], false); // reset
}}
onClick={(event) =>
selectObservableType(event.target.value)
}
/>
<Label check>
{jobType === JobTypes.OBSERVABLE
Expand Down Expand Up @@ -923,7 +922,7 @@ export default function ScanForm() {
<br />
For more info check the{" "}
<Link
to="https://khulnasoft.github.io/docs/ThreatMatrix/usage/#tlp-support"
to="https://khulnasoft.github.io/ThreatMatrix/docs/usage/#tlp-support"
target="_blank"
>
official doc.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe("test InvestigationFlow", () => {
analyzed_object_name: "test1.com",
playbook: "Dns",
status: "reported_without_fails",
is_sample: false,
children: [],
},
],
Expand Down Expand Up @@ -125,13 +126,15 @@ describe("test InvestigationFlow", () => {
analyzed_object_name: "test1.com",
playbook: "Dns",
status: "reported_without_fails",
is_sample: false,
children: [],
},
{
pk: 20,
analyzed_object_name: "test2.com",
playbook: "Dns",
status: "reported_without_fails",
is_sample: false,
children: [],
},
],
Expand Down Expand Up @@ -171,12 +174,14 @@ describe("test InvestigationFlow", () => {
analyzed_object_name: "test1.com",
playbook: "Dns",
status: "reported_without_fails",
is_sample: false,
children: [
{
pk: 11,
analyzed_object_name: "test11.com",
playbook: "Dns",
status: "reported_without_fails",
is_sample: false,
children: [],
},
],
Expand Down Expand Up @@ -255,6 +260,7 @@ describe("test InvestigationFlow", () => {
playbook: "Dns",
status: "reported_without_fails",
received_request_time: "2024-04-03T13:08:45.417245Z",
is_sample: false,
children: [
{
pk: 11,
Expand All @@ -263,9 +269,18 @@ describe("test InvestigationFlow", () => {
status: "reported_without_fails",
children: [],
received_request_time: "2024-04-03T13:09:45.417245Z",
is_sample: false,
},
],
},
{
pk: 12,
analyzed_object_name: "test.sh",
playbook: null,
status: "reported_without_fails",
received_request_time: "2024-08-23T10:03:27.489939Z",
is_sample: true,
},
],
}}
investigationId={1}
Expand All @@ -280,16 +295,11 @@ describe("test InvestigationFlow", () => {
expect(rootNode).toBeInTheDocument();
expect(rootNode.textContent).toBe("My test");

// first job node
// observable job node
const firstJobNode = container.querySelector("#job-10");
expect(firstJobNode).toBeInTheDocument();
expect(firstJobNode.textContent).toBe("test1.com");

// pivot node
const secondJobNode = container.querySelector("#job-11");
expect(secondJobNode).toBeInTheDocument();
expect(secondJobNode.textContent).toBe("test11.com");

fireEvent.click(firstJobNode);
// first job tollbar
const jobTollbar = container.querySelector("#toolbar-job-10");
Expand Down Expand Up @@ -318,6 +328,11 @@ describe("test InvestigationFlow", () => {
expect(jobInfo.textContent).toContain("Playbook:Dns");
expect(jobInfo.textContent).toContain("Created:");

// observable child node
const secondJobNode = container.querySelector("#job-11");
expect(secondJobNode).toBeInTheDocument();
expect(secondJobNode.textContent).toBe("test11.com");

fireEvent.click(secondJobNode);
// pivot tollbar
const secondJobTollbar = container.querySelector("#toolbar-job-11");
Expand Down Expand Up @@ -345,5 +360,34 @@ describe("test InvestigationFlow", () => {
expect(secondJobInfo.textContent).toContain("Name:test11.com");
expect(secondJobInfo.textContent).toContain("Playbook:Dns");
expect(jobInfo.textContent).toContain("Created:");

const fileJobNode = container.querySelector("#job-12");
expect(fileJobNode).toBeInTheDocument();
expect(fileJobNode.textContent).toBe("test.sh");
fireEvent.click(fileJobNode);
// file job tollbar
const fileJobTollbar = container.querySelector("#toolbar-job-12");
expect(fileJobTollbar).toBeInTheDocument();
const removeFileJobButton = screen.getByRole("button", {
name: "Remove Branch",
});
expect(removeFileJobButton).toBeInTheDocument();
const linkFileJobButton = screen.getByRole("link", { name: "Link" });
expect(linkFileJobButton).toBeInTheDocument();
const fileJobPivotButton = screen.getByRole("link", { name: "Pivot" });
expect(fileJobPivotButton).toBeInTheDocument();
const fileJobCopyButton = screen.getByRole("button", { name: "Copy" });
expect(fileJobCopyButton).toBeInTheDocument();
// link to job page
expect(linkFileJobButton.href).toContain("/jobs/12/visualizer");
// link pivot
expect(fileJobPivotButton.href).toContain("/scan?parent=12&isSample=true");
// job info
const fileJobInfo = container.querySelector("#job12-info");
expect(fileJobInfo).toBeInTheDocument();
expect(fileJobInfo.textContent).toContain("Job:#12");
expect(fileJobInfo.textContent).toContain("Name:test.sh");
expect(fileJobInfo.textContent).toContain("Playbook:");
expect(fileJobInfo.textContent).toContain("Created:");
});
});
Loading
Loading