From ec310d3f05e6a4e9d72b106b419066a0177149a0 Mon Sep 17 00:00:00 2001 From: Srijan Date: Wed, 3 Dec 2025 15:39:50 +0530 Subject: [PATCH 1/7] feat(visualizer): add VisualizableImage backend class. Closes #3023 --- api_app/visualizers_manager/classes.py | 59 +++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/api_app/visualizers_manager/classes.py b/api_app/visualizers_manager/classes.py index f95a4431e6..4f2665867b 100644 --- a/api_app/visualizers_manager/classes.py +++ b/api_app/visualizers_manager/classes.py @@ -146,7 +146,6 @@ def type(self) -> str: class VisualizableDownload(VisualizableObject): - def __init__( self, value: str, @@ -189,6 +188,63 @@ def attributes(self) -> List[str]: ] +class VisualizableImage(VisualizableObject): + """ + A visualizable component for displaying images in the visualizer. + + Supports two image sources: + - url: A URL to fetch the image from (e.g., URLscan screenshot) + - base64: Base64 encoded image data + + At least one of url or base64 must be provided. + If both are provided, url takes precedence. + """ + + def __init__( + self, + value: str = "", + url: str = "", + base64: str = "", + title: str = "", + description: str = "", + max_width: int = 500, + max_height: int = 400, + allow_expand: bool = True, + size: VisualizableSize = VisualizableSize.S_AUTO, + alignment: VisualizableAlignment = VisualizableAlignment.CENTER, + disable: bool = False, + ): + if not url and not base64: + raise ValueError("VisualizableImage requires either 'url' or 'base64'") + + super().__init__(size, alignment, disable) + self.value = value or title # value is used as alt text + self.url = url + self.base64 = base64 + self.title = title + self.description = description + self.max_width = max_width + self.max_height = max_height + self.allow_expand = allow_expand + + @property + def type(self) -> str: + return "image" + + @property + def attributes(self) -> List[str]: + return super().attributes + [ + "value", + "url", + "base64", + "title", + "description", + "max_width", + "max_height", + "allow_expand", + ] + + class VisualizableBool(VisualizableBase): def __init__( self, @@ -553,7 +609,6 @@ def get_pivots_reports(self) -> QuerySet: return PivotReport.objects.filter(job=self._job) def get_data_models(self) -> QuerySet: - data_model_class = self._job.analyzable.get_data_model_class() analyzer_reports_pk = [report.pk for report in self.get_analyzer_reports()] return data_model_class.objects.filter(analyzers_report__in=analyzer_reports_pk) From d4b6101d56a2b78d03e6b2231731fb7953f410e2 Mon Sep 17 00:00:00 2001 From: Srijan Date: Wed, 3 Dec 2025 15:40:06 +0530 Subject: [PATCH 2/7] feat(frontend): add ImageVisualizer React component --- .../common/visualizer/elements/image.jsx | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 frontend/src/components/common/visualizer/elements/image.jsx diff --git a/frontend/src/components/common/visualizer/elements/image.jsx b/frontend/src/components/common/visualizer/elements/image.jsx new file mode 100644 index 0000000000..1be256a557 --- /dev/null +++ b/frontend/src/components/common/visualizer/elements/image.jsx @@ -0,0 +1,197 @@ +// This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl +// See the file 'LICENSE' for copying permission. + +import React from "react"; +import PropTypes from "prop-types"; +import { Modal, ModalBody, ModalHeader, UncontrolledTooltip } from "reactstrap"; +import { MdBrokenImage, MdZoomIn } from "react-icons/md"; + +/** + * ImageVisualizer component for displaying images in the visualizer. + * Supports both URL and base64 encoded images. + * Includes click-to-zoom functionality with a modal preview. + */ +export function ImageVisualizer({ + id, + url, + base64, + title, + description, + maxWidth, + maxHeight, + allowExpand, + disable, + size, +}) { + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [hasError, setHasError] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(true); + + // Determine the image source - URL takes precedence over base64 + const imageSrc = React.useMemo(() => { + if (url) return url; + if (base64) { + // Handle base64 with or without data URI prefix + if (base64.startsWith("data:")) { + return base64; + } + // Default to PNG if no prefix provided + return `data:image/png;base64,${base64}`; + } + return null; + }, [url, base64]); + + const toggleModal = () => { + if (!disable && allowExpand && !hasError) { + setIsModalOpen(!isModalOpen); + } + }; + + const handleImageLoad = () => { + setIsLoading(false); + setHasError(false); + }; + + const handleImageError = () => { + setIsLoading(false); + setHasError(true); + }; + + // Reset states when image source changes + React.useEffect(() => { + setIsLoading(true); + setHasError(false); + }, [imageSrc]); + + const containerStyle = { + maxWidth: maxWidth || 500, + maxHeight: maxHeight || 400, + overflow: "hidden", + display: "flex", + flexDirection: "column", + alignItems: "center", + }; + + const imageStyle = { + maxWidth: "100%", + maxHeight: maxHeight ? maxHeight - 40 : 360, + objectFit: "contain", + cursor: !disable && allowExpand && !hasError ? "pointer" : "default", + opacity: disable ? 0.5 : 1, + }; + + const renderImage = () => { + if (hasError) { + return ( +
+ + Failed to load image +
+ ); + } + + return ( + <> + {isLoading && ( +
+ + Loading... +
+ )} + {title { + if ((e.key === "Enter" || e.key === " ") && allowExpand && !disable) { + toggleModal(); + } + }} + /> + + ); + }; + + if (!imageSrc) { + return ( +
+ No image source provided +
+ ); + } + + return ( +
+ {title &&
{title}
} +
+ {renderImage()} + {!hasError && !isLoading && allowExpand && !disable && ( + <> + + + Click to enlarge + + + )} +
+ {description && ( +
{description}
+ )} + + {/* Modal for expanded view */} + + {title || "Image Preview"} + + {title + {description &&

{description}

} +
+
+
+ ); +} + +ImageVisualizer.propTypes = { + id: PropTypes.string.isRequired, + url: PropTypes.string, + base64: PropTypes.string, + title: PropTypes.string, + description: PropTypes.string, + maxWidth: PropTypes.number, + maxHeight: PropTypes.number, + allowExpand: PropTypes.bool, + disable: PropTypes.bool, + size: PropTypes.string, +}; + +ImageVisualizer.defaultProps = { + url: "", + base64: "", + title: "", + description: "", + maxWidth: 500, + maxHeight: 400, + allowExpand: true, + disable: false, + size: "col-auto", +}; From da2443be478581366a0b570ae1519a603d19636c Mon Sep 17 00:00:00 2001 From: Srijan Date: Wed, 3 Dec 2025 15:40:26 +0530 Subject: [PATCH 3/7] feat(frontend): integrate ImageVisualizer in visualizer parser --- .../common/visualizer/elements/const.js | 1 + .../common/visualizer/validators.js | 10 ++++++++++ .../common/visualizer/visualizer.jsx | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/frontend/src/components/common/visualizer/elements/const.js b/frontend/src/components/common/visualizer/elements/const.js index 71bb52d57b..827b90bd10 100644 --- a/frontend/src/components/common/visualizer/elements/const.js +++ b/frontend/src/components/common/visualizer/elements/const.js @@ -6,4 +6,5 @@ export const VisualizerComponentType = Object.freeze({ TITLE: "title", TABLE: "table", DOWNLOAD: "download", + IMAGE: "image", }); diff --git a/frontend/src/components/common/visualizer/validators.js b/frontend/src/components/common/visualizer/validators.js index cf7a3070a5..c7b019c62d 100644 --- a/frontend/src/components/common/visualizer/validators.js +++ b/frontend/src/components/common/visualizer/validators.js @@ -144,6 +144,16 @@ function parseElementFields(rawElement) { // validation for the elements switch (validatedFields.type) { + case VisualizerComponentType.IMAGE: { + validatedFields.url = parseString(rawElement.url); + validatedFields.base64 = parseString(rawElement.base64); + validatedFields.title = parseString(rawElement.title); + validatedFields.description = parseString(rawElement.description); + validatedFields.maxWidth = rawElement.max_width || 500; + validatedFields.maxHeight = rawElement.max_height || 400; + validatedFields.allowExpand = parseBool(rawElement.allow_expand ?? true); + break; + } case VisualizerComponentType.DOWNLOAD: { validatedFields.value = parseString(rawElement.value); validatedFields.mimetype = parseMimetype(rawElement.mimetype); diff --git a/frontend/src/components/common/visualizer/visualizer.jsx b/frontend/src/components/common/visualizer/visualizer.jsx index 76b6d00482..afeceec011 100644 --- a/frontend/src/components/common/visualizer/visualizer.jsx +++ b/frontend/src/components/common/visualizer/visualizer.jsx @@ -8,6 +8,7 @@ import { BooleanVisualizer } from "./elements/bool"; import { BaseVisualizer } from "./elements/base"; import { VerticalListVisualizer } from "./elements/verticalList"; import { TitleVisualizer } from "./elements/title"; +import { ImageVisualizer } from "./elements/image"; import { VisualizerComponentType } from "./elements/const"; import { getIcon } from "../icon/icons"; @@ -27,6 +28,24 @@ import { DownloadVisualizer } from "./elements/download"; function convertToElement(element, idElement, isChild = false) { let visualizerElement; switch (element.type) { + case VisualizerComponentType.IMAGE: { + visualizerElement = ( + + ); + break; + } case VisualizerComponentType.DOWNLOAD: { visualizerElement = ( Date: Wed, 3 Dec 2025 15:40:32 +0530 Subject: [PATCH 4/7] test: add tests for VisualizableImage component --- .../common/visualizer/elements/image.test.jsx | 147 ++++++++++++++++++ .../visualizers_manager/test_classes.py | 58 +++++++ 2 files changed, 205 insertions(+) create mode 100644 frontend/tests/components/common/visualizer/elements/image.test.jsx diff --git a/frontend/tests/components/common/visualizer/elements/image.test.jsx b/frontend/tests/components/common/visualizer/elements/image.test.jsx new file mode 100644 index 0000000000..30913027dc --- /dev/null +++ b/frontend/tests/components/common/visualizer/elements/image.test.jsx @@ -0,0 +1,147 @@ +// This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl +// See the file 'LICENSE' for copying permission. + +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ImageVisualizer } from "../../../../../src/components/common/visualizer/elements/image"; + +describe("ImageVisualizer component", () => { + test("renders image from URL", () => { + render( + , + ); + + expect(screen.getByText("Test Image")).toBeInTheDocument(); + expect(screen.getByText("A test description")).toBeInTheDocument(); + expect(screen.getByAltText("Test Image")).toBeInTheDocument(); + }); + + test("renders image from base64", () => { + const base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA"; + render( + , + ); + + const img = screen.getByAltText("Base64 Image"); + expect(img).toBeInTheDocument(); + expect(img.src).toContain("data:image/png;base64,"); + }); + + test("renders image from base64 with data URI prefix", () => { + const base64DataUri = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; + render( + , + ); + + const img = screen.getByAltText("JPEG Image"); + expect(img.src).toBe(base64DataUri); + }); + + test("shows error message when no source provided", () => { + render(); + + expect(screen.getByText("No image source provided")).toBeInTheDocument(); + }); + + test("renders with disabled state", () => { + render( + , + ); + + const img = screen.getByAltText("Disabled Image"); + expect(img).toHaveStyle({ opacity: "0.5" }); + }); + + test("opens modal on image click when allowExpand is true", async () => { + render( + , + ); + + // Simulate image load + const img = screen.getByAltText("Expandable Image"); + fireEvent.load(img); + + // Click the image + fireEvent.click(img); + + // Modal should be open + await waitFor(() => { + expect(screen.getByText("Image Preview")).toBeInTheDocument(); + }); + }); + + test("does not open modal when allowExpand is false", () => { + render( + , + ); + + // Simulate image load + const img = screen.getByAltText("Non-expandable Image"); + fireEvent.load(img); + + // Click the image + fireEvent.click(img); + + // Modal should not be open + expect(screen.queryByText("Image Preview")).not.toBeInTheDocument(); + }); + + test("shows loading state initially", () => { + render( + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + test("shows error state when image fails to load", async () => { + render( + , + ); + + // Simulate image error + const img = screen.getByAltText("Broken Image"); + fireEvent.error(img); + + await waitFor(() => { + expect(screen.getByText("Failed to load image")).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/api_app/visualizers_manager/test_classes.py b/tests/api_app/visualizers_manager/test_classes.py index ff41fb21d5..e9b3e70023 100644 --- a/tests/api_app/visualizers_manager/test_classes.py +++ b/tests/api_app/visualizers_manager/test_classes.py @@ -14,6 +14,7 @@ VisualizableBool, VisualizableDownload, VisualizableHorizontalList, + VisualizableImage, VisualizableLevel, VisualizableLevelSize, VisualizableObject, @@ -677,3 +678,60 @@ def test_to_dict(self): "disable_sort_by": True, } self.assertEqual(expected_result, result) + + +class VisualizableImageTestCase(CustomTestCase): + def test_to_dict_with_url(self): + img = VisualizableImage( + url="https://example.com/image.png", + title="Test Image", + description="A test image", + max_width=400, + max_height=300, + allow_expand=True, + ) + result = img.to_dict() + + self.assertEqual(result["type"], "image") + self.assertEqual(result["url"], "https://example.com/image.png") + self.assertEqual(result["title"], "Test Image") + self.assertEqual(result["description"], "A test image") + self.assertEqual(result["max_width"], 400) + self.assertEqual(result["max_height"], 300) + self.assertEqual(result["allow_expand"], True) + + def test_to_dict_with_base64(self): + img = VisualizableImage( + base64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA", + title="Base64 Image", + ) + result = img.to_dict() + + self.assertEqual(result["type"], "image") + self.assertEqual(result["base64"], "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA") + self.assertEqual(result["url"], "") + self.assertEqual(result["title"], "Base64 Image") + + def test_requires_url_or_base64(self): + with self.assertRaises(ValueError) as context: + VisualizableImage(title="No source") + self.assertIn("url", str(context.exception).lower()) + + def test_default_values(self): + img = VisualizableImage(url="https://example.com/img.png") + result = img.to_dict() + + self.assertEqual(result["max_width"], 500) + self.assertEqual(result["max_height"], 400) + self.assertEqual(result["allow_expand"], True) + self.assertEqual(result["disable"], False) + self.assertEqual(result["alignment"], "center") + + def test_disabled_image(self): + img = VisualizableImage( + url="https://example.com/img.png", + disable=True, + ) + result = img.to_dict() + + self.assertEqual(result["disable"], True) From b08456baecf5847641297c14982983d932a7bf60 Mon Sep 17 00:00:00 2001 From: Srijan Date: Wed, 3 Dec 2025 19:14:03 +0530 Subject: [PATCH 5/7] fix(frontend): improve ImageVisualizer accessibility and CSS handling - Wrap clickable image in button element for accessibility (jsx-a11y) - Support string CSS values for maxWidth/maxHeight (e.g., '500px') - Update propTypes to accept both number and string dimensions --- .../common/visualizer/elements/image.jsx | 65 ++++++++++++------- .../common/visualizer/validators.js | 4 +- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/common/visualizer/elements/image.jsx b/frontend/src/components/common/visualizer/elements/image.jsx index 1be256a557..364a38da40 100644 --- a/frontend/src/components/common/visualizer/elements/image.jsx +++ b/frontend/src/components/common/visualizer/elements/image.jsx @@ -64,8 +64,8 @@ export function ImageVisualizer({ }, [imageSrc]); const containerStyle = { - maxWidth: maxWidth || 500, - maxHeight: maxHeight || 400, + maxWidth: maxWidth || "500px", + maxHeight: maxHeight || "400px", overflow: "hidden", display: "flex", flexDirection: "column", @@ -74,7 +74,7 @@ export function ImageVisualizer({ const imageStyle = { maxWidth: "100%", - maxHeight: maxHeight ? maxHeight - 40 : 360, + maxHeight: maxHeight || "360px", objectFit: "contain", cursor: !disable && allowExpand && !hasError ? "pointer" : "default", opacity: disable ? 0.5 : 1, @@ -93,6 +93,16 @@ export function ImageVisualizer({ ); } + const imageElement = ( + {title + ); + return ( <> {isLoading && ( @@ -104,21 +114,22 @@ export function ImageVisualizer({ Loading... )} - {title { - if ((e.key === "Enter" || e.key === " ") && allowExpand && !disable) { - toggleModal(); - } - }} - /> + {allowExpand && !disable ? ( + + ) : ( + imageElement + )} ); }; @@ -157,12 +168,18 @@ export function ImageVisualizer({ {/* Modal for expanded view */} - {title || "Image Preview"} + + {title || "Image Preview"} + {title {description &&

{description}

}
@@ -177,8 +194,8 @@ ImageVisualizer.propTypes = { base64: PropTypes.string, title: PropTypes.string, description: PropTypes.string, - maxWidth: PropTypes.number, - maxHeight: PropTypes.number, + maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), allowExpand: PropTypes.bool, disable: PropTypes.bool, size: PropTypes.string, @@ -189,8 +206,8 @@ ImageVisualizer.defaultProps = { base64: "", title: "", description: "", - maxWidth: 500, - maxHeight: 400, + maxWidth: "500px", + maxHeight: "400px", allowExpand: true, disable: false, size: "col-auto", diff --git a/frontend/src/components/common/visualizer/validators.js b/frontend/src/components/common/visualizer/validators.js index c7b019c62d..73db9353c1 100644 --- a/frontend/src/components/common/visualizer/validators.js +++ b/frontend/src/components/common/visualizer/validators.js @@ -149,8 +149,8 @@ function parseElementFields(rawElement) { validatedFields.base64 = parseString(rawElement.base64); validatedFields.title = parseString(rawElement.title); validatedFields.description = parseString(rawElement.description); - validatedFields.maxWidth = rawElement.max_width || 500; - validatedFields.maxHeight = rawElement.max_height || 400; + validatedFields.maxWidth = parseString(rawElement.max_width || "500px"); + validatedFields.maxHeight = parseString(rawElement.max_height || "400px"); validatedFields.allowExpand = parseBool(rawElement.allow_expand ?? true); break; } From e7eccf88eb091231dc5bc3790fbc494298b0b049 Mon Sep 17 00:00:00 2001 From: Srijan Date: Wed, 3 Dec 2025 19:55:04 +0530 Subject: [PATCH 6/7] fix(frontend): remove container overflow to prevent image clipping --- frontend/src/components/common/visualizer/elements/image.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/components/common/visualizer/elements/image.jsx b/frontend/src/components/common/visualizer/elements/image.jsx index 364a38da40..b8f2f90b13 100644 --- a/frontend/src/components/common/visualizer/elements/image.jsx +++ b/frontend/src/components/common/visualizer/elements/image.jsx @@ -65,8 +65,6 @@ export function ImageVisualizer({ const containerStyle = { maxWidth: maxWidth || "500px", - maxHeight: maxHeight || "400px", - overflow: "hidden", display: "flex", flexDirection: "column", alignItems: "center", @@ -74,7 +72,7 @@ export function ImageVisualizer({ const imageStyle = { maxWidth: "100%", - maxHeight: maxHeight || "360px", + maxHeight: maxHeight || "400px", objectFit: "contain", cursor: !disable && allowExpand && !hasError ? "pointer" : "default", opacity: disable ? 0.5 : 1, From 8d21aa3770a1c8d40ecbec5a178b31315a564870 Mon Sep 17 00:00:00 2001 From: Srijan Date: Thu, 4 Dec 2025 17:13:28 +0530 Subject: [PATCH 7/7] fix(test): click button wrapper instead of img for modal test --- .../components/common/visualizer/elements/image.test.jsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/tests/components/common/visualizer/elements/image.test.jsx b/frontend/tests/components/common/visualizer/elements/image.test.jsx index 30913027dc..4c3286987e 100644 --- a/frontend/tests/components/common/visualizer/elements/image.test.jsx +++ b/frontend/tests/components/common/visualizer/elements/image.test.jsx @@ -85,12 +85,13 @@ describe("ImageVisualizer component", () => { const img = screen.getByAltText("Expandable Image"); fireEvent.load(img); - // Click the image - fireEvent.click(img); + // Click the button wrapper (image is inside a button when allowExpand is true) + const button = img.closest("button"); + fireEvent.click(button); - // Modal should be open + // Modal should be open - header shows the title await waitFor(() => { - expect(screen.getByText("Image Preview")).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); }); });