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)
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/elements/image.jsx b/frontend/src/components/common/visualizer/elements/image.jsx
new file mode 100644
index 0000000000..b8f2f90b13
--- /dev/null
+++ b/frontend/src/components/common/visualizer/elements/image.jsx
@@ -0,0 +1,212 @@
+// 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 || "500px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ };
+
+ const imageStyle = {
+ maxWidth: "100%",
+ maxHeight: maxHeight || "400px",
+ objectFit: "contain",
+ cursor: !disable && allowExpand && !hasError ? "pointer" : "default",
+ opacity: disable ? 0.5 : 1,
+ };
+
+ const renderImage = () => {
+ if (hasError) {
+ return (
+
+
+ Failed to load image
+
+ );
+ }
+
+ const imageElement = (
+
+ );
+
+ return (
+ <>
+ {isLoading && (
+
+
+ Loading...
+
+ )}
+ {allowExpand && !disable ? (
+
+ ) : (
+ imageElement
+ )}
+ >
+ );
+ };
+
+ 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"}
+
+
+
+ {description && {description}
}
+
+
+
+ );
+}
+
+ImageVisualizer.propTypes = {
+ id: PropTypes.string.isRequired,
+ url: PropTypes.string,
+ base64: PropTypes.string,
+ title: PropTypes.string,
+ description: PropTypes.string,
+ maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ allowExpand: PropTypes.bool,
+ disable: PropTypes.bool,
+ size: PropTypes.string,
+};
+
+ImageVisualizer.defaultProps = {
+ url: "",
+ base64: "",
+ title: "",
+ description: "",
+ 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 cf7a3070a5..73db9353c1 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 = parseString(rawElement.max_width || "500px");
+ validatedFields.maxHeight = parseString(rawElement.max_height || "400px");
+ 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 = (
{
+ 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 button wrapper (image is inside a button when allowExpand is true)
+ const button = img.closest("button");
+ fireEvent.click(button);
+
+ // Modal should be open - header shows the title
+ await waitFor(() => {
+ expect(screen.getByRole("dialog")).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)