From b4ff1dcc75b5ead68baf91055b095c68ae685560 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 22 Oct 2025 15:51:43 +0200 Subject: [PATCH 01/11] refactor(graphs): graph components kebab case (#8966) Co-authored-by: alejandrobailo --- Makefile | 10 +- ui/components/graphs/BarChart.tsx | 162 -- ui/components/graphs/SankeyChart.tsx | 137 -- .../{DonutChart.tsx => donut-chart.tsx} | 29 +- ...lBarChart.tsx => horizontal-bar-chart.tsx} | 45 +- ui/components/graphs/index.ts | 18 +- .../graphs/{LineChart.tsx => line-chart.tsx} | 38 +- ui/components/graphs/map-chart.tsx | 479 +++++ ui/components/graphs/map-region-filter.tsx | 50 + .../{RadarChart.tsx => radar-chart.tsx} | 31 +- .../{RadialChart.tsx => radial-chart.tsx} | 7 +- ui/components/graphs/sankey-chart.tsx | 403 ++++ .../{ScatterPlot.tsx => scatter-plot.tsx} | 44 +- .../shared/{AlertPill.tsx => alert-pill.tsx} | 16 +- .../{ChartLegend.tsx => chart-legend.tsx} | 12 +- .../{ChartTooltip.tsx => chart-tooltip.tsx} | 77 +- ui/components/graphs/shared/constants.ts | 38 +- ui/components/graphs/types.ts | 8 + ui/components/ui/index.ts | 2 +- ui/components/ui/select/index.ts | 12 + ui/dependency-log.json | 254 ++- ui/package-lock.json | 1740 ++++++++++++++--- ui/package.json | 5 + ui/styles/globals.css | 70 + 24 files changed, 2899 insertions(+), 788 deletions(-) delete mode 100644 ui/components/graphs/BarChart.tsx delete mode 100644 ui/components/graphs/SankeyChart.tsx rename ui/components/graphs/{DonutChart.tsx => donut-chart.tsx} (84%) rename ui/components/graphs/{HorizontalBarChart.tsx => horizontal-bar-chart.tsx} (70%) rename ui/components/graphs/{LineChart.tsx => line-chart.tsx} (82%) create mode 100644 ui/components/graphs/map-chart.tsx create mode 100644 ui/components/graphs/map-region-filter.tsx rename ui/components/graphs/{RadarChart.tsx => radar-chart.tsx} (80%) rename ui/components/graphs/{RadialChart.tsx => radial-chart.tsx} (90%) create mode 100644 ui/components/graphs/sankey-chart.tsx rename ui/components/graphs/{ScatterPlot.tsx => scatter-plot.tsx} (81%) rename ui/components/graphs/shared/{AlertPill.tsx => alert-pill.tsx} (59%) rename ui/components/graphs/shared/{ChartLegend.tsx => chart-legend.tsx} (59%) rename ui/components/graphs/shared/{ChartTooltip.tsx => chart-tooltip.tsx} (57%) create mode 100644 ui/components/ui/select/index.ts diff --git a/Makefile b/Makefile index 368bb885bd..861c9cf7fe 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,14 @@ help: ## Show this help. @echo "Prowler Makefile" @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) +##@ Build no cache +build-no-cache-dev: + docker compose -f docker-compose-dev.yml build --no-cache api-dev worker-dev worker-beat + ##@ Development Environment run-api-dev: ## Start development environment with API, PostgreSQL, Valkey, and workers - docker compose -f docker-compose-dev.yml up api-dev postgres valkey worker-dev worker-beat --build + docker compose -f docker-compose-dev.yml up api-dev postgres valkey worker-dev worker-beat + +##@ Development Environment +build-and-run-api-dev: build-no-cache-dev run-api-dev + diff --git a/ui/components/graphs/BarChart.tsx b/ui/components/graphs/BarChart.tsx deleted file mode 100644 index 8ebb7eca3b..0000000000 --- a/ui/components/graphs/BarChart.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { - Bar, - BarChart as RechartsBar, - CartesianGrid, - Cell, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; - -import { ChartTooltip } from "./shared/ChartTooltip"; -import { CHART_COLORS, LAYOUT_OPTIONS } from "./shared/constants"; -import { getSeverityColorByName } from "./shared/utils"; -import { BarDataPoint, LayoutOption } from "./types"; - -interface BarChartProps { - data: BarDataPoint[]; - layout?: LayoutOption; - xLabel?: string; - yLabel?: string; - height?: number; - showValues?: boolean; -} - -const CustomLabel = ({ x, y, width, height, value, data }: any) => { - const percentage = data.percentage; - return ( - - {percentage !== undefined - ? `${percentage}% • ${value.toLocaleString()}` - : value.toLocaleString()} - - ); -}; - -export function BarChart({ - data, - layout = LAYOUT_OPTIONS.horizontal, - xLabel, - yLabel, - height = 400, - showValues = true, -}: BarChartProps) { - const isHorizontal = layout === LAYOUT_OPTIONS.horizontal; - - return ( - - - - {isHorizontal ? ( - <> - - - - ) : ( - <> - - - - )} - } /> - ( - - ) - : false - } - > - {data.map((entry, index) => ( - - ))} - - - - ); -} diff --git a/ui/components/graphs/SankeyChart.tsx b/ui/components/graphs/SankeyChart.tsx deleted file mode 100644 index d9c35cd1df..0000000000 --- a/ui/components/graphs/SankeyChart.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; - -import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts"; - -import { CHART_COLORS, SEVERITY_COLORS } from "./shared/constants"; - -interface SankeyNode { - name: string; -} - -interface SankeyLink { - source: number; - target: number; - value: number; -} - -interface SankeyChartProps { - data: { - nodes: SankeyNode[]; - links: SankeyLink[]; - }; - height?: number; -} - -const COLORS: Record = { - Success: "var(--color-success)", - Fail: "var(--color-destructive)", - AWS: "var(--color-orange)", - Azure: "var(--color-cyan)", - Google: "var(--color-red)", - ...SEVERITY_COLORS, -}; - -const CustomTooltip = ({ active, payload }: any) => { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-

{data.name}

- {data.value && ( -

Value: {data.value}

- )} -
- ); - } - return null; -}; - -const CustomNode = ({ x, y, width, height, payload, containerWidth }: any) => { - const isOut = x + width + 6 > containerWidth; - const nodeName = payload.name; - const color = COLORS[nodeName] || CHART_COLORS.defaultColor; - - return ( - - - - {nodeName} - - - {payload.value} - - - ); -}; - -const CustomLink = (props: any) => { - const { - sourceX, - targetX, - sourceY, - targetY, - sourceControlX, - targetControlX, - linkWidth, - } = props; - - const sourceName = props.payload.source?.name || ""; - const color = COLORS[sourceName] || CHART_COLORS.defaultColor; - - return ( - - - - ); -}; - -export function SankeyChart({ data, height = 400 }: SankeyChartProps) { - return ( - - } - link={} - nodePadding={50} - margin={{ top: 20, right: 160, bottom: 20, left: 160 }} - > - } /> - - - ); -} diff --git a/ui/components/graphs/DonutChart.tsx b/ui/components/graphs/donut-chart.tsx similarity index 84% rename from ui/components/graphs/DonutChart.tsx rename to ui/components/graphs/donut-chart.tsx index 57713cc2f6..ea5ff49e5f 100644 --- a/ui/components/graphs/DonutChart.tsx +++ b/ui/components/graphs/donut-chart.tsx @@ -5,7 +5,7 @@ import { Cell, Label, Pie, PieChart, Tooltip } from "recharts"; import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart"; -import { ChartLegend } from "./shared/ChartLegend"; +import { ChartLegend } from "./shared/chart-legend"; import { DonutDataPoint } from "./types"; interface DonutChartProps { @@ -24,18 +24,30 @@ const CustomTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { const data = payload[0].payload; return ( -
+
- + {data.percentage}% {data.name}
{data.change !== undefined && ( -

+

{data.change > 0 ? "+" : ""} {data.change}% @@ -145,14 +157,19 @@ export function DonutChart({ {formattedValue} {centerLabel.label} diff --git a/ui/components/graphs/HorizontalBarChart.tsx b/ui/components/graphs/horizontal-bar-chart.tsx similarity index 70% rename from ui/components/graphs/HorizontalBarChart.tsx rename to ui/components/graphs/horizontal-bar-chart.tsx index b91e575656..c792d60d38 100644 --- a/ui/components/graphs/HorizontalBarChart.tsx +++ b/ui/components/graphs/horizontal-bar-chart.tsx @@ -26,7 +26,12 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {

{title && (
-

{title}

+

+ {title} +

)} @@ -48,8 +53,9 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) { >
{isHovered && ( -
+
- + {item.value.toLocaleString()} {item.name} Risk
{item.newFindings !== undefined && (
- - + + {item.newFindings} New Findings
)} {item.change !== undefined && ( -

+

{item.change > 0 ? "+" : ""} {item.change}% @@ -102,15 +126,16 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {

{item.percentage}% - - {item.value.toLocaleString()} + + {item.value.toLocaleString()}
); diff --git a/ui/components/graphs/index.ts b/ui/components/graphs/index.ts index f0f0e53697..79d9b01e53 100644 --- a/ui/components/graphs/index.ts +++ b/ui/components/graphs/index.ts @@ -1,9 +1,9 @@ -export { BarChart } from "./BarChart"; -export { DonutChart } from "./DonutChart"; -export { HorizontalBarChart } from "./HorizontalBarChart"; -export { LineChart } from "./LineChart"; -export { RadarChart } from "./RadarChart"; -export { RadialChart } from "./RadialChart"; -export { SankeyChart } from "./SankeyChart"; -export { ScatterPlot } from "./ScatterPlot"; -export { ChartLegend, type ChartLegendItem } from "./shared/ChartLegend"; +export { DonutChart } from "./donut-chart"; +export { HorizontalBarChart } from "./horizontal-bar-chart"; +export { LineChart } from "./line-chart"; +export { MapChart, type MapChartData, type MapChartProps } from "./map-chart"; +export { RadarChart } from "./radar-chart"; +export { RadialChart } from "./radial-chart"; +export { SankeyChart } from "./sankey-chart"; +export { ScatterPlot } from "./scatter-plot"; +export { ChartLegend, type ChartLegendItem } from "./shared/chart-legend"; diff --git a/ui/components/graphs/LineChart.tsx b/ui/components/graphs/line-chart.tsx similarity index 82% rename from ui/components/graphs/LineChart.tsx rename to ui/components/graphs/line-chart.tsx index b19c9591f5..daefb55808 100644 --- a/ui/components/graphs/LineChart.tsx +++ b/ui/components/graphs/line-chart.tsx @@ -14,8 +14,8 @@ import { YAxis, } from "recharts"; -import { AlertPill } from "./shared/AlertPill"; -import { ChartLegend } from "./shared/ChartLegend"; +import { AlertPill } from "./shared/alert-pill"; +import { ChartLegend } from "./shared/chart-legend"; import { CHART_COLORS } from "./shared/constants"; import { LineConfig, LineDataPoint } from "./types"; @@ -48,8 +48,19 @@ const CustomLineTooltip = ({ const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0); return ( -
-

{label}

+
+

+ {label} +

@@ -67,18 +78,29 @@ const CustomLineTooltip = ({ className="h-2 w-2 rounded-full" style={{ backgroundColor: item.stroke }} /> - {item.value} + + {item.value} +
{newFindings !== undefined && (
- - + + {newFindings} New Findings
)} {change !== undefined && typeof change === "number" && ( -

+

{change > 0 ? "+" : ""} {change}% diff --git a/ui/components/graphs/map-chart.tsx b/ui/components/graphs/map-chart.tsx new file mode 100644 index 0000000000..586bb47b25 --- /dev/null +++ b/ui/components/graphs/map-chart.tsx @@ -0,0 +1,479 @@ +"use client"; + +import * as d3 from "d3"; +import type { + Feature, + FeatureCollection, + GeoJsonProperties, + Geometry, +} from "geojson"; +import { AlertTriangle, Info, MapPin } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { feature } from "topojson-client"; +import type { + GeometryCollection, + Objects, + Topology, +} from "topojson-specification"; + +import { HorizontalBarChart } from "./horizontal-bar-chart"; +import { BarDataPoint } from "./types"; + +// Constants +const MAP_CONFIG = { + defaultWidth: 688, + defaultHeight: 400, + pointRadius: 6, + selectedPointRadius: 8, + transitionDuration: 300, +} as const; + +const MAP_COLORS = { + landFill: "var(--chart-border-emphasis)", + landStroke: "var(--chart-border)", + pointDefault: "#DB2B49", + pointSelected: "#86DA26", + pointHover: "#DB2B49", +} as const; + +const RISK_LEVELS = { + LOW_HIGH: "low-high", + HIGH: "high", + CRITICAL: "critical", +} as const; + +type RiskLevel = (typeof RISK_LEVELS)[keyof typeof RISK_LEVELS]; + +interface LocationPoint { + id: string; + name: string; + region: string; + coordinates: [number, number]; + totalFindings: number; + riskLevel: RiskLevel; + severityData: BarDataPoint[]; + change?: number; +} + +export interface MapChartData { + locations: LocationPoint[]; + regions: string[]; +} + +export interface MapChartProps { + data: MapChartData; + height?: number; + onLocationSelect?: (location: LocationPoint | null) => void; +} + +// Utility functions +function createProjection(width: number, height: number) { + return d3 + .geoNaturalEarth1() + .fitExtent( + [ + [1, 1], + [width - 1, height - 1], + ], + { type: "Sphere" }, + ) + .precision(0.2); +} + +async function fetchWorldData(): Promise { + try { + const worldAtlasModule = await import("world-atlas/countries-110m.json"); + const worldData = worldAtlasModule.default || worldAtlasModule; + const topology = worldData as unknown as Topology; + return feature( + topology, + topology.objects.countries as GeometryCollection, + ) as FeatureCollection; + } catch (error) { + console.error("Error loading world map data:", error); + return null; + } +} + +// Helper: Create SVG element +function createSVGElement( + type: string, + attributes: Record, +): T { + const element = document.createElementNS( + "http://www.w3.org/2000/svg", + type, + ) as T; + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, value); + }); + return element; +} + +// Components +function MapTooltip({ + location, + position, +}: { + location: LocationPoint; + position: { x: number; y: number }; +}) { + const CHART_COLORS = { + tooltipBorder: "var(--chart-border-emphasis)", + tooltipBackground: "var(--chart-background)", + textPrimary: "var(--chart-text-primary)", + textSecondary: "var(--chart-text-secondary)", + }; + + return ( +

+
+ + + {location.name} + +
+
+ + + {location.totalFindings.toLocaleString()} Fail Findings + +
+ {location.change !== undefined && ( +

+ + {location.change > 0 ? "+" : ""} + {location.change}% + {" "} + since last scan +

+ )} +
+ ); +} + +function EmptyState() { + const CHART_COLORS = { + tooltipBorder: "var(--chart-border-emphasis)", + tooltipBackground: "var(--chart-background)", + textSecondary: "var(--chart-text-secondary)", + }; + + return ( +
+
+ +

+ Select a location on the map to view details +

+
+
+ ); +} + +function LoadingState({ height }: { height: number }) { + const CHART_COLORS = { + textSecondary: "var(--chart-text-secondary)", + }; + + return ( +
+
+
+ Loading map... +
+
+
+ ); +} + +export function MapChart({ + data, + height = MAP_CONFIG.defaultHeight, +}: MapChartProps) { + const svgRef = useRef(null); + const containerRef = useRef(null); + const [selectedLocation, setSelectedLocation] = + useState(null); + const [hoveredLocation, setHoveredLocation] = useState( + null, + ); + const [tooltipPosition, setTooltipPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [worldData, setWorldData] = useState(null); + const [isLoadingMap, setIsLoadingMap] = useState(true); + const [dimensions, setDimensions] = useState<{ + width: number; + height: number; + }>({ + width: MAP_CONFIG.defaultWidth, + height, + }); + + // Fetch world data once on mount + useEffect(() => { + let isMounted = true; + fetchWorldData() + .then((data) => { + if (isMounted && data) setWorldData(data); + }) + .catch(console.error) + .finally(() => { + if (isMounted) setIsLoadingMap(false); + }); + return () => { + isMounted = false; + }; + }, []); + + // Update dimensions on resize + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + setDimensions({ width: containerRef.current.clientWidth, height }); + } + }; + updateDimensions(); + window.addEventListener("resize", updateDimensions); + return () => window.removeEventListener("resize", updateDimensions); + }, [height]); + + // Render the map + useEffect(() => { + if (!svgRef.current || !worldData || isLoadingMap) return; + + const svg = svgRef.current; + const { width, height } = dimensions; + svg.innerHTML = ""; + + const projection = createProjection(width, height); + const path = d3.geoPath().projection(projection); + + // Render countries + const mapGroup = createSVGElement("g", { + class: "map-countries", + }); + worldData.features?.forEach( + (feature: Feature) => { + const pathData = path(feature); + if (pathData) { + const pathElement = createSVGElement("path", { + d: pathData, + fill: MAP_COLORS.landFill, + stroke: MAP_COLORS.landStroke, + "stroke-width": "0.5", + }); + mapGroup.appendChild(pathElement); + } + }, + ); + svg.appendChild(mapGroup); + + // Helper to update tooltip position + const updateTooltip = (e: MouseEvent) => { + const rect = svg.getBoundingClientRect(); + setTooltipPosition({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }; + + // Helper to create circle + const createCircle = (location: LocationPoint) => { + const projected = projection(location.coordinates); + if (!projected) return null; + + const [x, y] = projected; + if (x < 0 || x > width || y < 0 || y > height) return null; + + const isSelected = selectedLocation?.id === location.id; + const isHovered = hoveredLocation?.id === location.id; + const classes = ["cursor-pointer"]; + + if (isSelected) classes.push("drop-shadow-[0_0_8px_#86da26]"); + if (isHovered && !isSelected) classes.push("opacity-70"); + + const circle = createSVGElement("circle", { + cx: x.toString(), + cy: y.toString(), + r: (isSelected + ? MAP_CONFIG.selectedPointRadius + : MAP_CONFIG.pointRadius + ).toString(), + fill: isSelected ? MAP_COLORS.pointSelected : MAP_COLORS.pointDefault, + class: classes.join(" "), + }); + + circle.addEventListener("click", () => + setSelectedLocation(isSelected ? null : location), + ); + circle.addEventListener("mouseenter", (e) => { + setHoveredLocation(location); + updateTooltip(e); + }); + circle.addEventListener("mousemove", updateTooltip); + circle.addEventListener("mouseleave", () => { + setHoveredLocation(null); + setTooltipPosition(null); + }); + + return circle; + }; + + // Render points + const pointsGroup = createSVGElement("g", { + class: "threat-points", + }); + + // Unselected points first + data.locations.forEach((location) => { + if (selectedLocation?.id !== location.id) { + const circle = createCircle(location); + if (circle) pointsGroup.appendChild(circle); + } + }); + + // Selected point last (on top) + if (selectedLocation) { + const selectedData = data.locations.find( + (loc) => loc.id === selectedLocation.id, + ); + if (selectedData) { + const circle = createCircle(selectedData); + if (circle) pointsGroup.appendChild(circle); + } + } + + svg.appendChild(pointsGroup); + }, [ + data.locations, + dimensions, + selectedLocation, + hoveredLocation, + worldData, + isLoadingMap, + ]); + + const CHART_COLORS = { + tooltipBorder: "var(--chart-border-emphasis)", + tooltipBackground: "var(--chart-background)", + textPrimary: "var(--chart-text-primary)", + textSecondary: "var(--chart-text-secondary)", + }; + + return ( +
+ {/* Map Section */} +
+

+ Threat Map +

+ +
+ {isLoadingMap ? ( + + ) : ( + <> +
+ + {hoveredLocation && tooltipPosition && ( + + )} +
+
+
+ + {data.locations.length} Locations + +
+ + )} +
+
+ + {/* Details Section */} +
+
+ {selectedLocation ? ( +
+
+
+
+

+ {selectedLocation.name} +

+
+

+ {selectedLocation.totalFindings.toLocaleString()} Total Findings +

+
+ +
+ ) : ( + + )} +
+
+ ); +} diff --git a/ui/components/graphs/map-region-filter.tsx b/ui/components/graphs/map-region-filter.tsx new file mode 100644 index 0000000000..3b483d1c62 --- /dev/null +++ b/ui/components/graphs/map-region-filter.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select/Select"; + +interface MapRegionFilterProps { + regions: string[]; + selectedRegion: string; + onRegionChange: (region: string) => void; + chartColors: { + tooltipBorder: string; + tooltipBackground: string; + textPrimary: string; + }; +} + +export function MapRegionFilter({ + regions, + selectedRegion, + onRegionChange, + chartColors, +}: MapRegionFilterProps) { + return ( + + ); +} diff --git a/ui/components/graphs/RadarChart.tsx b/ui/components/graphs/radar-chart.tsx similarity index 80% rename from ui/components/graphs/RadarChart.tsx rename to ui/components/graphs/radar-chart.tsx index 462c29dd68..ab3a43e6da 100644 --- a/ui/components/graphs/RadarChart.tsx +++ b/ui/components/graphs/radar-chart.tsx @@ -13,7 +13,7 @@ import { ChartTooltip, } from "@/components/ui/chart/Chart"; -import { AlertPill } from "./shared/AlertPill"; +import { AlertPill } from "./shared/alert-pill"; import { CHART_COLORS } from "./shared/constants"; import { RadarDataPoint } from "./types"; @@ -28,7 +28,7 @@ interface RadarChartProps { const chartConfig = { value: { label: "Findings", - color: "var(--color-magenta)", + color: "var(--chart-radar-primary)", }, } satisfies ChartConfig; @@ -36,15 +36,27 @@ const CustomTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { const data = payload[0]; return ( -
-

+

+

{data.payload.category}

{data.payload.change !== undefined && ( -

+

{data.payload.change > 0 ? "+" : ""} {data.payload.change}% @@ -84,8 +96,11 @@ const CustomDot = (props: any) => { cx={cx} cy={cy} r={isSelected ? 9 : 6} - fill={isSelected ? "var(--color-success)" : "var(--color-purple-dark)"} + fill={ + isSelected ? "var(--chart-success-color)" : "var(--chart-radar-primary)" + } fillOpacity={1} + className={isSelected ? "drop-shadow-[0_0_8px_#86da26]" : ""} style={{ cursor: onSelectPoint ? "pointer" : "default", pointerEvents: "all", @@ -117,7 +132,7 @@ export function RadarChart({ {percentage}% diff --git a/ui/components/graphs/sankey-chart.tsx b/ui/components/graphs/sankey-chart.tsx new file mode 100644 index 0000000000..58179aadf6 --- /dev/null +++ b/ui/components/graphs/sankey-chart.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useState } from "react"; +import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts"; + +import { ChartTooltip } from "./shared/chart-tooltip"; +import { CHART_COLORS } from "./shared/constants"; + +interface SankeyNode { + name: string; + newFindings?: number; + change?: number; +} + +interface SankeyLink { + source: number; + target: number; + value: number; +} + +interface SankeyChartProps { + data: { + nodes: SankeyNode[]; + links: SankeyLink[]; + }; + height?: number; +} + +interface LinkTooltipState { + show: boolean; + x: number; + y: number; + sourceName: string; + targetName: string; + value: number; + color: string; +} + +interface NodeTooltipState { + show: boolean; + x: number; + y: number; + name: string; + value: number; + color: string; + newFindings?: number; + change?: number; +} + +// Note: Using hex colors directly because Recharts SVG fill doesn't resolve CSS variables +const COLORS: Record = { + Success: "#86da26", + Fail: "#db2b49", + AWS: "#ff9900", + Azure: "#00bcd4", + Google: "#EA4335", + Critical: "#971348", + High: "#ff3077", + Medium: "#ff7d19", + Low: "#fdd34f", + Info: "#2e51b2", + Informational: "#2e51b2", +}; + +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +

+

+ {data.name} +

+ {data.value && ( +

+ Value: {data.value} +

+ )} +
+ ); + } + return null; +}; + +const CustomNode = (props: any) => { + const { x, y, width, height, payload, containerWidth } = props; + const isOut = x + width + 6 > containerWidth; + const nodeName = payload.name; + const color = COLORS[nodeName] || CHART_COLORS.defaultColor; + const isHidden = nodeName === ""; + const hasTooltip = !isHidden && payload.newFindings; + + const handleMouseEnter = (e: React.MouseEvent) => { + if (!hasTooltip) return; + + const rect = e.currentTarget.closest("svg") as SVGSVGElement; + if (rect) { + const bbox = rect.getBoundingClientRect(); + props.onNodeHover?.({ + x: e.clientX - bbox.left, + y: e.clientY - bbox.top, + name: nodeName, + value: payload.value, + color, + newFindings: payload.newFindings, + change: payload.change, + }); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!hasTooltip) return; + + const rect = e.currentTarget.closest("svg") as SVGSVGElement; + if (rect) { + const bbox = rect.getBoundingClientRect(); + props.onNodeMove?.({ + x: e.clientX - bbox.left, + y: e.clientY - bbox.top, + }); + } + }; + + const handleMouseLeave = () => { + if (!hasTooltip) return; + props.onNodeLeave?.(); + }; + + return ( + + + {!isHidden && ( + <> + + {nodeName} + + + {payload.value} + + + )} + + ); +}; + +const CustomLink = (props: any) => { + const { + sourceX, + targetX, + sourceY, + targetY, + sourceControlX, + targetControlX, + linkWidth, + index, + } = props; + + const sourceName = props.payload.source?.name || ""; + const targetName = props.payload.target?.name || ""; + const value = props.payload.value || 0; + const color = COLORS[sourceName] || CHART_COLORS.defaultColor; + const isHidden = targetName === ""; + + const isHovered = props.hoveredLink !== null && props.hoveredLink === index; + const hasHoveredLink = props.hoveredLink !== null; + + const pathD = ` + M${sourceX},${sourceY + linkWidth / 2} + C${sourceControlX},${sourceY + linkWidth / 2} + ${targetControlX},${targetY + linkWidth / 2} + ${targetX},${targetY + linkWidth / 2} + L${targetX},${targetY - linkWidth / 2} + C${targetControlX},${targetY - linkWidth / 2} + ${sourceControlX},${sourceY - linkWidth / 2} + ${sourceX},${sourceY - linkWidth / 2} + Z + `; + + const getOpacity = () => { + if (isHidden) return "0"; + if (!hasHoveredLink) return "0.4"; + return isHovered ? "0.8" : "0.1"; + }; + + const handleMouseEnter = (e: React.MouseEvent) => { + const rect = e.currentTarget.parentElement?.parentElement + ?.parentElement as unknown as SVGSVGElement; + if (rect) { + const bbox = rect.getBoundingClientRect(); + props.onLinkHover?.(index, { + x: e.clientX - bbox.left, + y: e.clientY - bbox.top, + sourceName, + targetName, + value, + color, + }); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + const rect = e.currentTarget.parentElement?.parentElement + ?.parentElement as unknown as SVGSVGElement; + if (rect && isHovered) { + const bbox = rect.getBoundingClientRect(); + props.onLinkMove?.({ + x: e.clientX - bbox.left, + y: e.clientY - bbox.top, + }); + } + }; + + const handleMouseLeave = () => { + props.onLinkLeave?.(); + }; + + return ( + + + + ); +}; + +export function SankeyChart({ data, height = 400 }: SankeyChartProps) { + const [hoveredLink, setHoveredLink] = useState(null); + const [linkTooltip, setLinkTooltip] = useState({ + show: false, + x: 0, + y: 0, + sourceName: "", + targetName: "", + value: 0, + color: "", + }); + + const [nodeTooltip, setNodeTooltip] = useState({ + show: false, + x: 0, + y: 0, + name: "", + value: 0, + color: "", + }); + + const handleLinkHover = ( + index: number, + data: Omit, + ) => { + setHoveredLink(index); + setLinkTooltip({ show: true, ...data }); + }; + + const handleLinkMove = (position: { x: number; y: number }) => { + setLinkTooltip((prev) => ({ + ...prev, + x: position.x, + y: position.y, + })); + }; + + const handleLinkLeave = () => { + setHoveredLink(null); + setLinkTooltip((prev) => ({ ...prev, show: false })); + }; + + const handleNodeHover = (data: Omit) => { + setNodeTooltip({ show: true, ...data }); + }; + + const handleNodeMove = (position: { x: number; y: number }) => { + setNodeTooltip((prev) => ({ + ...prev, + x: position.x, + y: position.y, + })); + }; + + const handleNodeLeave = () => { + setNodeTooltip((prev) => ({ ...prev, show: false })); + }; + + return ( +
+ + + } + link={ + + } + nodePadding={50} + margin={{ top: 20, right: 160, bottom: 20, left: 160 }} + sort={false} + > + } /> + + + {linkTooltip.show && ( +
+ +
+ )} + {nodeTooltip.show && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/components/graphs/ScatterPlot.tsx b/ui/components/graphs/scatter-plot.tsx similarity index 81% rename from ui/components/graphs/ScatterPlot.tsx rename to ui/components/graphs/scatter-plot.tsx index 920f6c0c10..a0725f0dc5 100644 --- a/ui/components/graphs/ScatterPlot.tsx +++ b/ui/components/graphs/scatter-plot.tsx @@ -11,18 +11,11 @@ import { YAxis, } from "recharts"; -import { AlertPill } from "./shared/AlertPill"; -import { ChartLegend } from "./shared/ChartLegend"; +import { AlertPill } from "./shared/alert-pill"; +import { ChartLegend } from "./shared/chart-legend"; import { CHART_COLORS } from "./shared/constants"; import { getSeverityColorByRiskScore } from "./shared/utils"; - -interface ScatterDataPoint { - x: number; - y: number; - provider: string; - name: string; - size?: number; -} +import type { ScatterDataPoint } from "./types"; interface ScatterPlotProps { data: ScatterDataPoint[]; @@ -34,9 +27,9 @@ interface ScatterPlotProps { } const PROVIDER_COLORS = { - AWS: "var(--color-orange)", - Azure: "var(--color-cyan)", - Google: "var(--color-red)", + AWS: "var(--chart-provider-aws)", + Azure: "var(--chart-provider-azure)", + Google: "var(--chart-provider-google)", }; const CustomTooltip = ({ active, payload }: any) => { @@ -45,9 +38,23 @@ const CustomTooltip = ({ active, payload }: any) => { const severityColor = getSeverityColorByRiskScore(data.x); return ( -
-

{data.name}

-

+

+

+ {data.name} +

+

{data.x} Risk Score

@@ -69,7 +76,7 @@ const CustomScatterDot = ({ const isSelected = selectedPoint?.name === payload.name; const size = isSelected ? 18 : 8; const fill = isSelected - ? "var(--color-success)" + ? "#86DA26" : PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] || CHART_COLORS.defaultColor; @@ -79,8 +86,9 @@ const CustomScatterDot = ({ cy={cy} r={size / 2} fill={fill} - stroke={isSelected ? "var(--color-success)" : "transparent"} + stroke={isSelected ? "#86DA26" : "transparent"} strokeWidth={2} + className={isSelected ? "drop-shadow-[0_0_8px_#86da26]" : ""} style={{ cursor: "pointer" }} onClick={() => onSelectPoint?.(payload)} /> diff --git a/ui/components/graphs/shared/AlertPill.tsx b/ui/components/graphs/shared/alert-pill.tsx similarity index 59% rename from ui/components/graphs/shared/AlertPill.tsx rename to ui/components/graphs/shared/alert-pill.tsx index f611b27766..2da8b1903f 100644 --- a/ui/components/graphs/shared/AlertPill.tsx +++ b/ui/components/graphs/shared/alert-pill.tsx @@ -17,13 +17,17 @@ export function AlertPill({ }: AlertPillProps) { return (
-
- +
+ {value} diff --git a/ui/components/graphs/shared/ChartLegend.tsx b/ui/components/graphs/shared/chart-legend.tsx similarity index 59% rename from ui/components/graphs/shared/ChartLegend.tsx rename to ui/components/graphs/shared/chart-legend.tsx index 11066aa54a..2efea3ea85 100644 --- a/ui/components/graphs/shared/ChartLegend.tsx +++ b/ui/components/graphs/shared/chart-legend.tsx @@ -9,14 +9,22 @@ interface ChartLegendProps { export function ChartLegend({ items }: ChartLegendProps) { return ( -
+
{items.map((item, index) => (
- {item.label} + + {item.label} +
))}
diff --git a/ui/components/graphs/shared/ChartTooltip.tsx b/ui/components/graphs/shared/chart-tooltip.tsx similarity index 57% rename from ui/components/graphs/shared/ChartTooltip.tsx rename to ui/components/graphs/shared/chart-tooltip.tsx index 6b4bde27bc..432a15713d 100644 --- a/ui/components/graphs/shared/ChartTooltip.tsx +++ b/ui/components/graphs/shared/chart-tooltip.tsx @@ -3,6 +3,7 @@ import { Bell, VolumeX } from "lucide-react"; import { cn } from "@/lib/utils"; import { TooltipData } from "../types"; +import { CHART_COLORS } from "./constants"; interface ChartTooltipProps { active?: boolean; @@ -27,7 +28,13 @@ export function ChartTooltip({ const color = payload[0].color || data.color; return ( -
+
{showColorIndicator && color && (
)} -

{label || data.name}

+

+ {label || data.name} +

-

+

{typeof data.value === "number" ? data.value.toLocaleString() : data.value} @@ -50,8 +62,11 @@ export function ChartTooltip({ {data.newFindings !== undefined && data.newFindings > 0 && (

- - + + {data.newFindings} New Findings
@@ -59,20 +74,33 @@ export function ChartTooltip({ {data.new !== undefined && data.new > 0 && (
- - {data.new} New + + + {data.new} New +
)} {data.muted !== undefined && data.muted > 0 && (
- - {data.muted} Muted + + + {data.muted} Muted +
)} {data.change !== undefined && ( -

+

{data.change > 0 ? "+" : ""} {data.change}% @@ -97,8 +125,19 @@ export function MultiSeriesChartTooltip({ } return ( -

-

{label}

+
+

+ {label} +

{payload.map((entry: any, index: number) => (
@@ -106,12 +145,20 @@ export function MultiSeriesChartTooltip({ className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} /> - {entry.name}: - + + {entry.name}: + + {entry.value} {entry.payload[`${entry.dataKey}_change`] && ( - + ({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""} {entry.payload[`${entry.dataKey}_change`]}%) diff --git a/ui/components/graphs/shared/constants.ts b/ui/components/graphs/shared/constants.ts index 9ab80b8fa8..4aadd4aac0 100644 --- a/ui/components/graphs/shared/constants.ts +++ b/ui/components/graphs/shared/constants.ts @@ -1,21 +1,33 @@ export const SEVERITY_COLORS = { - Informational: "var(--color-info)", - Low: "var(--color-warning)", - Medium: "var(--color-warning-emphasis)", - High: "var(--color-danger)", - Critical: "var(--color-danger-emphasis)", + Informational: "var(--chart-info)", + Info: "var(--chart-info)", + Low: "var(--chart-warning)", + Medium: "var(--chart-warning-emphasis)", + High: "var(--chart-danger)", + Critical: "var(--chart-danger-emphasis)", +} as const; + +export const PROVIDER_COLORS = { + AWS: "var(--chart-provider-aws)", + Azure: "var(--chart-provider-azure)", + Google: "var(--chart-provider-google)", +} as const; + +export const STATUS_COLORS = { + Success: "var(--chart-success-color)", + Fail: "var(--chart-fail)", } as const; export const CHART_COLORS = { - tooltipBorder: "var(--color-slate-700)", - tooltipBackground: "var(--color-slate-800)", - textPrimary: "var(--color-white)", - textSecondary: "var(--color-slate-400)", - gridLine: "var(--color-slate-700)", + tooltipBorder: "var(--chart-border-emphasis)", + tooltipBackground: "var(--chart-background)", + textPrimary: "var(--chart-text-primary)", + textSecondary: "var(--chart-text-secondary)", + gridLine: "var(--chart-border-emphasis)", backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity - alertPillBg: "var(--color-alert-pill-bg)", - alertPillText: "var(--color-alert-pill-text)", - defaultColor: "var(--color-slate-500)", // Default fallback color for charts + alertPillBg: "var(--chart-alert-bg)", + alertPillText: "var(--chart-alert-text)", + defaultColor: "#64748b", // slate-500 } as const; export const CHART_DIMENSIONS = { diff --git a/ui/components/graphs/types.ts b/ui/components/graphs/types.ts index 0961a32105..f7a9b02542 100644 --- a/ui/components/graphs/types.ts +++ b/ui/components/graphs/types.ts @@ -36,6 +36,14 @@ export interface RadarDataPoint { change?: number; } +export interface ScatterDataPoint { + x: number; + y: number; + provider: string; + name: string; + size?: number; +} + export interface LineConfig { dataKey: string; color: string; diff --git a/ui/components/ui/index.ts b/ui/components/ui/index.ts index 205ec7fd50..c4059a7b55 100644 --- a/ui/components/ui/index.ts +++ b/ui/components/ui/index.ts @@ -12,6 +12,6 @@ export * from "./feedback-banner/feedback-banner"; export * from "./headers/navigation-header"; export * from "./label/Label"; export * from "./main-layout/main-layout"; -export * from "./select/Select"; +export * from "./select"; export * from "./sidebar"; export * from "./toast"; diff --git a/ui/components/ui/select/index.ts b/ui/components/ui/select/index.ts new file mode 100644 index 0000000000..730dd10551 --- /dev/null +++ b/ui/components/ui/select/index.ts @@ -0,0 +1,12 @@ +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "./Select"; diff --git a/ui/dependency-log.json b/ui/dependency-log.json index 64a12b5901..4677a082eb 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -5,7 +5,7 @@ "from": "1.0.59", "to": "1.0.59", "strategy": "installed", - "generatedAt": "2025-10-01T11:13:12.025Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -13,7 +13,7 @@ "from": "2.0.59", "to": "2.0.59", "strategy": "installed", - "generatedAt": "2025-10-01T11:13:12.025Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -21,15 +21,15 @@ "from": "2.8.4", "to": "2.8.4", "strategy": "installed", - "generatedAt": "2025-09-29T14:26:25.838Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "@hookform/resolvers", - "from": "3.10.0", + "from": "5.2.2", "to": "5.2.2", "strategy": "installed", - "generatedAt": "2025-10-01T15:09:44.056Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -37,7 +37,7 @@ "from": "0.3.77", "to": "0.3.77", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -45,23 +45,23 @@ "from": "0.4.9", "to": "0.4.9", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "@langchain/langgraph-supervisor", - "from": "0.0.12", + "from": "0.0.20", "to": "0.0.20", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "@langchain/openai", - "from": "0.6.9", + "from": "0.5.18", "to": "0.5.18", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -69,7 +69,7 @@ "from": "15.3.5", "to": "15.3.5", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -77,7 +77,7 @@ "from": "1.1.14", "to": "1.1.14", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -85,7 +85,7 @@ "from": "1.1.14", "to": "1.1.14", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -93,7 +93,7 @@ "from": "2.1.15", "to": "2.1.15", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -101,7 +101,7 @@ "from": "1.3.2", "to": "1.3.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -109,7 +109,7 @@ "from": "2.1.7", "to": "2.1.7", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -117,7 +117,7 @@ "from": "2.2.5", "to": "2.2.5", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -125,7 +125,7 @@ "from": "1.2.3", "to": "1.2.3", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -133,7 +133,7 @@ "from": "1.2.14", "to": "1.2.14", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -141,7 +141,7 @@ "from": "3.9.4", "to": "3.9.4", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -149,7 +149,7 @@ "from": "3.8.12", "to": "3.8.12", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -157,7 +157,7 @@ "from": "4.1.13", "to": "4.1.13", "strategy": "installed", - "generatedAt": "2025-09-24T15:04:48.761Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -165,7 +165,7 @@ "from": "0.5.16", "to": "0.5.16", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -173,7 +173,7 @@ "from": "8.21.3", "to": "8.21.3", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -181,15 +181,15 @@ "from": "4.0.9", "to": "4.0.9", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "ai", - "from": "4.3.16", + "from": "5.0.59", "to": "5.0.59", "strategy": "installed", - "generatedAt": "2025-10-01T10:03:22.788Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -197,7 +197,7 @@ "from": "6.0.2", "to": "6.0.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -205,7 +205,7 @@ "from": "0.7.1", "to": "0.7.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -213,7 +213,15 @@ "from": "2.1.1", "to": "2.1.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" + }, + { + "section": "dependencies", + "name": "d3", + "from": "7.9.0", + "to": "7.9.0", + "strategy": "installed", + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -221,7 +229,7 @@ "from": "4.1.0", "to": "4.1.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -229,7 +237,7 @@ "from": "11.18.2", "to": "11.18.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -237,7 +245,7 @@ "from": "10.7.16", "to": "10.7.16", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -245,7 +253,7 @@ "from": "5.10.0", "to": "5.10.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -253,7 +261,7 @@ "from": "4.1.0", "to": "4.1.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -261,7 +269,7 @@ "from": "4.0.0", "to": "4.0.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -269,7 +277,7 @@ "from": "0.543.0", "to": "0.543.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -277,15 +285,15 @@ "from": "15.0.12", "to": "15.0.12", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "next", - "from": "14.2.32", + "from": "15.5.3", "to": "15.5.3", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -293,7 +301,7 @@ "from": "5.0.0-beta.29", "to": "5.0.0-beta.29", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -301,7 +309,7 @@ "from": "0.2.1", "to": "0.2.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -309,23 +317,23 @@ "from": "1.4.2", "to": "1.4.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "react", - "from": "18.3.1", + "from": "19.1.1", "to": "19.1.1", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "react-dom", - "from": "18.3.1", + "from": "19.1.1", "to": "19.1.1", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -333,7 +341,7 @@ "from": "7.62.0", "to": "7.62.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -341,7 +349,7 @@ "from": "10.1.0", "to": "10.1.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -349,7 +357,7 @@ "from": "2.15.4", "to": "2.15.4", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -357,7 +365,7 @@ "from": "3.13.0", "to": "3.13.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -365,15 +373,7 @@ "from": "0.0.1", "to": "0.0.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" - }, - { - "section": "dependencies", - "name": "shadcn", - "from": "3.2.1", - "to": "3.2.1", - "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -381,7 +381,7 @@ "from": "0.33.5", "to": "0.33.5", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -389,7 +389,7 @@ "from": "3.3.1", "to": "3.3.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -397,7 +397,15 @@ "from": "1.0.7", "to": "1.0.7", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" + }, + { + "section": "dependencies", + "name": "topojson-client", + "from": "3.1.0", + "to": "3.1.0", + "strategy": "installed", + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -405,7 +413,7 @@ "from": "1.4.0", "to": "1.4.0", "strategy": "installed", - "generatedAt": "2025-10-15T07:57:13.225Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", @@ -413,23 +421,31 @@ "from": "11.1.0", "to": "11.1.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-22T12:36:37.962Z" + }, + { + "section": "dependencies", + "name": "world-atlas", + "from": "2.0.2", + "to": "2.0.2", + "strategy": "installed", + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "zod", - "from": "3.25.73", + "from": "4.1.11", "to": "4.1.11", "strategy": "installed", - "generatedAt": "2025-10-01T09:40:25.207Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "dependencies", "name": "zustand", - "from": "4.5.7", + "from": "5.0.8", "to": "5.0.8", "strategy": "installed", - "generatedAt": "2025-10-01T09:40:25.207Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -437,15 +453,23 @@ "from": "5.2.1", "to": "5.2.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", "name": "@playwright/test", - "from": "1.53.2", - "to": "1.53.2", + "from": "1.56.1", + "to": "1.56.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" + }, + { + "section": "devDependencies", + "name": "@types/d3", + "from": "7.4.3", + "to": "7.4.3", + "strategy": "installed", + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -453,23 +477,31 @@ "from": "20.5.7", "to": "20.5.7", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", "name": "@types/react", - "from": "18.3.3", + "from": "19.1.13", "to": "19.1.13", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", "name": "@types/react-dom", - "from": "18.3.0", + "from": "19.1.9", "to": "19.1.9", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" + }, + { + "section": "devDependencies", + "name": "@types/topojson-client", + "from": "3.1.5", + "to": "3.1.5", + "strategy": "installed", + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -477,7 +509,7 @@ "from": "10.0.0", "to": "10.0.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -485,7 +517,7 @@ "from": "7.18.0", "to": "7.18.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -493,7 +525,7 @@ "from": "7.18.0", "to": "7.18.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -501,7 +533,7 @@ "from": "10.4.19", "to": "10.4.19", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -509,7 +541,7 @@ "from": "19.1.0-rc.3", "to": "19.1.0-rc.3", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -517,15 +549,15 @@ "from": "8.57.1", "to": "8.57.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", "name": "eslint-config-next", - "from": "14.2.32", + "from": "15.5.3", "to": "15.5.3", "strategy": "installed", - "generatedAt": "2025-09-23T10:22:08.630Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -533,7 +565,7 @@ "from": "10.1.5", "to": "10.1.5", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -541,7 +573,7 @@ "from": "2.32.0", "to": "2.32.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -549,7 +581,7 @@ "from": "6.10.2", "to": "6.10.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -557,7 +589,7 @@ "from": "11.1.0", "to": "11.1.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -565,7 +597,7 @@ "from": "5.5.1", "to": "5.5.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -573,7 +605,7 @@ "from": "7.37.5", "to": "7.37.5", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -581,7 +613,7 @@ "from": "4.6.2", "to": "4.6.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -589,7 +621,7 @@ "from": "3.0.1", "to": "3.0.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -597,7 +629,7 @@ "from": "12.1.1", "to": "12.1.1", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -605,7 +637,7 @@ "from": "3.2.0", "to": "3.2.0", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -613,7 +645,7 @@ "from": "9.1.7", "to": "9.1.7", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -621,7 +653,7 @@ "from": "15.5.2", "to": "15.5.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -629,7 +661,7 @@ "from": "8.4.38", "to": "8.4.38", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -637,15 +669,23 @@ "from": "3.6.2", "to": "3.6.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", "name": "prettier-plugin-tailwindcss", - "from": "0.6.13", + "from": "0.6.14", "to": "0.6.14", "strategy": "installed", - "generatedAt": "2025-09-24T13:59:11.231Z" + "generatedAt": "2025-10-22T12:36:37.962Z" + }, + { + "section": "devDependencies", + "name": "shadcn", + "from": "3.4.1", + "to": "3.4.1", + "strategy": "installed", + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -653,15 +693,15 @@ "from": "0.1.20", "to": "0.1.20", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", "name": "tailwindcss", - "from": "3.4.3", + "from": "4.1.13", "to": "4.1.13", "strategy": "installed", - "generatedAt": "2025-09-24T13:59:11.231Z" + "generatedAt": "2025-10-22T12:36:37.962Z" }, { "section": "devDependencies", @@ -669,6 +709,6 @@ "from": "5.5.4", "to": "5.5.4", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.554Z" + "generatedAt": "2025-10-22T12:36:37.962Z" } ] diff --git a/ui/package-lock.json b/ui/package-lock.json index 49aaa1bfe6..1530aa3134 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -36,6 +36,7 @@ "alert": "6.0.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "d3": "7.9.0", "date-fns": "4.1.0", "framer-motion": "11.18.2", "intl-messageformat": "10.7.16", @@ -58,17 +59,21 @@ "sharp": "0.33.5", "tailwind-merge": "3.3.1", "tailwindcss-animate": "1.0.7", + "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", "uuid": "11.1.0", + "world-atlas": "2.0.2", "zod": "4.1.11", "zustand": "5.0.8" }, "devDependencies": { "@iconify/react": "5.2.1", "@playwright/test": "1.56.1", + "@types/d3": "7.4.3", "@types/node": "20.5.7", "@types/react": "19.1.13", "@types/react-dom": "19.1.9", + "@types/topojson-client": "3.1.5", "@types/uuid": "10.0.0", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", @@ -722,26 +727,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bundled-es-modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cookie": "^0.7.2" - } - }, - "node_modules/@bundled-es-modules/statuses": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", - "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", - "dev": true, - "license": "ISC", - "dependencies": { - "statuses": "^2.0.1" - } - }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", @@ -971,9 +956,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", + "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", "license": "MIT", "optional": true, "dependencies": { @@ -982,9 +967,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", "license": "MIT", "optional": true, "dependencies": { @@ -1021,9 +1006,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -3376,9 +3361,9 @@ } }, "node_modules/@inquirer/ansi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", - "integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz", + "integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==", "dev": true, "license": "MIT", "engines": { @@ -3386,14 +3371,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.18", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.18.tgz", - "integrity": "sha512-MilmWOzHa3Ks11tzvuAmFoAd/wRuaP3SwlT1IZhyMke31FKLxPiuDWcGXhU+PKveNOpAc4axzAgrgxuIJJRmLw==", + "version": "5.1.19", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.19.tgz", + "integrity": "sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/type": "^3.0.8" + "@inquirer/core": "^10.3.0", + "@inquirer/type": "^3.0.9" }, "engines": { "node": ">=18" @@ -3408,15 +3393,15 @@ } }, "node_modules/@inquirer/core": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz", - "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz", + "integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.0", - "@inquirer/figures": "^1.0.13", - "@inquirer/type": "^3.0.8", + "@inquirer/ansi": "^1.0.1", + "@inquirer/figures": "^1.0.14", + "@inquirer/type": "^3.0.9", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", @@ -3499,9 +3484,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", - "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz", + "integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==", "dev": true, "license": "MIT", "engines": { @@ -3509,9 +3494,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", - "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz", + "integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==", "dev": true, "license": "MIT", "engines": { @@ -3750,9 +3735,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.1.6.tgz", - "integrity": "sha512-PeXxfo4ls8yql6YdW8qjnZgp1giy7oqJiGjy4j2OSJ7lpkir8n62YpvADDByEh9sPzGLJYh92ZUAh0GNfQ18vA==", + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.1.10.tgz", + "integrity": "sha512-9srSCb2bSvcvehMgjA2sMMwX0o1VUgPN6ghwm5Fwc9JGAKsQa6n1S4eCwy1h4abuYxwajH5n3spBw+4I2WYbgw==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -3899,9 +3884,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.1.tgz", - "integrity": "sha512-d//GE8/Yh7aC3e7p+kZG8JqqEAwwDUmAfvH1quogtbk+ksS6E0RR6toKKESPYYZVre0meqkJb27zb+dhqE9Sgw==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", + "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3943,9 +3928,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.39.7", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.7.tgz", - "integrity": "sha512-sURvQbbKsq5f8INV54YJgJEdk8oxBanqkTiXXd33rKmofFCwZLhLRszPduMZ9TA9b8/1CHc/IJmOlBHJk2Q5AQ==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5991,23 +5976,23 @@ } }, "node_modules/@react-aria/grid": { - "version": "3.14.4", - "resolved": "https://registry.npmjs.org/@react-aria/grid/-/grid-3.14.4.tgz", - "integrity": "sha512-l1FLQNKnoHpY4UClUTPUV0AqJ5bfAULEE0ErY86KznWLd+Hqzo7mHLqqDV02CDa/8mIUcdoax/MrYYIbPDlOZA==", + "version": "3.14.5", + "resolved": "https://registry.npmjs.org/@react-aria/grid/-/grid-3.14.5.tgz", + "integrity": "sha512-XHw6rgjlTqc85e3zjsWo3U0EVwjN5MOYtrolCKc/lc2ItNdcY3OlMhpsU9+6jHwg/U3VCSWkGvwAz9hg7krd8Q==", "license": "Apache-2.0", "dependencies": { - "@react-aria/focus": "^3.21.1", - "@react-aria/i18n": "^3.12.12", - "@react-aria/interactions": "^3.25.5", + "@react-aria/focus": "^3.21.2", + "@react-aria/i18n": "^3.12.13", + "@react-aria/interactions": "^3.25.6", "@react-aria/live-announcer": "^3.4.4", - "@react-aria/selection": "^3.25.1", - "@react-aria/utils": "^3.30.1", - "@react-stately/collections": "^3.12.7", - "@react-stately/grid": "^3.11.5", - "@react-stately/selection": "^3.20.5", - "@react-types/checkbox": "^3.10.1", - "@react-types/grid": "^3.3.5", - "@react-types/shared": "^3.32.0", + "@react-aria/selection": "^3.26.0", + "@react-aria/utils": "^3.31.0", + "@react-stately/collections": "^3.12.8", + "@react-stately/grid": "^3.11.6", + "@react-stately/selection": "^3.20.6", + "@react-types/checkbox": "^3.10.2", + "@react-types/grid": "^3.3.6", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -6015,6 +6000,158 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-aria/grid/node_modules/@internationalized/date": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", + "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-aria/i18n": { + "version": "3.12.13", + "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.13.tgz", + "integrity": "sha512-YTM2BPg0v1RvmP8keHenJBmlx8FXUKsdYIEX7x6QWRd1hKlcDwphfjzvt0InX9wiLiPHsT5EoBTpuUk8SXc0Mg==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.10.0", + "@internationalized/message": "^3.1.8", + "@internationalized/number": "^3.6.5", + "@internationalized/string": "^3.2.7", + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-aria/selection": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@react-aria/selection/-/selection-3.26.0.tgz", + "integrity": "sha512-ZBH3EfWZ+RfhTj01dH8L17uT7iNbXWS8u77/fUpHgtrm0pwNVhx0TYVnLU1YpazQ/3WVpvWhmBB8sWwD1FlD/g==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/focus": "^3.21.2", + "@react-aria/i18n": "^3.12.13", + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-stately/selection": "^3.20.6", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-stately/collections": { + "version": "3.12.8", + "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.8.tgz", + "integrity": "sha512-AceJYLLXt1Y2XIcOPi6LEJSs4G/ubeYW3LqOCQbhfIgMaNqKfQMIfagDnPeJX9FVmPFSlgoCBxb1pTJW2vjCAQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-types/checkbox": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@react-types/checkbox/-/checkbox-3.10.2.tgz", + "integrity": "sha512-ktPkl6ZfIdGS1tIaGSU/2S5Agf2NvXI9qAgtdMDNva0oLyAZ4RLQb6WecPvofw1J7YKXu0VA5Mu7nlX+FM2weQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/grid/node_modules/@react-types/grid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@react-types/grid/-/grid-3.3.6.tgz", + "integrity": "sha512-vIZJlYTii2n1We9nAugXwM2wpcpsC6JigJFBd6vGhStRdRWRoU4yv1Gc98Usbx0FQ/J7GLVIgeG8+1VMTKBdxw==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-aria/i18n": { "version": "3.12.12", "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.12.tgz", @@ -6098,13 +6235,13 @@ } }, "node_modules/@react-aria/landmark": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.6.tgz", - "integrity": "sha512-dMPBqJWTDAr3Lj5hA+XYDH2PWqtFghYy+y7iq7K5sK/96cub8hZEUjhwn+HGgHsLerPp0dWt293nKupAJnf4Vw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.7.tgz", + "integrity": "sha512-t8c610b8hPLS6Vwv+rbuSyljZosI1s5+Tosfa0Fk4q7d+Ex6Yj7hLfUFy59GxZAufhUYfGX396fT0gPqAbU1tg==", "license": "Apache-2.0", "dependencies": { - "@react-aria/utils": "^3.30.1", - "@react-types/shared": "^3.32.0", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.4.0" }, @@ -6113,16 +6250,49 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/link": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.8.5.tgz", - "integrity": "sha512-klhV4roPp5MLRXJv1N+7SXOj82vx4gzVpuwQa3vouA+YI1my46oNzwgtkLGSTvE9OvDqYzPDj2YxFYhMywrkuw==", + "node_modules/@react-aria/landmark/node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", "license": "Apache-2.0", "dependencies": { - "@react-aria/interactions": "^3.25.5", - "@react-aria/utils": "^3.30.1", - "@react-types/link": "^3.6.4", - "@react-types/shared": "^3.32.0", + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/landmark/node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/link": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.8.6.tgz", + "integrity": "sha512-7F7UDJnwbU9IjfoAdl6f3Hho5/WB7rwcydUOjUux0p7YVWh/fTjIFjfAGyIir7MJhPapun1D0t97QQ3+8jXVcg==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/link": "^3.6.5", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -6130,6 +6300,68 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-aria/link/node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/link/node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/link/node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/link/node_modules/@react-types/link": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@react-types/link/-/link-3.6.5.tgz", + "integrity": "sha512-+I2s3XWBEvLrzts0GnNeA84mUkwo+a7kLUWoaJkW0TOBDG7my95HFYxF9WnqKye7NgpOkCqz4s3oW96xPdIniQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-aria/listbox": { "version": "3.14.8", "resolved": "https://registry.npmjs.org/@react-aria/listbox/-/listbox-3.14.8.tgz", @@ -6232,6 +6464,23 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-aria/overlays/node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-aria/overlays/node_modules/@react-aria/ssr": { "version": "3.9.10", "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", @@ -6247,15 +6496,33 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/overlays/node_modules/@react-aria/visually-hidden": { - "version": "3.8.27", - "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.27.tgz", - "integrity": "sha512-hD1DbL3WnjPnCdlQjwe19bQVRAGJyN0Aaup+s7NNtvZUn7AjoEH78jo8TE+L8yM7z/OZUQF26laCfYqeIwWn4g==", + "node_modules/@react-aria/overlays/node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", "license": "Apache-2.0", "dependencies": { - "@react-aria/interactions": "^3.25.5", - "@react-aria/utils": "^3.30.1", - "@react-types/shared": "^3.32.0", + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/overlays/node_modules/@react-aria/visually-hidden": { + "version": "3.8.28", + "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.28.tgz", + "integrity": "sha512-KRRjbVVob2CeBidF24dzufMxBveEUtUu7IM+hpdZKB+gxVROoh4XRLPv9SFmaH89Z7D9To3QoykVZoWD0lan6Q==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -6343,16 +6610,16 @@ } }, "node_modules/@react-aria/spinbutton": { - "version": "3.6.18", - "resolved": "https://registry.npmjs.org/@react-aria/spinbutton/-/spinbutton-3.6.18.tgz", - "integrity": "sha512-dnmh7sNsprhYTpqCJhcuc9QJ9C/IG/o9TkgW5a9qcd2vS+dzEgqAiJKIMbJFG9kiJymv2NwIPysF12IWix+J3A==", + "version": "3.6.19", + "resolved": "https://registry.npmjs.org/@react-aria/spinbutton/-/spinbutton-3.6.19.tgz", + "integrity": "sha512-xOIXegDpts9t3RSHdIN0iYQpdts0FZ3LbpYJIYVvdEHo9OpDS+ElnDzCGtwZLguvZlwc5s1LAKuKopDUsAEMkw==", "license": "Apache-2.0", "dependencies": { - "@react-aria/i18n": "^3.12.12", + "@react-aria/i18n": "^3.12.13", "@react-aria/live-announcer": "^3.4.4", - "@react-aria/utils": "^3.30.1", - "@react-types/button": "^3.14.0", - "@react-types/shared": "^3.32.0", + "@react-aria/utils": "^3.31.0", + "@react-types/button": "^3.14.1", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -6360,6 +6627,80 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-aria/spinbutton/node_modules/@internationalized/date": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", + "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-aria/spinbutton/node_modules/@react-aria/i18n": { + "version": "3.12.13", + "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.13.tgz", + "integrity": "sha512-YTM2BPg0v1RvmP8keHenJBmlx8FXUKsdYIEX7x6QWRd1hKlcDwphfjzvt0InX9wiLiPHsT5EoBTpuUk8SXc0Mg==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.10.0", + "@internationalized/message": "^3.1.8", + "@internationalized/number": "^3.6.5", + "@internationalized/string": "^3.2.7", + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/spinbutton/node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/spinbutton/node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/spinbutton/node_modules/@react-types/button": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.14.1.tgz", + "integrity": "sha512-D8C4IEwKB7zEtiWYVJ3WE/5HDcWlze9mLWQ5hfsBfpePyWCgO3bT/+wjb/7pJvcAocrkXo90QrMm85LcpBtrpg==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-aria/ssr": { "version": "3.9.4", "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.4.tgz", @@ -6419,15 +6760,65 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-aria/table/node_modules/@react-aria/visually-hidden": { - "version": "3.8.27", - "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.27.tgz", - "integrity": "sha512-hD1DbL3WnjPnCdlQjwe19bQVRAGJyN0Aaup+s7NNtvZUn7AjoEH78jo8TE+L8yM7z/OZUQF26laCfYqeIwWn4g==", + "node_modules/@react-aria/table/node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", "license": "Apache-2.0", "dependencies": { - "@react-aria/interactions": "^3.25.5", - "@react-aria/utils": "^3.30.1", - "@react-types/shared": "^3.32.0", + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/table/node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/table/node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/table/node_modules/@react-aria/visually-hidden": { + "version": "3.8.28", + "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.28.tgz", + "integrity": "sha512-KRRjbVVob2CeBidF24dzufMxBveEUtUu7IM+hpdZKB+gxVROoh4XRLPv9SFmaH89Z7D9To3QoykVZoWD0lan6Q==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -6497,16 +6888,16 @@ } }, "node_modules/@react-aria/toggle": { - "version": "3.12.1", - "resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.12.1.tgz", - "integrity": "sha512-XaFiRs1KEcIT6bTtVY/KTQxw4kinemj/UwXw2iJTu9XS43hhJ/9cvj8KzNGrKGqaxTpOYj62TnSHZbSiFViHDA==", + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.12.2.tgz", + "integrity": "sha512-g25XLYqJuJpt0/YoYz2Rab8ax+hBfbssllcEFh0v0jiwfk2gwTWfRU9KAZUvxIqbV8Nm8EBmrYychDpDcvW1kw==", "license": "Apache-2.0", "dependencies": { - "@react-aria/interactions": "^3.25.5", - "@react-aria/utils": "^3.30.1", - "@react-stately/toggle": "^3.9.1", - "@react-types/checkbox": "^3.10.1", - "@react-types/shared": "^3.32.0", + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-stately/toggle": "^3.9.2", + "@react-types/checkbox": "^3.10.2", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -6514,6 +6905,83 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-aria/toggle/node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/toggle/node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/toggle/node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/toggle/node_modules/@react-stately/toggle": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@react-stately/toggle/-/toggle-3.9.2.tgz", + "integrity": "sha512-dOxs9wrVXHUmA7lc8l+N9NbTJMAaXcYsnNGsMwfXIXQ3rdq+IjWGNYJ52UmNQyRYFcg0jrzRrU16TyGbNjOdNQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-stately/utils": "^3.10.8", + "@react-types/checkbox": "^3.10.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/toggle/node_modules/@react-types/checkbox": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@react-types/checkbox/-/checkbox-3.10.2.tgz", + "integrity": "sha512-ktPkl6ZfIdGS1tIaGSU/2S5Agf2NvXI9qAgtdMDNva0oLyAZ4RLQb6WecPvofw1J7YKXu0VA5Mu7nlX+FM2weQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-aria/toolbar": { "version": "3.0.0-beta.20", "resolved": "https://registry.npmjs.org/@react-aria/toolbar/-/toolbar-3.0.0-beta.20.tgz", @@ -6704,21 +7172,46 @@ } }, "node_modules/@react-stately/grid": { - "version": "3.11.5", - "resolved": "https://registry.npmjs.org/@react-stately/grid/-/grid-3.11.5.tgz", - "integrity": "sha512-4cNjGYaNkcVS2wZoNHUrMRICBpkHStYw57EVemP7MjiWEVu53kzPgR1Iwmti2WFCpi1Lwu0qWNeCfzKpXW4BTg==", + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@react-stately/grid/-/grid-3.11.6.tgz", + "integrity": "sha512-vWPAkzpeTIsrurHfMubzMuqEw7vKzFhIJeEK5sEcLunyr1rlADwTzeWrHNbPMl66NAIAi70Dr1yNq+kahQyvMA==", "license": "Apache-2.0", "dependencies": { - "@react-stately/collections": "^3.12.7", - "@react-stately/selection": "^3.20.5", - "@react-types/grid": "^3.3.5", - "@react-types/shared": "^3.32.0", + "@react-stately/collections": "^3.12.8", + "@react-stately/selection": "^3.20.6", + "@react-types/grid": "^3.3.6", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-stately/grid/node_modules/@react-stately/collections": { + "version": "3.12.8", + "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.8.tgz", + "integrity": "sha512-AceJYLLXt1Y2XIcOPi6LEJSs4G/ubeYW3LqOCQbhfIgMaNqKfQMIfagDnPeJX9FVmPFSlgoCBxb1pTJW2vjCAQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/grid/node_modules/@react-types/grid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@react-types/grid/-/grid-3.3.6.tgz", + "integrity": "sha512-vIZJlYTii2n1We9nAugXwM2wpcpsC6JigJFBd6vGhStRdRWRoU4yv1Gc98Usbx0FQ/J7GLVIgeG8+1VMTKBdxw==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-stately/list": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/@react-stately/list/-/list-3.13.0.tgz", @@ -6797,31 +7290,113 @@ } }, "node_modules/@react-stately/select": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@react-stately/select/-/select-3.7.1.tgz", - "integrity": "sha512-vZt4j9yVyOTWWJoP9plXmYaPZH2uMxbjcGMDbiShwsFiK8C2m9b3Cvy44TZehfzCWzpMVR/DYxEYuonEIGA82Q==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@react-stately/select/-/select-3.8.0.tgz", + "integrity": "sha512-A721nlt0DSCDit0wKvhcrXFTG5Vv1qkEVkeKvobmETZy6piKvwh0aaN8iQno5AFuZaj1iOZeNjZ/20TsDJR/4A==", "license": "Apache-2.0", "dependencies": { - "@react-stately/form": "^3.2.1", - "@react-stately/list": "^3.13.0", - "@react-stately/overlays": "^3.6.19", - "@react-types/select": "^3.10.1", - "@react-types/shared": "^3.32.0", + "@react-stately/form": "^3.2.2", + "@react-stately/list": "^3.13.1", + "@react-stately/overlays": "^3.6.20", + "@react-stately/utils": "^3.10.8", + "@react-types/select": "^3.11.0", + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-stately/selection": { - "version": "3.20.5", - "resolved": "https://registry.npmjs.org/@react-stately/selection/-/selection-3.20.5.tgz", - "integrity": "sha512-YezWUNEn2pz5mQlbhmngiX9HqQsruLSXlkrAzB1DD6aliGrUvPKufTTGCixOaB8KVeCamdiFAgx1WomNplzdQA==", + "node_modules/@react-stately/select/node_modules/@react-stately/collections": { + "version": "3.12.8", + "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.8.tgz", + "integrity": "sha512-AceJYLLXt1Y2XIcOPi6LEJSs4G/ubeYW3LqOCQbhfIgMaNqKfQMIfagDnPeJX9FVmPFSlgoCBxb1pTJW2vjCAQ==", "license": "Apache-2.0", "dependencies": { - "@react-stately/collections": "^3.12.7", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/select/node_modules/@react-stately/form": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@react-stately/form/-/form-3.2.2.tgz", + "integrity": "sha512-soAheOd7oaTO6eNs6LXnfn0tTqvOoe3zN9FvtIhhrErKz9XPc5sUmh3QWwR45+zKbitOi1HOjfA/gifKhZcfWw==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/select/node_modules/@react-stately/list": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/@react-stately/list/-/list-3.13.1.tgz", + "integrity": "sha512-eHaoauh21twbcl0kkwULhVJ+CzYcy1jUjMikNVMHOQdhr4WIBdExf7PmSgKHKqsSPhpGg6IpTCY2dUX3RycjDg==", + "license": "Apache-2.0", + "dependencies": { + "@react-stately/collections": "^3.12.8", + "@react-stately/selection": "^3.20.6", "@react-stately/utils": "^3.10.8", - "@react-types/shared": "^3.32.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/select/node_modules/@react-stately/overlays": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/@react-stately/overlays/-/overlays-3.6.20.tgz", + "integrity": "sha512-YAIe+uI8GUXX8F/0Pzr53YeC5c/bjqbzDFlV8NKfdlCPa6+Jp4B/IlYVjIooBj9+94QvbQdjylegvYWK/iPwlg==", + "license": "Apache-2.0", + "dependencies": { + "@react-stately/utils": "^3.10.8", + "@react-types/overlays": "^3.9.2", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/select/node_modules/@react-types/overlays": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.2.tgz", + "integrity": "sha512-Q0cRPcBGzNGmC8dBuHyoPR7N3057KTS5g+vZfQ53k8WwmilXBtemFJPLsogJbspuewQ/QJ3o2HYsp2pne7/iNw==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/selection": { + "version": "3.20.6", + "resolved": "https://registry.npmjs.org/@react-stately/selection/-/selection-3.20.6.tgz", + "integrity": "sha512-a0bjuP2pJYPKEiedz2Us1W1aSz0iHRuyeQEdBOyL6Z6VUa6hIMq9H60kvseir2T85cOa4QggizuRV7mcO6bU5w==", + "license": "Apache-2.0", + "dependencies": { + "@react-stately/collections": "^3.12.8", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/selection/node_modules/@react-stately/collections": { + "version": "3.12.8", + "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.8.tgz", + "integrity": "sha512-AceJYLLXt1Y2XIcOPi6LEJSs4G/ubeYW3LqOCQbhfIgMaNqKfQMIfagDnPeJX9FVmPFSlgoCBxb1pTJW2vjCAQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -7053,13 +7628,25 @@ } }, "node_modules/@react-types/dialog": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@react-types/dialog/-/dialog-3.5.21.tgz", - "integrity": "sha512-jF1gN4bvwYamsLjefaFDnaSKxTa3Wtvn5f7WLjNVZ8ICVoiMBMdUJXTlPQHAL4YWqtCj4hK/3uimR1E+Pwd7Xw==", + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@react-types/dialog/-/dialog-3.5.22.tgz", + "integrity": "sha512-smSvzOcqKE196rWk0oqJDnz+ox5JM5+OT0PmmJXiUD4q7P5g32O6W5Bg7hMIFUI9clBtngo8kLaX2iMg+GqAzg==", "license": "Apache-2.0", "dependencies": { - "@react-types/overlays": "^3.9.1", - "@react-types/shared": "^3.32.0" + "@react-types/overlays": "^3.9.2", + "@react-types/shared": "^3.32.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/dialog/node_modules/@react-types/overlays": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.2.tgz", + "integrity": "sha512-Q0cRPcBGzNGmC8dBuHyoPR7N3057KTS5g+vZfQ53k8WwmilXBtemFJPLsogJbspuewQ/QJ3o2HYsp2pne7/iNw==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" @@ -7102,12 +7689,12 @@ } }, "node_modules/@react-types/listbox": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/@react-types/listbox/-/listbox-3.7.3.tgz", - "integrity": "sha512-ONgror9uyGmIer5XxpRRNcc8QFVWiOzINrMKyaS8G4l3aP52ZwYpRfwMAVtra8lkVNvXDmO7hthPZkB6RYdNOA==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@react-types/listbox/-/listbox-3.7.4.tgz", + "integrity": "sha512-p4YEpTl/VQGrqVE8GIfqTS5LkT5jtjDTbVeZgrkPnX/fiPhsfbTPiZ6g0FNap4+aOGJFGEEZUv2q4vx+rCORww==", "license": "Apache-2.0", "dependencies": { - "@react-types/shared": "^3.32.0" + "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" @@ -7175,12 +7762,12 @@ } }, "node_modules/@react-types/select": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@react-types/select/-/select-3.10.1.tgz", - "integrity": "sha512-teANUr1byOzGsS/r2j7PatV470JrOhKP8En9lscfnqW5CeUghr+0NxkALnPkiEhCObi/Vu8GIcPareD0HNhtFA==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@react-types/select/-/select-3.11.0.tgz", + "integrity": "sha512-SzIsMFVPCbXE1Z1TLfpdfiwJ1xnIkcL1/CjGilmUKkNk5uT7rYX1xCJqWCjXI0vAU1xM4Qn+T3n8de4fw6HRBg==", "license": "Apache-2.0", "dependencies": { - "@react-types/shared": "^3.32.0" + "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" @@ -7196,24 +7783,24 @@ } }, "node_modules/@react-types/slider": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@react-types/slider/-/slider-3.8.1.tgz", - "integrity": "sha512-WxiQWj6iQr5Uft0/KcB9XSr361XnyTmL6eREZZacngA9CjPhRWYP3BRDPcCTuP7fj9Yi4QKMrryyjHqMHP8OKQ==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@react-types/slider/-/slider-3.8.2.tgz", + "integrity": "sha512-MQYZP76OEOYe7/yA2To+Dl0LNb0cKKnvh5JtvNvDnAvEprn1RuLiay8Oi/rTtXmc2KmBa4VdTcsXsmkbbkeN2Q==", "license": "Apache-2.0", "dependencies": { - "@react-types/shared": "^3.32.0" + "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-types/switch": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@react-types/switch/-/switch-3.5.14.tgz", - "integrity": "sha512-M8kIv97i+ejCel4Ho+Y7tDbpOehymGwPA4ChxibeyD32+deyxu5B6BXxgKiL3l+oTLQ8ihLo3sRESdPFw8vpQg==", + "version": "3.5.15", + "resolved": "https://registry.npmjs.org/@react-types/switch/-/switch-3.5.15.tgz", + "integrity": "sha512-r/ouGWQmIeHyYSP1e5luET+oiR7N7cLrAlWsrAfYRWHxqXOSNQloQnZJ3PLHrKFT02fsrQhx2rHaK2LfKeyN3A==", "license": "Apache-2.0", "dependencies": { - "@react-types/shared": "^3.32.0" + "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" @@ -7233,12 +7820,12 @@ } }, "node_modules/@react-types/tabs": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/@react-types/tabs/-/tabs-3.3.18.tgz", - "integrity": "sha512-yX/AVlGS7VXCuy2LSm8y8nxUrKVBgnLv+FrtkLqf6jUMtD4KP3k1c4+GPHeScR0HcYzCQF7gCF3Skba1RdYoug==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/@react-types/tabs/-/tabs-3.3.19.tgz", + "integrity": "sha512-fE+qI43yR5pAMpeqPxGqQq9jDHXEPqXskuxNHERMW0PYMdPyem2Cw6goc5F4qeZO3Hf6uPZgHkvJz2OAq7TbBw==", "license": "Apache-2.0", "dependencies": { - "@react-types/shared": "^3.32.0" + "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" @@ -7277,9 +7864,9 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", - "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.0.tgz", + "integrity": "sha512-WJFej426qe4RWOm9MMtP4V3CV4AucXolQty+GRgAWLgQXmpCuwzs7hEpxxhSc/znXUSxum9d/P/32MW0FlAAlA==", "dev": true, "license": "MIT" }, @@ -7756,12 +8343,44 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } }, "node_modules/@types/d3-array": { "version": "3.2.2", @@ -7769,18 +8388,128 @@ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -7796,6 +8525,27 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -7805,6 +8555,20 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", @@ -7820,12 +8584,40 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -7850,6 +8642,13 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -7932,6 +8731,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -8543,9 +9363,9 @@ } }, "node_modules/ansis": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", - "integrity": "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", "dev": true, "license": "ISC", "engines": { @@ -8835,9 +9655,9 @@ } }, "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", "dev": true, "license": "MPL-2.0", "engines": { @@ -8902,9 +9722,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", - "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz", + "integrity": "sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8956,9 +9776,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", - "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -8976,9 +9796,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001741", - "electron-to-chromium": "^1.5.218", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, @@ -9072,9 +9892,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001745", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", - "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", "funding": [ { "type": "opencollective", @@ -9417,13 +10237,12 @@ } }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "dev": true, + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 10" } }, "node_modules/compute-scroll-into-view": { @@ -9440,12 +10259,12 @@ "license": "MIT" }, "node_modules/console-table-printer": { - "version": "2.14.6", - "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.14.6.tgz", - "integrity": "sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", + "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", "license": "MIT", "dependencies": { - "simple-wcswidth": "^1.0.1" + "simple-wcswidth": "^1.1.2" } }, "node_modules/content-disposition": { @@ -9572,6 +10391,47 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -9584,6 +10444,43 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -9593,6 +10490,77 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -9602,6 +10570,32 @@ "node": ">=12" } }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -9611,6 +10605,27 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -9632,6 +10647,33 @@ "node": ">=12" } }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -9648,6 +10690,28 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -9693,6 +10757,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -9892,6 +10991,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -9912,9 +11020,9 @@ } }, "node_modules/detect-libc": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", - "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -9986,9 +11094,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", - "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -10014,15 +11122,15 @@ } }, "node_modules/eciesjs": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.15.tgz", - "integrity": "sha512-r6kEJXDKecVOCj2nLMuXK/FCPeurW33+3JRpfXVbjLja3XUYFfD9I/JBreH6sUyzcm3G/YQboBjMla6poKeSdA==", + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.16.tgz", + "integrity": "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw==", "dev": true, "license": "MIT", "dependencies": { - "@ecies/ciphers": "^0.2.3", + "@ecies/ciphers": "^0.2.4", "@noble/ciphers": "^1.3.0", - "@noble/curves": "^1.9.1", + "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" }, "engines": { @@ -10039,9 +11147,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.223", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.223.tgz", - "integrity": "sha512-qKm55ic6nbEmagFlTFczML33rF90aU+WtrJ9MdTCThrcvDNdUHN4p6QfVN78U06ZmguqXIyMPyYhw2TrbDUwPQ==", + "version": "1.5.238", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.238.tgz", + "integrity": "sha512-khBdc+w/Gv+cS8e/Pbnaw/FXcBUeKrRVik9IxfXtgREOWyJhR4tj43n3amkVogJ/yeQUqzkrZcFhtIxIdqmmcQ==", "dev": true, "license": "ISC" }, @@ -11254,9 +12362,9 @@ "license": "Apache-2.0" }, "node_modules/fast-equals": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.0.tgz", - "integrity": "sha512-xwP+dG/in/nJelMOUEQBiIYeOoHKihWPB2sNZ8ZeDbZFoGb1OwTGMggGRgg6CRitNx7kmHgtIz2dOHDQ8Ap7Bw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz", + "integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -11630,6 +12738,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -11756,9 +12874,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12138,7 +13256,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -12491,14 +13608,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -12864,9 +13982,9 @@ } }, "node_modules/jiti": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", - "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -13027,9 +14145,9 @@ } }, "node_modules/langsmith": { - "version": "0.3.71", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.71.tgz", - "integrity": "sha512-xl00JZso7J3OaurUQ+seT2qRJ34OGZXYAvCYj3vNC3TB+JOcdcYZ1uLvENqOloKB8VCiADh1eZ0FG3Cj/cy2FQ==", + "version": "0.3.74", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.74.tgz", + "integrity": "sha512-ZuW3Qawz8w88XcuCRH91yTp6lsdGuwzRqZ5J0Hf5q/AjMz7DwcSv0MkE6V5W+8hFMI850QZN2Wlxwm3R9lHlZg==", "license": "MIT", "dependencies": { "@types/uuid": "^10.0.0", @@ -13397,6 +14515,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/lint-staged/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/listr2": { "version": "8.3.3", "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", @@ -14432,20 +15560,18 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.11.3", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.3.tgz", - "integrity": "sha512-878imp8jxIpfzuzxYfX0qqTq1IFQz/1/RBHs/PyirSjzi+xKM/RRfIpIqHSCWjH0GxidrjhgiiXC+DWXNDvT9w==", + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.6.tgz", + "integrity": "sha512-MCYMykvmiYScyUm7I6y0VCxpNq1rgd5v7kG8ks5dKtvmxRUUPjribX6mUoUNBbM5/3PhUyoelEWiKXGOz84c+w==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@bundled-es-modules/cookie": "^2.0.1", - "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.39.1", + "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", - "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", @@ -14453,6 +15579,7 @@ "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", + "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^4.26.1", @@ -14477,6 +15604,16 @@ } } }, + "node_modules/msw/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/msw/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -14528,9 +15665,9 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", - "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", "bin": { @@ -15132,9 +16269,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", + "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", "dev": true, "license": "MIT" }, @@ -15178,9 +16315,9 @@ } }, "node_modules/oauth4webapi": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.1.tgz", - "integrity": "sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.2.tgz", + "integrity": "sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -15555,9 +16692,9 @@ } }, "node_modules/package-manager-detector": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", - "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.5.0.tgz", + "integrity": "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==", "dev": true, "license": "MIT" }, @@ -16581,13 +17718,13 @@ } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -16705,6 +17842,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -16767,6 +17910,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -16857,7 +18006,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -16882,9 +18030,9 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -17591,9 +18739,9 @@ } }, "node_modules/string-width/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -17808,18 +18956,18 @@ } }, "node_modules/style-to-js": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", - "integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz", + "integrity": "sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==", "license": "MIT", "dependencies": { - "style-to-object": "1.0.9" + "style-to-object": "1.0.11" } }, "node_modules/style-to-object": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz", - "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.11.tgz", + "integrity": "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==", "license": "MIT", "dependencies": { "inline-style-parser": "0.2.4" @@ -17956,9 +19104,9 @@ } }, "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", "engines": { "node": ">=6" @@ -18080,22 +19228,22 @@ } }, "node_modules/tldts": { - "version": "7.0.16", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz", - "integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==", + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.16" + "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.16", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz", - "integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==", + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", "dev": true, "license": "MIT" }, @@ -18122,6 +19270,26 @@ "node": ">=0.6" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -18405,9 +19573,9 @@ } }, "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -18459,9 +19627,9 @@ } }, "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18667,9 +19835,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -18879,6 +20047,12 @@ "node": ">=0.10.0" } }, + "node_modules/world-atlas": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz", + "integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index c491fb4ba7..ee103a6f45 100644 --- a/ui/package.json +++ b/ui/package.json @@ -50,6 +50,7 @@ "alert": "6.0.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "d3": "7.9.0", "date-fns": "4.1.0", "framer-motion": "11.18.2", "intl-messageformat": "10.7.16", @@ -72,17 +73,21 @@ "sharp": "0.33.5", "tailwind-merge": "3.3.1", "tailwindcss-animate": "1.0.7", + "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", "uuid": "11.1.0", + "world-atlas": "2.0.2", "zod": "4.1.11", "zustand": "5.0.8" }, "devDependencies": { "@iconify/react": "5.2.1", + "@types/d3": "7.4.3", "@playwright/test": "1.56.1", "@types/node": "20.5.7", "@types/react": "19.1.13", "@types/react-dom": "19.1.9", + "@types/topojson-client": "3.1.5", "@types/uuid": "10.0.0", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 8209e59934..3265d97c4f 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -1,8 +1,59 @@ @import "tailwindcss"; @config "../tailwind.config.js"; +@theme { + /* Chart Severity Colors - Dark Theme */ + --chart-info: #2e51b2; + --chart-warning: #fdd34f; + --chart-warning-emphasis: #ff7d19; + --chart-danger: #ff3077; + --chart-danger-emphasis: #971348; + + /* Chart Status Colors */ + --chart-success-color: #86da26; + --chart-fail: #db2b49; + + /* Chart Radar Colors */ + --chart-radar-primary: #b51c80; + --chart-radar-primary-rgb: 181 28 128; + + /* Chart Provider Colors */ + --chart-provider-aws: #ff9900; + --chart-provider-azure: #00bcd4; + --chart-provider-google: #EA4335; + + /* Chart UI Colors - Dark Theme (defaults) */ + --chart-text-primary: #ffffff; + --chart-text-secondary: #94a3b8; + --chart-border: #475569; + --chart-border-emphasis: #334155; + --chart-background: #1e293b; + + /* Chart Alert Colors */ + --chart-alert-bg: #432232; + --chart-alert-text: #f54280; +} + @layer base { :root { + /* Light Theme Chart Colors */ + --chart-info: #1e40af; + --chart-warning: #d97706; + --chart-warning-emphasis: #dc2626; + --chart-danger: #dc2626; + --chart-danger-emphasis: #991b1b; + --chart-success-color: #16a34a; + --chart-fail: #dc2626; + --chart-radar-primary: #9d174d; + --chart-text-primary: #1f2937; + --chart-text-secondary: #6b7280; + --chart-border: #d1d5db; + --chart-border-emphasis: #9ca3af; + --chart-background: #f9fafb; + --chart-alert-bg: #fecdd3; + --chart-alert-text: #be123c; + + /* Chart HSL values */ --chart-success: 146 80% 35%; --chart-fail: 339 90% 51%; --chart-muted: 45 93% 47%; @@ -17,6 +68,24 @@ } .dark { + /* Dark Theme Chart Colors */ + --chart-info: #2e51b2; + --chart-warning: #fdd34f; + --chart-warning-emphasis: #ff7d19; + --chart-danger: #ff3077; + --chart-danger-emphasis: #971348; + --chart-success-color: #86da26; + --chart-fail: #db2b49; + --chart-radar-primary: #b51c80; + --chart-text-primary: #ffffff; + --chart-text-secondary: #94a3b8; + --chart-border: #475569; + --chart-border-emphasis: #334155; + --chart-background: #1e293b; + --chart-alert-bg: #432232; + --chart-alert-text: #f54280; + + /* Chart HSL values */ --chart-success: 146 80% 35%; --chart-fail: 339 90% 51%; --chart-muted: 45 93% 47%; @@ -56,6 +125,7 @@ transform-box: fill-box; transform-origin: center; } + } @layer base { From 9f372902addb5a8394d220e1f1b24d2857cbf80a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 22 Oct 2025 15:59:56 +0200 Subject: [PATCH 02/11] feat(threatscore): support compliance pdf reporting (#8867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sergio Garcia Co-authored-by: alejandrobailo Co-authored-by: Víctor Fernández Poyatos --- .gitignore | 3 + api/CHANGELOG.md | 1 + api/poetry.lock | 529 ++++++- api/pyproject.toml | 4 +- api/src/backend/api/v1/views.py | 61 + .../tasks/assets/fonts/FiraCode-Regular.ttf | Bin 0 -> 188500 bytes .../assets/fonts/PlusJakartaSans-Regular.ttf | Bin 0 -> 94760 bytes .../backend/tasks/assets/img/prowler_logo.png | Bin 0 -> 24198 bytes api/src/backend/tasks/jobs/export.py | 11 +- api/src/backend/tasks/jobs/report.py | 1332 +++++++++++++++++ api/src/backend/tasks/tasks.py | 34 +- api/src/backend/tasks/tests/test_export.py | 8 +- api/src/backend/tasks/tests/test_report.py | 957 ++++++++++++ api/src/backend/tasks/tests/test_tasks.py | 186 ++- ui/CHANGELOG.md | 1 + ui/actions/scans/scans.ts | 42 + .../compliance/[compliancetitle]/page.tsx | 27 +- .../threatscore-download-button.tsx | 45 + ui/app/(prowler)/compliance/page.tsx | 107 +- .../compliance-header/data-compliance.tsx | 4 +- ui/components/compliance/index.ts | 2 + .../compliance/threatscore-badge.tsx | 148 ++ ui/components/compliance/threatscore-logo.tsx | 79 + ui/lib/compliance/threatscore-calculator.ts | 69 + ui/lib/helper.ts | 49 +- 25 files changed, 3587 insertions(+), 112 deletions(-) create mode 100644 api/src/backend/tasks/assets/fonts/FiraCode-Regular.ttf create mode 100644 api/src/backend/tasks/assets/fonts/PlusJakartaSans-Regular.ttf create mode 100644 api/src/backend/tasks/assets/img/prowler_logo.png create mode 100644 api/src/backend/tasks/jobs/report.py create mode 100644 api/src/backend/tasks/tests/test_report.py create mode 100644 ui/app/(prowler)/compliance/[compliancetitle]/threatscore-download-button.tsx create mode 100644 ui/components/compliance/threatscore-badge.tsx create mode 100644 ui/components/compliance/threatscore-logo.tsx create mode 100644 ui/lib/compliance/threatscore-calculator.ts diff --git a/.gitignore b/.gitignore index 4b39b18b83..74dcdac26a 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,6 @@ CLAUDE.md # MCP Server mcp_server/prowler_mcp_server/prowler_app/server.py mcp_server/prowler_mcp_server/prowler_app/utils/schema.yaml + +# Compliance report +*.pdf diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index bdbcdcda6d..be3eacf644 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler API** are documented in this file. - API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805) - SAML role mapping protection for single-admin tenants to prevent accidental lockout [(#8882)](https://github.com/prowler-cloud/prowler/pull/8882) - Support for `passed_findings` and `total_findings` fields in compliance requirement overview for accurate Prowler ThreatScore calculation [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) +- PDF reporting for Prowler ThreatScore [(#8867)](https://github.com/prowler-cloud/prowler/pull/8867) - Database read replica support [(#8869)](https://github.com/prowler-cloud/prowler/pull/8869) - Support Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000) - Add `provider_id__in` filter support to findings and findings severity overview endpoints [(#8951)](https://github.com/prowler-cloud/prowler/pull/8951) diff --git a/api/poetry.lock b/api/poetry.lock index 61929f3bdc..40313d9fa5 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1256,6 +1256,98 @@ files = [ {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, ] +[[package]] +name = "contourpy" +version = "1.3.3" +description = "Python library for calculating contours of 2D quadrilateral grids" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1"}, + {file = "contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381"}, + {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7"}, + {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1"}, + {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a"}, + {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db"}, + {file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620"}, + {file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f"}, + {file = "contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff"}, + {file = "contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42"}, + {file = "contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470"}, + {file = "contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb"}, + {file = "contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6"}, + {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7"}, + {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8"}, + {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea"}, + {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1"}, + {file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7"}, + {file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411"}, + {file = "contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69"}, + {file = "contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b"}, + {file = "contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc"}, + {file = "contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5"}, + {file = "contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1"}, + {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286"}, + {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5"}, + {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67"}, + {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9"}, + {file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659"}, + {file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7"}, + {file = "contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d"}, + {file = "contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263"}, + {file = "contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9"}, + {file = "contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d"}, + {file = "contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216"}, + {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae"}, + {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20"}, + {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99"}, + {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b"}, + {file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a"}, + {file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e"}, + {file = "contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3"}, + {file = "contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8"}, + {file = "contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301"}, + {file = "contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a"}, + {file = "contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77"}, + {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5"}, + {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4"}, + {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36"}, + {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3"}, + {file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b"}, + {file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36"}, + {file = "contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d"}, + {file = "contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd"}, + {file = "contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339"}, + {file = "contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772"}, + {file = "contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77"}, + {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13"}, + {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe"}, + {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f"}, + {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0"}, + {file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4"}, + {file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f"}, + {file = "contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae"}, + {file = "contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc"}, + {file = "contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77"}, + {file = "contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880"}, +] + +[package.dependencies] +numpy = ">=1.25" + +[package.extras] +bokeh = ["bokeh", "selenium"] +docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] +mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.17.0)", "types-Pillow"] +test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] +test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] + [[package]] name = "coverage" version = "7.5.4" @@ -1390,6 +1482,22 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "cycler" +version = "0.12.1" +description = "Composable style cycles" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, + {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, +] + +[package.extras] +docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] +tests = ["pytest", "pytest-cov", "pytest-xdist"] + [[package]] name = "dash" version = "3.1.1" @@ -2120,6 +2228,87 @@ werkzeug = ">=3.1.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "fonttools" +version = "4.60.1" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28"}, + {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15"}, + {file = "fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c"}, + {file = "fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea"}, + {file = "fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652"}, + {file = "fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a"}, + {file = "fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce"}, + {file = "fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038"}, + {file = "fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f"}, + {file = "fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2"}, + {file = "fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914"}, + {file = "fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1"}, + {file = "fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d"}, + {file = "fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa"}, + {file = "fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258"}, + {file = "fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf"}, + {file = "fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc"}, + {file = "fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877"}, + {file = "fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c"}, + {file = "fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401"}, + {file = "fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903"}, + {file = "fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed"}, + {file = "fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6"}, + {file = "fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383"}, + {file = "fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb"}, + {file = "fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4"}, + {file = "fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c"}, + {file = "fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77"}, + {file = "fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199"}, + {file = "fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c"}, + {file = "fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272"}, + {file = "fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac"}, + {file = "fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3"}, + {file = "fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85"}, + {file = "fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537"}, + {file = "fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003"}, + {file = "fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08"}, + {file = "fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99"}, + {file = "fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6"}, + {file = "fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987"}, + {file = "fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299"}, + {file = "fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01"}, + {file = "fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801"}, + {file = "fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc"}, + {file = "fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc"}, + {file = "fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed"}, + {file = "fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259"}, + {file = "fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c"}, + {file = "fonttools-4.60.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:122e1a8ada290423c493491d002f622b1992b1ab0b488c68e31c413390dc7eb2"}, + {file = "fonttools-4.60.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a140761c4ff63d0cb9256ac752f230460ee225ccef4ad8f68affc723c88e2036"}, + {file = "fonttools-4.60.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eae96373e4b7c9e45d099d7a523444e3554360927225c1cdae221a58a45b856"}, + {file = "fonttools-4.60.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:596ecaca36367027d525b3b426d8a8208169d09edcf8c7506aceb3a38bfb55c7"}, + {file = "fonttools-4.60.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ee06fc57512144d8b0445194c2da9f190f61ad51e230f14836286470c99f854"}, + {file = "fonttools-4.60.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b42d86938e8dda1cd9a1a87a6d82f1818eaf933348429653559a458d027446da"}, + {file = "fonttools-4.60.1-cp39-cp39-win32.whl", hash = "sha256:8b4eb332f9501cb1cd3d4d099374a1e1306783ff95489a1026bde9eb02ccc34a"}, + {file = "fonttools-4.60.1-cp39-cp39-win_amd64.whl", hash = "sha256:7473a8ed9ed09aeaa191301244a5a9dbe46fe0bf54f9d6cd21d83044c3321217"}, + {file = "fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb"}, + {file = "fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9"}, +] + +[package.extras] +all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0) ; python_version <= \"3.12\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr ; sys_platform == \"darwin\""] +unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] +woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] + [[package]] name = "freezegun" version = "1.5.1" @@ -2787,6 +2976,117 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "kiwisolver" +version = "1.4.9" +description = "A fast implementation of the Cassowary constraint solver" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"}, + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"}, + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634"}, + {file = "kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611"}, + {file = "kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464"}, + {file = "kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2"}, + {file = "kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145"}, + {file = "kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54"}, + {file = "kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c"}, + {file = "kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d"}, + {file = "kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce"}, + {file = "kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7"}, + {file = "kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1"}, + {file = "kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d"}, +] + [[package]] name = "kombu" version = "5.5.4" @@ -3137,6 +3437,85 @@ dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] tests = ["pytest", "simplejson"] +[[package]] +name = "matplotlib" +version = "3.10.6" +description = "Python plotting package" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "matplotlib-3.10.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bc7316c306d97463a9866b89d5cc217824e799fa0de346c8f68f4f3d27c8693d"}, + {file = "matplotlib-3.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d00932b0d160ef03f59f9c0e16d1e3ac89646f7785165ce6ad40c842db16cc2e"}, + {file = "matplotlib-3.10.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fa4c43d6bfdbfec09c733bca8667de11bfa4970e8324c471f3a3632a0301c15"}, + {file = "matplotlib-3.10.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea117a9c1627acaa04dbf36265691921b999cbf515a015298e54e1a12c3af837"}, + {file = "matplotlib-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08fc803293b4e1694ee325896030de97f74c141ccff0be886bb5915269247676"}, + {file = "matplotlib-3.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:2adf92d9b7527fbfb8818e050260f0ebaa460f79d61546374ce73506c9421d09"}, + {file = "matplotlib-3.10.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:905b60d1cb0ee604ce65b297b61cf8be9f4e6cfecf95a3fe1c388b5266bc8f4f"}, + {file = "matplotlib-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bac38d816637343e53d7185d0c66677ff30ffb131044a81898b5792c956ba76"}, + {file = "matplotlib-3.10.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:942a8de2b5bfff1de31d95722f702e2966b8a7e31f4e68f7cd963c7cd8861cf6"}, + {file = "matplotlib-3.10.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3276c85370bc0dfca051ec65c5817d1e0f8f5ce1b7787528ec8ed2d524bbc2f"}, + {file = "matplotlib-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9df5851b219225731f564e4b9e7f2ac1e13c9e6481f941b5631a0f8e2d9387ce"}, + {file = "matplotlib-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:abb5d9478625dd9c9eb51a06d39aae71eda749ae9b3138afb23eb38824026c7e"}, + {file = "matplotlib-3.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:886f989ccfae63659183173bb3fced7fd65e9eb793c3cc21c273add368536951"}, + {file = "matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347"}, + {file = "matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75"}, + {file = "matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95"}, + {file = "matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb"}, + {file = "matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07"}, + {file = "matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b"}, + {file = "matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa"}, + {file = "matplotlib-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:819e409653c1106c8deaf62e6de6b8611449c2cd9939acb0d7d4e57a3d95cc7a"}, + {file = "matplotlib-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:59c8ac8382fefb9cb71308dde16a7c487432f5255d8f1fd32473523abecfecdf"}, + {file = "matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84e82d9e0fd70c70bc55739defbd8055c54300750cbacf4740c9673a24d6933a"}, + {file = "matplotlib-3.10.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25f7a3eb42d6c1c56e89eacd495661fc815ffc08d9da750bca766771c0fd9110"}, + {file = "matplotlib-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9c862d91ec0b7842920a4cfdaaec29662195301914ea54c33e01f1a28d014b2"}, + {file = "matplotlib-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:1b53bd6337eba483e2e7d29c5ab10eee644bc3a2491ec67cc55f7b44583ffb18"}, + {file = "matplotlib-3.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:cbd5eb50b7058b2892ce45c2f4e92557f395c9991f5c886d1bb74a1582e70fd6"}, + {file = "matplotlib-3.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:acc86dd6e0e695c095001a7fccff158c49e45e0758fdf5dcdbb0103318b59c9f"}, + {file = "matplotlib-3.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e228cd2ffb8f88b7d0b29e37f68ca9aaf83e33821f24a5ccc4f082dd8396bc27"}, + {file = "matplotlib-3.10.6-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:658bc91894adeab669cf4bb4a186d049948262987e80f0857216387d7435d833"}, + {file = "matplotlib-3.10.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8913b7474f6dd83ac444c9459c91f7f0f2859e839f41d642691b104e0af056aa"}, + {file = "matplotlib-3.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:091cea22e059b89f6d7d1a18e2c33a7376c26eee60e401d92a4d6726c4e12706"}, + {file = "matplotlib-3.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:491e25e02a23d7207629d942c666924a6b61e007a48177fdd231a0097b7f507e"}, + {file = "matplotlib-3.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3d80d60d4e54cda462e2cd9a086d85cd9f20943ead92f575ce86885a43a565d5"}, + {file = "matplotlib-3.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:70aaf890ce1d0efd482df969b28a5b30ea0b891224bb315810a3940f67182899"}, + {file = "matplotlib-3.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1565aae810ab79cb72e402b22facfa6501365e73ebab70a0fdfb98488d2c3c0c"}, + {file = "matplotlib-3.10.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b23315a01981689aa4e1a179dbf6ef9fbd17143c3eea77548c2ecfb0499438"}, + {file = "matplotlib-3.10.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30fdd37edf41a4e6785f9b37969de57aea770696cb637d9946eb37470c94a453"}, + {file = "matplotlib-3.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc31e693da1c08012c764b053e702c1855378e04102238e6a5ee6a7117c53a47"}, + {file = "matplotlib-3.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:05be9bdaa8b242bc6ff96330d18c52f1fc59c6fb3a4dd411d953d67e7e1baf98"}, + {file = "matplotlib-3.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:f56a0d1ab05d34c628592435781d185cd99630bdfd76822cd686fb5a0aecd43a"}, + {file = "matplotlib-3.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:94f0b4cacb23763b64b5dace50d5b7bfe98710fed5f0cef5c08135a03399d98b"}, + {file = "matplotlib-3.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cc332891306b9fb39462673d8225d1b824c89783fee82840a709f96714f17a5c"}, + {file = "matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee1d607b3fb1590deb04b69f02ea1d53ed0b0bf75b2b1a5745f269afcbd3cdd3"}, + {file = "matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:376a624a218116461696b27b2bbf7a8945053e6d799f6502fc03226d077807bf"}, + {file = "matplotlib-3.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:83847b47f6524c34b4f2d3ce726bb0541c48c8e7692729865c3df75bfa0f495a"}, + {file = "matplotlib-3.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c7e0518e0d223683532a07f4b512e2e0729b62674f1b3a1a69869f98e6b1c7e3"}, + {file = "matplotlib-3.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:4dd83e029f5b4801eeb87c64efd80e732452781c16a9cf7415b7b63ec8f374d7"}, + {file = "matplotlib-3.10.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:13fcd07ccf17e354398358e0307a1f53f5325dca22982556ddb9c52837b5af41"}, + {file = "matplotlib-3.10.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:470fc846d59d1406e34fa4c32ba371039cd12c2fe86801159a965956f2575bd1"}, + {file = "matplotlib-3.10.6-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7173f8551b88f4ef810a94adae3128c2530e0d07529f7141be7f8d8c365f051"}, + {file = "matplotlib-3.10.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f2d684c3204fa62421bbf770ddfebc6b50130f9cad65531eeba19236d73bb488"}, + {file = "matplotlib-3.10.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6f4a69196e663a41d12a728fab8751177215357906436804217d6d9cf0d4d6cf"}, + {file = "matplotlib-3.10.6-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d6ca6ef03dfd269f4ead566ec6f3fb9becf8dab146fb999022ed85ee9f6b3eb"}, + {file = "matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c"}, +] + +[package.dependencies] +contourpy = ">=1.0.1" +cycler = ">=0.10" +fonttools = ">=4.22.0" +kiwisolver = ">=1.3.1" +numpy = ">=1.23" +packaging = ">=20.0" +pillow = ">=8" +pyparsing = ">=2.3.1" +python-dateutil = ">=2.7" + +[package.extras] +dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] + [[package]] name = "mccabe" version = "0.7.0" @@ -3857,6 +4236,131 @@ files = [ [package.dependencies] setuptools = "*" +[[package]] +name = "pillow" +version = "11.3.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, + {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, + {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, + {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, + {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, + {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, + {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, + {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, + {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, + {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, + {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, + {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, + {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, + {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, + {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, + {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, + {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, + {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, + {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, + {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, + {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, + {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, + {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, + {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, + {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, + {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, + {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.8" @@ -5016,6 +5520,29 @@ attrs = ">=22.2.0" rpds-py = ">=0.7.0" typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} +[[package]] +name = "reportlab" +version = "4.4.4" +description = "The Reportlab Toolkit" +optional = false +python-versions = "<4,>=3.9" +groups = ["main"] +files = [ + {file = "reportlab-4.4.4-py3-none-any.whl", hash = "sha256:299b3b0534e7202bb94ed2ddcd7179b818dcda7de9d8518a57c85a58a1ebaadb"}, + {file = "reportlab-4.4.4.tar.gz", hash = "sha256:cb2f658b7f4a15be2cc68f7203aa67faef67213edd4f2d4bdd3eb20dab75a80d"}, +] + +[package.dependencies] +charset-normalizer = "*" +pillow = ">=9.0.0" + +[package.extras] +accel = ["rl_accel (>=0.9.0,<1.1)"] +bidi = ["rlbidi"] +pycairo = ["freetype-py (>=2.3.0,<2.4)", "rlPyCairo (>=0.2.0,<1)"] +renderpm = ["rl_renderPM (>=4.0.3,<4.1)"] +shaping = ["uharfbuzz"] + [[package]] name = "requests" version = "2.32.5" @@ -6259,4 +6786,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "03442fd4673006c5a74374f90f53621fd1c9d117279fe6cc0355ef833eb7f9bb" +content-hash = "3c9164d668d37d6373eb5200bbe768232ead934d9312b9c68046b1df922789f3" diff --git a/api/pyproject.toml b/api/pyproject.toml index d7ef074e36..b1cd4af120 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -33,7 +33,9 @@ dependencies = [ "xmlsec==1.3.14", "h2 (==4.3.0)", "markdown (>=3.9,<4.0)", - "drf-simple-apikey (==2.2.1)" + "drf-simple-apikey (==2.2.1)", + "matplotlib (>=3.10.6,<4.0.0)", + "reportlab (>=4.4.4,<5.0.0)" ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 6e561ff969..d24c68ccc4 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -1593,6 +1593,25 @@ class ProviderViewSet(BaseRLSViewSet): }, request=None, ), + threatscore=extend_schema( + tags=["Scan"], + summary="Retrieve threatscore report", + description="Download a specific threatscore report (e.g., 'prowler_threatscore_aws') as a PDF file.", + request=None, + responses={ + 200: OpenApiResponse( + description="PDF file containing the threatscore report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 401: OpenApiResponse( + description="API key missing or user not Authenticated" + ), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="The scan has no threatscore reports, or the threatscore report generation task has not started yet" + ), + }, + ), ) @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="retrieve") @@ -1649,6 +1668,9 @@ class ScanViewSet(BaseRLSViewSet): if hasattr(self, "response_serializer_class"): return self.response_serializer_class return ScanComplianceReportSerializer + elif self.action == "threatscore": + if hasattr(self, "response_serializer_class"): + return self.response_serializer_class return super().get_serializer_class() def partial_update(self, request, *args, **kwargs): @@ -1880,6 +1902,45 @@ class ScanViewSet(BaseRLSViewSet): content, filename = loader return self._serve_file(content, filename, "text/csv") + @action( + detail=True, + methods=["get"], + url_name="threatscore", + ) + def threatscore(self, request, pk=None): + scan = self.get_object() + running_resp = self._get_task_status(scan) + if running_resp: + return running_resp + + if not scan.output_location: + return Response( + { + "detail": "The scan has no reports, or the threatscore report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, + ) + + if scan.output_location.startswith("s3://"): + bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") + key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") + prefix = os.path.join( + os.path.dirname(key_prefix), + "threatscore", + "*_threatscore_report.pdf", + ) + loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + else: + base = os.path.dirname(scan.output_location) + pattern = os.path.join(base, "threatscore", "*_threatscore_report.pdf") + loader = self._load_file(pattern, s3=False) + + if isinstance(loader, Response): + return loader + + content, filename = loader + return self._serve_file(content, filename, "application/pdf") + def create(self, request, *args, **kwargs): input_serializer = self.get_serializer(data=request.data) input_serializer.is_valid(raise_exception=True) diff --git a/api/src/backend/tasks/assets/fonts/FiraCode-Regular.ttf b/api/src/backend/tasks/assets/fonts/FiraCode-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3a57209a97f2880b3d5f643c75999a8f777059eb GIT binary patch literal 188500 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 literal 0 HcmV?d00001 diff --git a/api/src/backend/tasks/assets/fonts/PlusJakartaSans-Regular.ttf b/api/src/backend/tasks/assets/fonts/PlusJakartaSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..f43b5f4356734bedcc05588f4af61732009cd231 GIT binary patch literal 94760 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 W>vxPhzMfG8S?uZR=d#Wzp$P!=!(F!k literal 0 HcmV?d00001 diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 782e06e2b9..f9a4020225 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -229,7 +229,7 @@ def _upload_to_s3(tenant_id: str, zip_path: str, scan_id: str) -> str | None: def _generate_output_directory( output_directory, prowler_provider: object, tenant_id: str, scan_id: str -) -> tuple[str, str]: +) -> tuple[str, str, str]: """ Generate a file system path for the output directory of a prowler scan. @@ -256,6 +256,7 @@ def _generate_output_directory( >>> _generate_output_directory("/tmp", "aws", "tenant-1234", "scan-5678") '/tmp/tenant-1234/aws/scan-5678/prowler-output-2023-02-15T12:34:56', '/tmp/tenant-1234/aws/scan-5678/compliance/prowler-output-2023-02-15T12:34:56' + '/tmp/tenant-1234/aws/scan-5678/threatscore/prowler-output-2023-02-15T12:34:56' """ # Sanitize the prowler provider name to ensure it is a valid directory name prowler_provider_sanitized = re.sub(r"[^\w\-]", "-", prowler_provider) @@ -276,4 +277,10 @@ def _generate_output_directory( ) os.makedirs("/".join(compliance_path.split("/")[:-1]), exist_ok=True) - return path, compliance_path + threatscore_path = ( + f"{output_directory}/{tenant_id}/{scan_id}/threatscore/prowler-output-" + f"{prowler_provider_sanitized}-{timestamp}" + ) + os.makedirs("/".join(threatscore_path.split("/")[:-1]), exist_ok=True) + + return path, compliance_path, threatscore_path diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py new file mode 100644 index 0000000000..fc589f513d --- /dev/null +++ b/api/src/backend/tasks/jobs/report.py @@ -0,0 +1,1332 @@ +import io +import os +from collections import defaultdict +from pathlib import Path +from shutil import rmtree + +import matplotlib.pyplot as plt +from celery.utils.log import get_task_logger +from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY +from django.db.models import Count, Q +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas +from reportlab.platypus import ( + Image, + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) +from tasks.jobs.export import _generate_output_directory, _upload_to_s3 +from tasks.utils import batched + +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Finding, Provider, ScanSummary, StatusChoices +from api.utils import initialize_prowler_provider +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.finding import Finding as FindingOutput + +pdfmetrics.registerFont( + TTFont( + "PlusJakartaSans", + os.path.join( + os.path.dirname(__file__), "../assets/fonts/PlusJakartaSans-Regular.ttf" + ), + ) +) + +pdfmetrics.registerFont( + TTFont( + "FiraCode", + os.path.join(os.path.dirname(__file__), "../assets/fonts/FiraCode-Regular.ttf"), + ) +) + +logger = get_task_logger(__name__) + + +def _create_pdf_styles() -> dict[str, ParagraphStyle]: + """ + Create and return PDF paragraph styles used throughout the report. + + Returns: + dict[str, ParagraphStyle]: A dictionary containing the following styles: + - 'title': Title style with prowler green color + - 'h1': Heading 1 style with blue color and background + - 'h2': Heading 2 style with light blue color + - 'h3': Heading 3 style for sub-headings + - 'normal': Normal text style with left indent + - 'normal_center': Normal text style without indent + """ + styles = getSampleStyleSheet() + prowler_dark_green = colors.Color(0.1, 0.5, 0.2) + + title_style = ParagraphStyle( + "CustomTitle", + parent=styles["Title"], + fontSize=24, + textColor=prowler_dark_green, + spaceAfter=20, + fontName="PlusJakartaSans", + alignment=TA_CENTER, + ) + + h1 = ParagraphStyle( + "CustomH1", + parent=styles["Heading1"], + fontSize=18, + textColor=colors.Color(0.2, 0.4, 0.6), + spaceBefore=20, + spaceAfter=12, + fontName="PlusJakartaSans", + leftIndent=0, + borderWidth=2, + borderColor=colors.Color(0.2, 0.4, 0.6), + borderPadding=8, + backColor=colors.Color(0.95, 0.97, 1.0), + ) + + h2 = ParagraphStyle( + "CustomH2", + parent=styles["Heading2"], + fontSize=14, + textColor=colors.Color(0.3, 0.5, 0.7), + spaceBefore=15, + spaceAfter=8, + fontName="PlusJakartaSans", + leftIndent=10, + borderWidth=1, + borderColor=colors.Color(0.7, 0.8, 0.9), + borderPadding=5, + backColor=colors.Color(0.98, 0.99, 1.0), + ) + + h3 = ParagraphStyle( + "CustomH3", + parent=styles["Heading3"], + fontSize=12, + textColor=colors.Color(0.4, 0.6, 0.8), + spaceBefore=10, + spaceAfter=6, + fontName="PlusJakartaSans", + leftIndent=20, + ) + + normal = ParagraphStyle( + "CustomNormal", + parent=styles["Normal"], + fontSize=10, + textColor=colors.Color(0.2, 0.2, 0.2), + spaceBefore=4, + spaceAfter=4, + leftIndent=30, + fontName="PlusJakartaSans", + ) + + normal_center = ParagraphStyle( + "CustomNormalCenter", + parent=styles["Normal"], + fontSize=10, + textColor=colors.Color(0.2, 0.2, 0.2), + fontName="PlusJakartaSans", + ) + + return { + "title": title_style, + "h1": h1, + "h2": h2, + "h3": h3, + "normal": normal, + "normal_center": normal_center, + } + + +def _create_risk_component(risk_level: int, weight: int, score: int = 0) -> Table: + """ + Create a visual risk component table for the PDF report. + + Args: + risk_level (int): The risk level (0-5), where higher values indicate higher risk. + weight (int): The weight of the risk component. + score (int): The calculated score. Defaults to 0. + + Returns: + Table: A ReportLab Table object with colored cells representing risk, weight, and score. + """ + if risk_level >= 4: + risk_color = colors.Color(0.8, 0.2, 0.2) + elif risk_level >= 3: + risk_color = colors.Color(0.9, 0.6, 0.2) + elif risk_level >= 2: + risk_color = colors.Color(0.9, 0.9, 0.2) + else: + risk_color = colors.Color(0.2, 0.8, 0.2) + + if weight <= 50: + weight_color = colors.Color(0.2, 0.8, 0.2) + elif weight <= 100: + weight_color = colors.Color(0.9, 0.9, 0.2) + else: + weight_color = colors.Color(0.8, 0.2, 0.2) + + score_color = colors.Color(0.4, 0.4, 0.4) + + data = [ + [ + "Risk Level:", + str(risk_level), + "Weight:", + str(weight), + "Score:", + str(score), + ] + ] + + table = Table( + data, + colWidths=[ + 0.8 * inch, + 0.4 * inch, + 0.6 * inch, + 0.4 * inch, + 0.5 * inch, + 0.4 * inch, + ], + ) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), + ("BACKGROUND", (1, 0), (1, 0), risk_color), + ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("BACKGROUND", (2, 0), (2, 0), colors.Color(0.9, 0.9, 0.9)), + ("BACKGROUND", (3, 0), (3, 0), weight_color), + ("TEXTCOLOR", (3, 0), (3, 0), colors.white), + ("FONTNAME", (3, 0), (3, 0), "FiraCode"), + ("BACKGROUND", (4, 0), (4, 0), colors.Color(0.9, 0.9, 0.9)), + ("BACKGROUND", (5, 0), (5, 0), score_color), + ("TEXTCOLOR", (5, 0), (5, 0), colors.white), + ("FONTNAME", (5, 0), (5, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + + return table + + +def _create_status_component(status: str) -> Table: + """ + Create a visual status component with colored background. + + Args: + status (str): The status value (e.g., "PASS", "FAIL", "MANUAL"). + + Returns: + Table: A ReportLab Table object displaying the status with appropriate color coding. + """ + if status.upper() == "PASS": + status_color = colors.Color(0.2, 0.8, 0.2) + elif status.upper() == "FAIL": + status_color = colors.Color(0.8, 0.2, 0.2) + else: + status_color = colors.Color(0.4, 0.4, 0.4) + + data = [["State:", status.upper()]] + + table = Table(data, colWidths=[0.6 * inch, 0.8 * inch]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("BACKGROUND", (1, 0), (1, 0), status_color), + ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 12), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + + return table + + +def _create_section_score_chart( + requirements_list: list[dict], attributes_by_requirement_id: dict +) -> io.BytesIO: + """ + Create a bar chart showing compliance score by section using ThreatScore formula. + + Args: + requirements_list (list[dict]): List of requirement dictionaries with status and findings data. + attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes including risk level and weight. + + Returns: + io.BytesIO: A BytesIO buffer containing the chart image in PNG format. + """ + # Define expected sections + expected_sections = [ + "1. IAM", + "2. Attack Surface", + "3. Logging and Monitoring", + "4. Encryption", + ] + + # Initialize all expected sections with default values + sections_data = { + section: { + "numerator": 0, + "denominator": 0, + "has_findings": False, + } + for section in expected_sections + } + + # Collect data from requirements + for requirement in requirements_list: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) + + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if metadata: + m = metadata[0] + section = getattr(m, "Section", "Unknown") + + # Add section if not in expected list (for flexibility) + if section not in sections_data: + sections_data[section] = { + "numerator": 0, + "denominator": 0, + "has_findings": False, + } + + # Get findings data + passed_findings = requirement["attributes"].get("passed_findings", 0) + total_findings = requirement["attributes"].get("total_findings", 0) + + if total_findings > 0: + sections_data[section]["has_findings"] = True + risk_level = getattr(m, "LevelOfRisk", 0) + weight = getattr(m, "Weight", 0) + + # Calculate using ThreatScore formula from UI + rate_i = passed_findings / total_findings + rfac_i = 1 + 0.25 * risk_level + + sections_data[section]["numerator"] += ( + rate_i * total_findings * weight * rfac_i + ) + sections_data[section]["denominator"] += ( + total_findings * weight * rfac_i + ) + + section_names = [] + compliance_percentages = [] + + for section, data in sections_data.items(): + if data["has_findings"] and data["denominator"] > 0: + compliance_percentage = (data["numerator"] / data["denominator"]) * 100 + else: + compliance_percentage = 100 # No findings = 100% (PASS) + + section_names.append(section) + compliance_percentages.append(compliance_percentage) + + # Sort alphabetically by section name + sorted_data = sorted( + zip(section_names, compliance_percentages), + key=lambda x: x[0], + ) + section_names, compliance_percentages = ( + zip(*sorted_data) if sorted_data else ([], []) + ) + + fig, ax = plt.subplots(figsize=(12, 8)) + + colors_list = [] + for percentage in compliance_percentages: + if percentage >= 80: + color = "#4CAF50" + elif percentage >= 60: + color = "#8BC34A" + elif percentage >= 40: + color = "#FFEB3B" + elif percentage >= 20: + color = "#FF9800" + else: + color = "#F44336" + colors_list.append(color) + + bars = ax.bar(section_names, compliance_percentages, color=colors_list) + + ax.set_ylabel("Compliance Score (%)", fontsize=12) + ax.set_xlabel("Section", fontsize=12) + ax.set_ylim(0, 100) + + for bar, percentage in zip(bars, compliance_percentages): + height = bar.get_height() + ax.text( + bar.get_x() + bar.get_width() / 2.0, + height + 1, + f"{percentage:.1f}%", + ha="center", + va="bottom", + fontweight="bold", + ) + + plt.xticks(rotation=45, ha="right") + + ax.grid(True, alpha=0.3, axis="y") + + plt.tight_layout() + + buffer = io.BytesIO() + plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") + buffer.seek(0) + plt.close() + + return buffer + + +def _add_pdf_footer(canvas_obj: canvas.Canvas, doc: SimpleDocTemplate) -> None: + """ + Add footer with page number and branding to each page of the PDF. + + Args: + canvas_obj (canvas.Canvas): The ReportLab canvas object for drawing. + doc (SimpleDocTemplate): The document template containing page information. + """ + width, height = doc.pagesize + page_num_text = f"Page {doc.page}" + canvas_obj.setFont("PlusJakartaSans", 9) + canvas_obj.setFillColorRGB(0.4, 0.4, 0.4) + canvas_obj.drawString(30, 20, page_num_text) + powered_text = "Powered by Prowler" + text_width = canvas_obj.stringWidth(powered_text, "PlusJakartaSans", 9) + canvas_obj.drawString(width - text_width - 30, 20, powered_text) + + +def _aggregate_requirement_statistics_from_database( + tenant_id: str, scan_id: str +) -> dict[str, dict[str, int]]: + """ + Aggregate finding statistics by check_id using database aggregation. + + This function uses Django ORM aggregation to calculate pass/fail statistics + entirely in the database, avoiding the need to load findings into memory. + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to retrieve findings for. + + Returns: + dict[str, dict[str, int]]: Dictionary mapping check_id to statistics: + - 'passed' (int): Number of passed findings for this check + - 'total' (int): Total number of findings for this check + + Example: + { + 'aws_iam_user_mfa_enabled': {'passed': 10, 'total': 15}, + 'aws_s3_bucket_public_access': {'passed': 0, 'total': 5} + } + """ + requirement_statistics_by_check_id = {} + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Use database aggregation to calculate stats without loading findings into memory + aggregated_statistics_queryset = ( + Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id) + .values("check_id") + .annotate( + total_findings=Count("id"), + passed_findings=Count("id", filter=Q(status=StatusChoices.PASS)), + ) + ) + + for aggregated_stat in aggregated_statistics_queryset: + check_id = aggregated_stat["check_id"] + requirement_statistics_by_check_id[check_id] = { + "passed": aggregated_stat["passed_findings"], + "total": aggregated_stat["total_findings"], + } + + logger.info( + f"Aggregated statistics for {len(requirement_statistics_by_check_id)} unique checks" + ) + return requirement_statistics_by_check_id + + +def _load_findings_for_requirement_checks( + tenant_id: str, scan_id: str, check_ids: list[str], prowler_provider +) -> dict[str, list[FindingOutput]]: + """ + Load findings for specific check IDs on-demand. + + This function loads only the findings needed for a specific set of checks, + minimizing memory usage by avoiding loading all findings at once. This is used + when generating detailed findings tables for specific requirements in the PDF. + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to retrieve findings for. + check_ids (list[str]): List of check IDs to load findings for. + prowler_provider: The initialized Prowler provider instance. + + Returns: + dict[str, list[FindingOutput]]: Dictionary mapping check_id to list of FindingOutput objects. + + Example: + { + 'aws_iam_user_mfa_enabled': [FindingOutput(...), FindingOutput(...)], + 'aws_s3_bucket_public_access': [FindingOutput(...)] + } + """ + findings_by_check_id = defaultdict(list) + + if not check_ids: + return dict(findings_by_check_id) + + logger.info(f"Loading findings for {len(check_ids)} checks on-demand") + + findings_queryset = ( + Finding.all_objects.filter( + tenant_id=tenant_id, scan_id=scan_id, check_id__in=check_ids + ) + .order_by("uid") + .iterator() + ) + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + for batch, is_last_batch in batched( + findings_queryset, DJANGO_FINDINGS_BATCH_SIZE + ): + for finding_model in batch: + finding_output = FindingOutput.transform_api_finding( + finding_model, prowler_provider + ) + findings_by_check_id[finding_output.check_id].append(finding_output) + + total_findings_loaded = sum( + len(findings) for findings in findings_by_check_id.values() + ) + logger.info( + f"Loaded {total_findings_loaded} findings for {len(findings_by_check_id)} checks" + ) + + return dict(findings_by_check_id) + + +def _calculate_requirements_data_from_statistics( + compliance_obj, requirement_statistics_by_check_id: dict[str, dict[str, int]] +) -> tuple[dict[str, dict], list[dict]]: + """ + Calculate requirement status and statistics using pre-aggregated database statistics. + + This function uses O(n) lookups with pre-aggregated statistics from the database, + avoiding the need to iterate over all findings for each requirement. + + Args: + compliance_obj: The compliance framework object containing requirements. + requirement_statistics_by_check_id (dict[str, dict[str, int]]): Pre-aggregated statistics + mapping check_id to {'passed': int, 'total': int} counts. + + Returns: + tuple[dict[str, dict], list[dict]]: A tuple containing: + - attributes_by_requirement_id: Dictionary mapping requirement IDs to their attributes. + - requirements_list: List of requirement dictionaries with status and statistics. + """ + attributes_by_requirement_id = {} + requirements_list = [] + + compliance_framework = getattr(compliance_obj, "Framework", "N/A") + compliance_version = getattr(compliance_obj, "Version", "N/A") + + for requirement in compliance_obj.Requirements: + requirement_id = requirement.Id + requirement_description = getattr(requirement, "Description", "") + requirement_checks = getattr(requirement, "Checks", []) + requirement_attributes = getattr(requirement, "Attributes", []) + + # Store requirement metadata for later use + attributes_by_requirement_id[requirement_id] = { + "attributes": { + "req_attributes": requirement_attributes, + "checks": requirement_checks, + }, + "description": requirement_description, + } + + # Calculate aggregated passed and total findings for this requirement + total_passed_findings = 0 + total_findings_count = 0 + + for check_id in requirement_checks: + if check_id in requirement_statistics_by_check_id: + check_statistics = requirement_statistics_by_check_id[check_id] + total_findings_count += check_statistics["total"] + total_passed_findings += check_statistics["passed"] + + # Determine overall requirement status based on findings + if total_findings_count > 0: + if total_passed_findings == total_findings_count: + requirement_status = StatusChoices.PASS + else: + # Partial pass or complete fail both count as FAIL + requirement_status = StatusChoices.FAIL + else: + # No findings means manual review required + requirement_status = StatusChoices.MANUAL + + requirements_list.append( + { + "id": requirement_id, + "attributes": { + "framework": compliance_framework, + "version": compliance_version, + "status": requirement_status, + "description": requirement_description, + "passed_findings": total_passed_findings, + "total_findings": total_findings_count, + }, + } + ) + + return attributes_by_requirement_id, requirements_list + + +def generate_threatscore_report( + tenant_id: str, + scan_id: str, + compliance_id: str, + output_path: str, + provider_id: str, + only_failed: bool = True, + min_risk_level: int = 4, +) -> None: + """ + Generate a PDF compliance report based on Prowler ThreatScore framework. + + This function creates a comprehensive PDF report containing: + - Compliance overview and metadata + - Section-by-section compliance scores with charts + - Overall ThreatScore calculation + - Critical failed requirements + - Detailed findings for each requirement + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): ID of the scan executed by Prowler. + compliance_id (str): ID of the compliance framework (e.g., "prowler_threatscore_aws"). + output_path (str): Output PDF file path (e.g., "/tmp/threatscore_report.pdf"). + provider_id (str): Provider ID for the scan. + only_failed (bool): If True, only requirements with status "FAIL" will be included + in the detailed requirements section. Defaults to True. + min_risk_level (int): Minimum risk level for critical failed requirements. Defaults to 4. + + Raises: + Exception: If any error occurs during PDF generation, it will be logged and re-raised. + """ + logger.info( + f"Generating the report for the scan {scan_id} with provider {provider_id}" + ) + try: + # Get PDF styles + pdf_styles = _create_pdf_styles() + title_style = pdf_styles["title"] + h1 = pdf_styles["h1"] + h2 = pdf_styles["h2"] + h3 = pdf_styles["h3"] + normal = pdf_styles["normal"] + normal_center = pdf_styles["normal_center"] + + # Get compliance and provider information + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + provider_obj = Provider.objects.get(id=provider_id) + prowler_provider = initialize_prowler_provider(provider_obj) + provider_type = provider_obj.provider + + frameworks_bulk = Compliance.get_bulk(provider_type) + compliance_obj = frameworks_bulk[compliance_id] + compliance_framework = getattr(compliance_obj, "Framework", "N/A") + compliance_version = getattr(compliance_obj, "Version", "N/A") + compliance_name = getattr(compliance_obj, "Name", "N/A") + compliance_description = getattr(compliance_obj, "Description", "") + + # Aggregate requirement statistics from database (memory-efficient) + logger.info(f"Aggregating requirement statistics for scan {scan_id}") + requirement_statistics_by_check_id = ( + _aggregate_requirement_statistics_from_database(tenant_id, scan_id) + ) + + # Calculate requirements data using aggregated statistics + attributes_by_requirement_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + compliance_obj, requirement_statistics_by_check_id + ) + ) + + # Initialize PDF document + doc = SimpleDocTemplate( + output_path, + pagesize=letter, + title=f"Prowler ThreatScore Report - {compliance_framework}", + author="Prowler", + subject=f"Compliance Report for {compliance_framework}", + creator="Prowler Engineering Team", + keywords=f"compliance,{compliance_framework},security,framework,prowler", + ) + + elements = [] + + # Add logo + img_path = os.path.join( + os.path.dirname(__file__), "../assets/img/prowler_logo.png" + ) + logo = Image( + img_path, + width=5 * inch, + height=1 * inch, + ) + elements.append(logo) + + elements.append(Spacer(1, 0.5 * inch)) + elements.append(Paragraph("Prowler ThreatScore Report", title_style)) + elements.append(Spacer(1, 0.5 * inch)) + + # Add compliance information table + info_data = [ + ["Framework:", compliance_framework], + ["ID:", compliance_id], + ["Name:", Paragraph(compliance_name, normal_center)], + ["Version:", compliance_version], + ["Scan ID:", scan_id], + ["Description:", Paragraph(compliance_description, normal_center)], + ] + info_table = Table(info_data, colWidths=[2 * inch, 4 * inch]) + info_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 5), colors.Color(0.2, 0.4, 0.6)), + ("TEXTCOLOR", (0, 0), (0, 5), colors.white), + ("FONTNAME", (0, 0), (0, 5), "FiraCode"), + ("BACKGROUND", (1, 0), (1, 5), colors.Color(0.95, 0.97, 1.0)), + ("TEXTCOLOR", (1, 0), (1, 5), colors.Color(0.2, 0.2, 0.2)), + ("FONTNAME", (1, 0), (1, 5), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + + elements.append(info_table) + elements.append(PageBreak()) + + # Add compliance score chart + elements.append(Paragraph("Compliance Score by Sections", h1)) + elements.append(Spacer(1, 0.2 * inch)) + + chart_buffer = _create_section_score_chart( + requirements_list, attributes_by_requirement_id + ) + chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch) + elements.append(chart_image) + + # Calculate overall ThreatScore using the same formula as the UI + numerator = 0 + denominator = 0 + has_findings = False + + for requirement in requirements_list: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ) + + # Get findings data + passed_findings = requirement["attributes"].get("passed_findings", 0) + total_findings = requirement["attributes"].get("total_findings", 0) + + # Skip if no findings (avoid division by zero) + if total_findings == 0: + continue + + has_findings = True + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if metadata and len(metadata) > 0: + m = metadata[0] + risk_level = getattr(m, "LevelOfRisk", 0) + weight = getattr(m, "Weight", 0) + + # Calculate using ThreatScore formula from UI + rate_i = passed_findings / total_findings + rfac_i = 1 + 0.25 * risk_level + + numerator += rate_i * total_findings * weight * rfac_i + denominator += total_findings * weight * rfac_i + + # Calculate ThreatScore (percentualScore) + # If no findings exist, consider it 100% (PASS) + if not has_findings: + overall_compliance = 100 + elif denominator > 0: + overall_compliance = (numerator / denominator) * 100 + else: + overall_compliance = 0 + + elements.append(Spacer(1, 0.3 * inch)) + + summary_data = [ + ["ThreatScore:", f"{overall_compliance:.2f}%"], + ] + + if overall_compliance >= 80: + compliance_color = colors.Color(0.2, 0.8, 0.2) + elif overall_compliance >= 60: + compliance_color = colors.Color(0.8, 0.8, 0.2) + else: + compliance_color = colors.Color(0.8, 0.2, 0.2) + + summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 0), (0, 0), colors.white), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 12), + ("BACKGROUND", (1, 0), (1, 0), compliance_color), + ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("FONTSIZE", (1, 0), (1, 0), 16), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + + elements.append(summary_table) + elements.append(PageBreak()) + + # Add requirements index + elements.append(Paragraph("Requirements Index", h1)) + + sections = {} + for ( + requirement_id, + requirement_attributes, + ) in attributes_by_requirement_id.items(): + meta = requirement_attributes["attributes"]["req_attributes"][0] + section = getattr(meta, "Section", "N/A") + subsection = getattr(meta, "SubSection", "N/A") + title = getattr(meta, "Title", "N/A") + + if section not in sections: + sections[section] = {} + if subsection not in sections[section]: + sections[section][subsection] = [] + + sections[section][subsection].append({"id": requirement_id, "title": title}) + + section_num = 1 + for section_name, subsections in sections.items(): + elements.append(Paragraph(f"{section_num}. {section_name}", h2)) + + subsection_num = 1 + for subsection_name, requirements in subsections.items(): + elements.append(Paragraph(f"{subsection_name}", h3)) + + req_num = 1 + for req in requirements: + elements.append(Paragraph(f"{req['id']} - {req['title']}", normal)) + req_num += 1 + + subsection_num += 1 + + section_num += 1 + elements.append(Spacer(1, 0.1 * inch)) + + elements.append(PageBreak()) + + # Add critical failed requirements section + elements.append(Paragraph("Top Requirements by Level of Risk", h1)) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + f"Critical Failed Requirements (Risk Level ≥ {min_risk_level})", h2 + ) + ) + elements.append(Spacer(1, 0.2 * inch)) + + critical_failed_requirements = [] + for requirement in requirements_list: + requirement_status = requirement["attributes"]["status"] + if requirement_status == StatusChoices.FAIL: + requirement_id = requirement["id"] + metadata = ( + attributes_by_requirement_id.get(requirement_id, {}) + .get("attributes", {}) + .get("req_attributes", [{}])[0] + ) + if metadata: + risk_level = getattr(metadata, "LevelOfRisk", 0) + weight = getattr(metadata, "Weight", 0) + + if risk_level >= min_risk_level: + critical_failed_requirements.append( + { + "requirement": requirement, + "attributes": attributes_by_requirement_id[ + requirement_id + ], + "risk_level": risk_level, + "weight": weight, + "metadata": metadata, + } + ) + + critical_failed_requirements.sort( + key=lambda x: (x["risk_level"], x["weight"]), reverse=True + ) + + if not critical_failed_requirements: + elements.append( + Paragraph( + "✅ No critical failed requirements found. Great job!", normal + ) + ) + else: + elements.append( + Paragraph( + f"Found {len(critical_failed_requirements)} critical failed requirements that require immediate attention:", + normal, + ) + ) + elements.append(Spacer(1, 0.5 * inch)) + + table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]] + + for idx, critical_failed_requirement in enumerate( + critical_failed_requirements + ): + requirement_id = critical_failed_requirement["requirement"]["id"] + risk_level = critical_failed_requirement["risk_level"] + weight = critical_failed_requirement["weight"] + title = getattr(critical_failed_requirement["metadata"], "Title", "N/A") + section = getattr( + critical_failed_requirement["metadata"], "Section", "N/A" + ) + + if len(title) > 50: + title = title[:47] + "..." + + table_data.append( + [str(risk_level), str(weight), requirement_id, title, section] + ) + + critical_table = Table( + table_data, + colWidths=[0.7 * inch, 0.9 * inch, 1.3 * inch, 3.1 * inch, 1.5 * inch], + ) + + critical_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.8, 0.2, 0.2)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("BACKGROUND", (0, 1), (0, -1), colors.Color(0.8, 0.2, 0.2)), + ("TEXTCOLOR", (0, 1), (0, -1), colors.white), + ("FONTNAME", (0, 1), (0, -1), "FiraCode"), + ("ALIGN", (0, 1), (0, -1), "CENTER"), + ("FONTSIZE", (0, 1), (0, -1), 12), + ("ALIGN", (1, 1), (1, -1), "CENTER"), + ("FONTNAME", (1, 1), (1, -1), "FiraCode"), + ("FONTNAME", (2, 1), (2, -1), "FiraCode"), + ("FONTSIZE", (2, 1), (2, -1), 9), + ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"), + ("FONTSIZE", (3, 1), (-1, -1), 8), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ( + "BACKGROUND", + (1, 1), + (-1, -1), + colors.Color(0.98, 0.98, 0.98), + ), + ] + ) + ) + + for idx, critical_failed_requirement in enumerate( + critical_failed_requirements + ): + row_idx = idx + 1 + weight = critical_failed_requirement["weight"] + + if weight >= 150: + weight_color = colors.Color(0.8, 0.2, 0.2) + elif weight >= 100: + weight_color = colors.Color(0.9, 0.6, 0.2) + else: + weight_color = colors.Color(0.9, 0.9, 0.2) + + critical_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color), + ("TEXTCOLOR", (1, row_idx), (1, row_idx), colors.white), + ] + ) + ) + + elements.append(critical_table) + elements.append(Spacer(1, 0.2 * inch)) + + # Get styles for warning + styles = getSampleStyleSheet() + warning_text = """ + IMMEDIATE ACTION REQUIRED:
+ These requirements have the highest risk levels and have failed compliance checks. + Please prioritize addressing these issues to improve your security posture. + """ + + warning_style = ParagraphStyle( + "Warning", + parent=styles["Normal"], + fontSize=11, + textColor=colors.Color(0.8, 0.2, 0.2), + spaceBefore=10, + spaceAfter=10, + leftIndent=20, + rightIndent=20, + fontName="PlusJakartaSans", + backColor=colors.Color(1.0, 0.95, 0.95), + borderWidth=2, + borderColor=colors.Color(0.8, 0.2, 0.2), + borderPadding=10, + ) + + elements.append(Paragraph(warning_text, warning_style)) + + elements.append(PageBreak()) + + # Add detailed requirements section + def get_weight_for_requirement(requirement_dict): + requirement_id = requirement_dict["id"] + requirement_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ) + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if metadata: + return getattr(metadata[0], "Weight", 0) + return 0 + + sorted_requirements = sorted( + requirements_list, key=get_weight_for_requirement, reverse=True + ) + + if only_failed: + sorted_requirements = [ + requirement + for requirement in sorted_requirements + if requirement["attributes"]["status"] == StatusChoices.FAIL + ] + + # Collect all check IDs for requirements that will be displayed + # This allows us to load only the findings we actually need (memory optimization) + check_ids_to_load = [] + for requirement in sorted_requirements: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ) + check_ids = requirement_attributes.get("attributes", {}).get("checks", []) + check_ids_to_load.extend(check_ids) + + # Load findings on-demand only for the checks that will be displayed + logger.info( + f"Loading findings on-demand for {len(sorted_requirements)} requirements" + ) + findings_by_check_id = _load_findings_for_requirement_checks( + tenant_id, scan_id, check_ids_to_load, prowler_provider + ) + + for requirement in sorted_requirements: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ) + requirement_description = requirement["attributes"]["description"] + requirement_status = requirement["attributes"]["status"] + + elements.append( + Paragraph( + f"{requirement_id}: {requirement_attributes.get('description', requirement_description)}", + h1, + ) + ) + + status_component = _create_status_component(requirement_status) + elements.append(status_component) + elements.append(Spacer(1, 0.1 * inch)) + + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if metadata and len(metadata) > 0: + m = metadata[0] + elements.append(Paragraph("Title: ", h3)) + elements.append(Paragraph(f"{getattr(m, 'Title', 'N/A')}", normal)) + elements.append(Paragraph("Section: ", h3)) + elements.append(Paragraph(f"{getattr(m, 'Section', 'N/A')}", normal)) + elements.append(Paragraph("SubSection: ", h3)) + elements.append(Paragraph(f"{getattr(m, 'SubSection', 'N/A')}", normal)) + elements.append(Paragraph("Description: ", h3)) + elements.append( + Paragraph(f"{getattr(m, 'AttributeDescription', 'N/A')}", normal) + ) + elements.append(Paragraph("Additional Information: ", h3)) + elements.append( + Paragraph(f"{getattr(m, 'AdditionalInformation', 'N/A')}", normal) + ) + elements.append(Spacer(1, 0.1 * inch)) + + risk_level = getattr(m, "LevelOfRisk", 0) + weight = getattr(m, "Weight", 0) + + if requirement_status == StatusChoices.PASS: + score = risk_level * weight + else: + score = 0 + + risk_component = _create_risk_component(risk_level, weight, score) + elements.append(risk_component) + elements.append(Spacer(1, 0.1 * inch)) + + # Get findings for this requirement's checks (loaded on-demand earlier) + requirement_check_ids = requirement_attributes.get("attributes", {}).get( + "checks", [] + ) + for check_id in requirement_check_ids: + elements.append(Paragraph(f"Check: {check_id}", h2)) + elements.append(Spacer(1, 0.1 * inch)) + + # Get findings for this check (already loaded on-demand) + check_findings = findings_by_check_id.get(check_id, []) + + if not check_findings: + elements.append( + Paragraph("- No information for this finding currently", normal) + ) + else: + findings_table_data = [ + [ + "Finding", + "Resource name", + "Severity", + "Status", + "Region", + ] + ] + for finding_output in check_findings: + check_metadata = getattr(finding_output, "metadata", {}) + finding_title = getattr( + check_metadata, + "CheckTitle", + getattr(finding_output, "check_id", ""), + ) + resource_name = getattr(finding_output, "resource_name", "") + if not resource_name: + resource_name = getattr(finding_output, "resource_uid", "") + severity = getattr(check_metadata, "Severity", "").capitalize() + finding_status = getattr(finding_output, "status", "").upper() + region = getattr(finding_output, "region", "global") + + findings_table_data.append( + [ + Paragraph(finding_title, normal_center), + Paragraph(resource_name, normal_center), + Paragraph(severity, normal_center), + Paragraph(finding_status, normal_center), + Paragraph(region, normal_center), + ] + ) + findings_table = Table( + findings_table_data, + colWidths=[ + 2.5 * inch, + 3 * inch, + 0.9 * inch, + 0.9 * inch, + 0.9 * inch, + ], + ) + findings_table.setStyle( + TableStyle( + [ + ( + "BACKGROUND", + (0, 0), + (-1, 0), + colors.Color(0.2, 0.4, 0.6), + ), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ( + "GRID", + (0, 0), + (-1, -1), + 0.1, + colors.Color(0.7, 0.8, 0.9), + ), + ("LEFTPADDING", (0, 0), (0, 0), 0), + ("RIGHTPADDING", (0, 0), (0, 0), 0), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ] + ) + ) + elements.append(findings_table) + elements.append(Spacer(1, 0.1 * inch)) + + elements.append(PageBreak()) + + # Build the PDF + doc.build(elements, onFirstPage=_add_pdf_footer, onLaterPages=_add_pdf_footer) + except Exception as e: + logger.info( + f"Error building the document, line {e.__traceback__.tb_lineno} -- {e}" + ) + raise e + + +def generate_threatscore_report_job( + tenant_id: str, scan_id: str, provider_id: str +) -> dict[str, bool | str]: + """ + Job function to generate a threatscore report and upload it to S3. + + This function orchestrates the complete report generation workflow: + 1. Validates that the scan has findings + 2. Checks provider type compatibility + 3. Generates the output directory + 4. Calls generate_threatscore_report to create the PDF + 5. Uploads the PDF to S3 + 6. Cleans up temporary files + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to generate a report for. + provider_id (str): The ID of the provider used in the scan. + + Returns: + dict[str, bool | str]: A dictionary containing: + - 'upload' (bool): True if the report was successfully uploaded to S3, False otherwise. + - 'error' (str): Error message if an exception occurred (only present on error). + """ + # Check if the scan has findings and get provider info + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + if not ScanSummary.objects.filter(scan_id=scan_id).exists(): + logger.info(f"No findings found for scan {scan_id}") + return {"upload": False} + + provider_obj = Provider.objects.get(id=provider_id) + provider_uid = provider_obj.uid + provider_type = provider_obj.provider + + if provider_type not in ["aws", "azure", "gcp", "m365"]: + logger.info( + f"Provider {provider_id} is not supported for threatscore report" + ) + return {"upload": False} + + # This compliance is hardcoded because is the only one that is available for the threatscore report + compliance_id = f"prowler_threatscore_{provider_type}" + logger.info( + f"Generating threatscore report for scan {scan_id} with compliance {compliance_id} inside the job" + ) + try: + logger.info("Generating the output directory") + out_dir, _, threatscore_path = _generate_output_directory( + DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id + ) + except Exception as e: + logger.error(f"Error generating output directory: {e}") + return {"error": str(e)} + + pdf_path = f"{threatscore_path}_threatscore_report.pdf" + logger.info(f"The path for the threatscore report is {pdf_path}") + generate_threatscore_report( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=pdf_path, + provider_id=provider_id, + only_failed=True, + min_risk_level=4, + ) + + upload_uri = _upload_to_s3(tenant_id, pdf_path, scan_id) + if upload_uri: + try: + rmtree(Path(pdf_path).parent, ignore_errors=True) + except Exception as e: + logger.error(f"Error deleting output files: {e}") + final_location, did_upload = upload_uri, True + else: + final_location, did_upload = out_dir, False + + logger.info(f"Threatscore report outputs at {final_location}") + + return {"upload": did_upload} diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index fd4874eb6c..48e7e43dae 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -26,6 +26,7 @@ from tasks.jobs.integrations import ( upload_s3_integration, upload_security_hub_integration, ) +from tasks.jobs.report import generate_threatscore_report_job from tasks.jobs.scan import ( aggregate_findings, create_compliance_requirements, @@ -64,10 +65,15 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str) generate_outputs_task.si( scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id ), - check_integrations_task.si( - tenant_id=tenant_id, - provider_id=provider_id, - scan_id=scan_id, + group( + generate_threatscore_report_task.si( + tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id + ), + check_integrations_task.si( + tenant_id=tenant_id, + provider_id=provider_id, + scan_id=scan_id, + ), ), ).apply_async() @@ -304,7 +310,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): frameworks_bulk = Compliance.get_bulk(provider_type) frameworks_avail = get_compliance_frameworks(provider_type) - out_dir, comp_dir = _generate_output_directory( + out_dir, comp_dir, _ = _generate_output_directory( DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id ) @@ -617,3 +623,21 @@ def jira_integration_task( return send_findings_to_jira( tenant_id, integration_id, project_key, issue_type, finding_ids ) + + +@shared_task( + base=RLSTask, + name="scan-threatscore-report", + queue="scan-reports", +) +def generate_threatscore_report_task(tenant_id: str, scan_id: str, provider_id: str): + """ + Task to generate a threatscore report for a given scan. + Args: + tenant_id (str): The tenant identifier. + scan_id (str): The scan identifier. + provider_id (str): The provider identifier. + """ + return generate_threatscore_report_job( + tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id + ) diff --git a/api/src/backend/tasks/tests/test_export.py b/api/src/backend/tasks/tests/test_export.py index c10f20774b..e91985dbdb 100644 --- a/api/src/backend/tasks/tests/test_export.py +++ b/api/src/backend/tasks/tests/test_export.py @@ -150,15 +150,17 @@ class TestOutputs: provider = "aws" expected_timestamp = "20230615103045" - path, compliance = _generate_output_directory( + path, compliance, threatscore = _generate_output_directory( base_dir, provider, tenant_id, scan_id ) assert os.path.isdir(os.path.dirname(path)) assert os.path.isdir(os.path.dirname(compliance)) + assert os.path.isdir(os.path.dirname(threatscore)) assert path.endswith(f"{provider}-{expected_timestamp}") assert compliance.endswith(f"{provider}-{expected_timestamp}") + assert threatscore.endswith(f"{provider}-{expected_timestamp}") @patch("tasks.jobs.export.rls_transaction") @patch("tasks.jobs.export.Scan") @@ -181,12 +183,14 @@ class TestOutputs: provider = "aws/test@check" expected_timestamp = "20230615103045" - path, compliance = _generate_output_directory( + path, compliance, threatscore = _generate_output_directory( base_dir, provider, tenant_id, scan_id ) assert os.path.isdir(os.path.dirname(path)) assert os.path.isdir(os.path.dirname(compliance)) + assert os.path.isdir(os.path.dirname(threatscore)) assert path.endswith(f"aws-test-check-{expected_timestamp}") assert compliance.endswith(f"aws-test-check-{expected_timestamp}") + assert threatscore.endswith(f"aws-test-check-{expected_timestamp}") diff --git a/api/src/backend/tasks/tests/test_report.py b/api/src/backend/tasks/tests/test_report.py new file mode 100644 index 0000000000..ddc11d3a32 --- /dev/null +++ b/api/src/backend/tasks/tests/test_report.py @@ -0,0 +1,957 @@ +import uuid +from pathlib import Path +from unittest.mock import MagicMock, patch + +import matplotlib +import pytest +from tasks.jobs.report import ( + _aggregate_requirement_statistics_from_database, + _calculate_requirements_data_from_statistics, + _load_findings_for_requirement_checks, + generate_threatscore_report, + generate_threatscore_report_job, +) +from tasks.tasks import generate_threatscore_report_task + +from api.models import Finding, StatusChoices +from prowler.lib.check.models import Severity + +matplotlib.use("Agg") # Use non-interactive backend for tests + + +@pytest.mark.django_db +class TestGenerateThreatscoreReport: + def setup_method(self): + self.scan_id = str(uuid.uuid4()) + self.provider_id = str(uuid.uuid4()) + self.tenant_id = str(uuid.uuid4()) + + def test_no_findings_returns_early(self): + with patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter: + mock_filter.return_value.exists.return_value = False + + result = generate_threatscore_report_job( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) + + assert result == {"upload": False} + mock_filter.assert_called_once_with(scan_id=self.scan_id) + + @patch("tasks.jobs.report.rmtree") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_threatscore_report") + @patch("tasks.jobs.report._generate_output_directory") + @patch("tasks.jobs.report.Provider.objects.get") + @patch("tasks.jobs.report.ScanSummary.objects.filter") + def test_generate_threatscore_report_happy_path( + self, + mock_scan_summary_filter, + mock_provider_get, + mock_generate_output_directory, + mock_generate_report, + mock_upload, + mock_rmtree, + ): + mock_scan_summary_filter.return_value.exists.return_value = True + + mock_provider = MagicMock() + mock_provider.uid = "provider-uid" + mock_provider.provider = "aws" + mock_provider_get.return_value = mock_provider + + mock_generate_output_directory.return_value = ( + "/tmp/output", + "/tmp/compressed", + "/tmp/threatscore_path", + ) + + mock_upload.return_value = "s3://bucket/threatscore_report.pdf" + + result = generate_threatscore_report_job( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) + + assert result == {"upload": True} + mock_generate_report.assert_called_once_with( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + compliance_id="prowler_threatscore_aws", + output_path="/tmp/threatscore_path_threatscore_report.pdf", + provider_id=self.provider_id, + only_failed=True, + min_risk_level=4, + ) + mock_rmtree.assert_called_once_with( + Path("/tmp/threatscore_path_threatscore_report.pdf").parent, + ignore_errors=True, + ) + + def test_generate_threatscore_report_fails_upload(self): + with ( + patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, + patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, + patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, + patch("tasks.jobs.report.generate_threatscore_report"), + patch("tasks.jobs.report._upload_to_s3", return_value=None), + ): + mock_filter.return_value.exists.return_value = True + + # Mock provider + mock_provider = MagicMock() + mock_provider.uid = "aws-provider-uid" + mock_provider.provider = "aws" + mock_provider_get.return_value = mock_provider + + mock_gen_dir.return_value = ( + "/tmp/output", + "/tmp/compressed", + "/tmp/threatscore_path", + ) + + result = generate_threatscore_report_job( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) + + assert result == {"upload": False} + + def test_generate_threatscore_report_logs_rmtree_exception(self, caplog): + with ( + patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, + patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, + patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, + patch("tasks.jobs.report.generate_threatscore_report"), + patch( + "tasks.jobs.report._upload_to_s3", return_value="s3://bucket/report.pdf" + ), + patch( + "tasks.jobs.report.rmtree", side_effect=Exception("Test deletion error") + ), + ): + mock_filter.return_value.exists.return_value = True + + # Mock provider + mock_provider = MagicMock() + mock_provider.uid = "aws-provider-uid" + mock_provider.provider = "aws" + mock_provider_get.return_value = mock_provider + + mock_gen_dir.return_value = ( + "/tmp/output", + "/tmp/compressed", + "/tmp/threatscore_path", + ) + + with caplog.at_level("ERROR"): + generate_threatscore_report_job( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) + assert "Error deleting output files" in caplog.text + + def test_generate_threatscore_report_azure_provider(self): + with ( + patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, + patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, + patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, + patch("tasks.jobs.report.generate_threatscore_report") as mock_generate, + patch( + "tasks.jobs.report._upload_to_s3", return_value="s3://bucket/report.pdf" + ), + patch("tasks.jobs.report.rmtree"), + ): + mock_filter.return_value.exists.return_value = True + + mock_provider = MagicMock() + mock_provider.uid = "azure-provider-uid" + mock_provider.provider = "azure" + mock_provider_get.return_value = mock_provider + + mock_gen_dir.return_value = ( + "/tmp/output", + "/tmp/compressed", + "/tmp/threatscore_path", + ) + + generate_threatscore_report_job( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) + + mock_generate.assert_called_once_with( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + compliance_id="prowler_threatscore_azure", + output_path="/tmp/threatscore_path_threatscore_report.pdf", + provider_id=self.provider_id, + only_failed=True, + min_risk_level=4, + ) + + +@pytest.mark.django_db +class TestAggregateRequirementStatistics: + """Test suite for _aggregate_requirement_statistics_from_database function.""" + + def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture): + """Verify correct pass/total counts per check are aggregated from database.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + # Create findings with different check_ids and statuses + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-1", + check_id="check_1", + status=StatusChoices.PASS, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-2", + check_id="check_1", + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-3", + check_id="check_2", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result == { + "check_1": {"passed": 1, "total": 2}, + "check_2": {"passed": 1, "total": 1}, + } + + def test_handles_empty_scan(self, tenants_fixture, scans_fixture): + """Return empty dict when no findings exist for the scan.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result == {} + + def test_multiple_findings_same_check(self, tenants_fixture, scans_fixture): + """Aggregate multiple findings for same check_id correctly.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + # Create 5 findings for same check, 3 passed + for i in range(3): + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"finding-pass-{i}", + check_id="check_same", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + for i in range(2): + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"finding-fail-{i}", + check_id="check_same", + status=StatusChoices.FAIL, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result == {"check_same": {"passed": 3, "total": 5}} + + def test_only_failed_findings(self, tenants_fixture, scans_fixture): + """Correctly count when all findings are FAIL status.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-fail-1", + check_id="check_fail", + status=StatusChoices.FAIL, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-fail-2", + check_id="check_fail", + status=StatusChoices.FAIL, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result == {"check_fail": {"passed": 0, "total": 2}} + + def test_mixed_statuses(self, tenants_fixture, scans_fixture): + """Test with PASS, FAIL, and MANUAL statuses mixed.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-pass", + check_id="check_mixed", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-fail", + check_id="check_mixed", + status=StatusChoices.FAIL, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-manual", + check_id="check_mixed", + status=StatusChoices.MANUAL, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + # Only PASS status is counted as passed + assert result == {"check_mixed": {"passed": 1, "total": 3}} + + +@pytest.mark.django_db +class TestLoadFindingsForChecks: + """Test suite for _load_findings_for_requirement_checks function.""" + + def test_loads_only_requested_checks( + self, tenants_fixture, scans_fixture, providers_fixture + ): + """Verify only findings for specified check_ids are loaded.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + providers_fixture[0] + + # Create findings with different check_ids + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-1", + check_id="check_requested", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-2", + check_id="check_not_requested", + status=StatusChoices.FAIL, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + mock_provider = MagicMock() + + with patch( + "tasks.jobs.report.FindingOutput.transform_api_finding" + ) as mock_transform: + mock_finding_output = MagicMock() + mock_finding_output.check_id = "check_requested" + mock_transform.return_value = mock_finding_output + + result = _load_findings_for_requirement_checks( + str(tenant.id), str(scan.id), ["check_requested"], mock_provider + ) + + # Only one finding should be loaded + assert "check_requested" in result + assert "check_not_requested" not in result + assert len(result["check_requested"]) == 1 + assert mock_transform.call_count == 1 + + def test_empty_check_ids_returns_empty( + self, tenants_fixture, scans_fixture, providers_fixture + ): + """Return empty dict when check_ids list is empty.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + mock_provider = MagicMock() + + result = _load_findings_for_requirement_checks( + str(tenant.id), str(scan.id), [], mock_provider + ) + + assert result == {} + + def test_groups_by_check_id( + self, tenants_fixture, scans_fixture, providers_fixture + ): + """Multiple findings for same check are grouped correctly.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + # Create multiple findings for same check + for i in range(3): + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"finding-{i}", + check_id="check_group", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + mock_provider = MagicMock() + + with patch( + "tasks.jobs.report.FindingOutput.transform_api_finding" + ) as mock_transform: + mock_finding_output = MagicMock() + mock_finding_output.check_id = "check_group" + mock_transform.return_value = mock_finding_output + + result = _load_findings_for_requirement_checks( + str(tenant.id), str(scan.id), ["check_group"], mock_provider + ) + + assert len(result["check_group"]) == 3 + + def test_transforms_to_finding_output( + self, tenants_fixture, scans_fixture, providers_fixture + ): + """Findings are transformed using FindingOutput.transform_api_finding.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-transform", + check_id="check_transform", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + mock_provider = MagicMock() + + with patch( + "tasks.jobs.report.FindingOutput.transform_api_finding" + ) as mock_transform: + mock_finding_output = MagicMock() + mock_finding_output.check_id = "check_transform" + mock_transform.return_value = mock_finding_output + + result = _load_findings_for_requirement_checks( + str(tenant.id), str(scan.id), ["check_transform"], mock_provider + ) + + # Verify transform was called + mock_transform.assert_called_once() + # Verify the transformed output is in the result + assert result["check_transform"][0] == mock_finding_output + + def test_batched_iteration(self, tenants_fixture, scans_fixture, providers_fixture): + """Works correctly with multiple batches of findings.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + # Create enough findings to ensure batching (assuming batch size > 1) + for i in range(10): + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"finding-batch-{i}", + check_id="check_batch", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + mock_provider = MagicMock() + + with patch( + "tasks.jobs.report.FindingOutput.transform_api_finding" + ) as mock_transform: + mock_finding_output = MagicMock() + mock_finding_output.check_id = "check_batch" + mock_transform.return_value = mock_finding_output + + result = _load_findings_for_requirement_checks( + str(tenant.id), str(scan.id), ["check_batch"], mock_provider + ) + + # All 10 findings should be loaded regardless of batching + assert len(result["check_batch"]) == 10 + assert mock_transform.call_count == 10 + + +@pytest.mark.django_db +class TestCalculateRequirementsData: + """Test suite for _calculate_requirements_data_from_statistics function.""" + + def test_requirement_status_all_pass(self): + """Status is PASS when all findings for requirement checks pass.""" + mock_compliance = MagicMock() + mock_compliance.Framework = "TestFramework" + mock_compliance.Version = "1.0" + + mock_requirement = MagicMock() + mock_requirement.Id = "req_1" + mock_requirement.Description = "Test requirement" + mock_requirement.Checks = ["check_1", "check_2"] + mock_requirement.Attributes = [MagicMock()] + + mock_compliance.Requirements = [mock_requirement] + + requirement_statistics = { + "check_1": {"passed": 5, "total": 5}, + "check_2": {"passed": 3, "total": 3}, + } + + attributes_by_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + mock_compliance, requirement_statistics + ) + ) + + assert len(requirements_list) == 1 + assert requirements_list[0]["attributes"]["status"] == StatusChoices.PASS + assert requirements_list[0]["attributes"]["passed_findings"] == 8 + assert requirements_list[0]["attributes"]["total_findings"] == 8 + + def test_requirement_status_some_fail(self): + """Status is FAIL when some findings fail.""" + mock_compliance = MagicMock() + mock_compliance.Framework = "TestFramework" + mock_compliance.Version = "1.0" + + mock_requirement = MagicMock() + mock_requirement.Id = "req_2" + mock_requirement.Description = "Test requirement with failures" + mock_requirement.Checks = ["check_3"] + mock_requirement.Attributes = [MagicMock()] + + mock_compliance.Requirements = [mock_requirement] + + requirement_statistics = { + "check_3": {"passed": 2, "total": 5}, + } + + attributes_by_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + mock_compliance, requirement_statistics + ) + ) + + assert len(requirements_list) == 1 + assert requirements_list[0]["attributes"]["status"] == StatusChoices.FAIL + assert requirements_list[0]["attributes"]["passed_findings"] == 2 + assert requirements_list[0]["attributes"]["total_findings"] == 5 + + def test_requirement_status_no_findings(self): + """Status is MANUAL when no findings exist for requirement.""" + mock_compliance = MagicMock() + mock_compliance.Framework = "TestFramework" + mock_compliance.Version = "1.0" + + mock_requirement = MagicMock() + mock_requirement.Id = "req_3" + mock_requirement.Description = "Manual requirement" + mock_requirement.Checks = ["check_nonexistent"] + mock_requirement.Attributes = [MagicMock()] + + mock_compliance.Requirements = [mock_requirement] + + requirement_statistics = {} + + attributes_by_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + mock_compliance, requirement_statistics + ) + ) + + assert len(requirements_list) == 1 + assert requirements_list[0]["attributes"]["status"] == StatusChoices.MANUAL + assert requirements_list[0]["attributes"]["passed_findings"] == 0 + assert requirements_list[0]["attributes"]["total_findings"] == 0 + + def test_aggregates_multiple_checks(self): + """Correctly sum stats across multiple checks in requirement.""" + mock_compliance = MagicMock() + mock_compliance.Framework = "TestFramework" + mock_compliance.Version = "1.0" + + mock_requirement = MagicMock() + mock_requirement.Id = "req_4" + mock_requirement.Description = "Multi-check requirement" + mock_requirement.Checks = ["check_a", "check_b", "check_c"] + mock_requirement.Attributes = [MagicMock()] + + mock_compliance.Requirements = [mock_requirement] + + requirement_statistics = { + "check_a": {"passed": 10, "total": 15}, + "check_b": {"passed": 5, "total": 10}, + "check_c": {"passed": 0, "total": 5}, + } + + attributes_by_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + mock_compliance, requirement_statistics + ) + ) + + assert len(requirements_list) == 1 + # 10 + 5 + 0 = 15 passed + assert requirements_list[0]["attributes"]["passed_findings"] == 15 + # 15 + 10 + 5 = 30 total + assert requirements_list[0]["attributes"]["total_findings"] == 30 + # Not all passed, so should be FAIL + assert requirements_list[0]["attributes"]["status"] == StatusChoices.FAIL + + def test_returns_correct_structure(self): + """Verify tuple structure and dict keys are correct.""" + mock_compliance = MagicMock() + mock_compliance.Framework = "TestFramework" + mock_compliance.Version = "1.0" + + mock_attribute = MagicMock() + mock_requirement = MagicMock() + mock_requirement.Id = "req_5" + mock_requirement.Description = "Structure test" + mock_requirement.Checks = ["check_struct"] + mock_requirement.Attributes = [mock_attribute] + + mock_compliance.Requirements = [mock_requirement] + + requirement_statistics = {"check_struct": {"passed": 1, "total": 1}} + + attributes_by_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + mock_compliance, requirement_statistics + ) + ) + + # Verify attributes_by_id structure + assert "req_5" in attributes_by_id + assert "attributes" in attributes_by_id["req_5"] + assert "description" in attributes_by_id["req_5"] + assert "req_attributes" in attributes_by_id["req_5"]["attributes"] + assert "checks" in attributes_by_id["req_5"]["attributes"] + + # Verify requirements_list structure + assert len(requirements_list) == 1 + req = requirements_list[0] + assert "id" in req + assert "attributes" in req + assert "framework" in req["attributes"] + assert "version" in req["attributes"] + assert "status" in req["attributes"] + assert "description" in req["attributes"] + assert "passed_findings" in req["attributes"] + assert "total_findings" in req["attributes"] + + +@pytest.mark.django_db +class TestGenerateThreatscoreReportFunction: + def setup_method(self): + self.scan_id = str(uuid.uuid4()) + self.provider_id = str(uuid.uuid4()) + self.tenant_id = str(uuid.uuid4()) + self.compliance_id = "prowler_threatscore_aws" + self.output_path = "/tmp/test_threatscore_report.pdf" + + @patch("tasks.jobs.report.initialize_prowler_provider") + @patch("tasks.jobs.report.Provider.objects.get") + @patch("tasks.jobs.report.Compliance.get_bulk") + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report._calculate_requirements_data_from_statistics") + @patch("tasks.jobs.report._load_findings_for_requirement_checks") + @patch("tasks.jobs.report.SimpleDocTemplate") + @patch("tasks.jobs.report.Image") + @patch("tasks.jobs.report.Spacer") + @patch("tasks.jobs.report.Paragraph") + @patch("tasks.jobs.report.PageBreak") + @patch("tasks.jobs.report.Table") + @patch("tasks.jobs.report.TableStyle") + @patch("tasks.jobs.report.plt.subplots") + @patch("tasks.jobs.report.plt.savefig") + @patch("tasks.jobs.report.io.BytesIO") + def test_generate_threatscore_report_success( + self, + mock_bytesio, + mock_savefig, + mock_subplots, + mock_table_style, + mock_table, + mock_page_break, + mock_paragraph, + mock_spacer, + mock_image, + mock_doc_template, + mock_load_findings, + mock_calculate_requirements, + mock_aggregate_statistics, + mock_compliance_get_bulk, + mock_provider_get, + mock_initialize_provider, + ): + """Test the updated generate_threatscore_report using new memory-efficient architecture.""" + mock_provider = MagicMock() + mock_provider.provider = "aws" + mock_provider_get.return_value = mock_provider + + prowler_provider = MagicMock() + mock_initialize_provider.return_value = prowler_provider + + # Mock compliance object with requirements + mock_compliance_obj = MagicMock() + mock_compliance_obj.Framework = "ProwlerThreatScore" + mock_compliance_obj.Version = "1.0" + mock_compliance_obj.Description = "Test Description" + + # Configure requirement with properly set numeric attributes for chart generation + mock_requirement = MagicMock() + mock_requirement.Id = "req_1" + mock_requirement.Description = "Test requirement" + mock_requirement.Checks = ["check_1"] + + # Create a properly configured attribute mock with numeric values + mock_requirement_attr = MagicMock() + mock_requirement_attr.Section = "1. IAM" + mock_requirement_attr.SubSection = "1.1 Identity" + mock_requirement_attr.Title = "Test Requirement Title" + mock_requirement_attr.LevelOfRisk = 3 + mock_requirement_attr.Weight = 100 + mock_requirement_attr.AttributeDescription = "Test requirement description" + mock_requirement_attr.AdditionalInformation = "Additional test information" + + mock_requirement.Attributes = [mock_requirement_attr] + mock_compliance_obj.Requirements = [mock_requirement] + + mock_compliance_get_bulk.return_value = { + self.compliance_id: mock_compliance_obj + } + + # Mock the aggregated statistics from database + mock_aggregate_statistics.return_value = {"check_1": {"passed": 5, "total": 10}} + + # Mock the calculated requirements data with properly configured attributes + mock_attributes_by_id = { + "req_1": { + "attributes": { + "req_attributes": [mock_requirement_attr], + "checks": ["check_1"], + }, + "description": "Test requirement", + } + } + mock_requirements_list = [ + { + "id": "req_1", + "attributes": { + "framework": "ProwlerThreatScore", + "version": "1.0", + "status": StatusChoices.FAIL, + "description": "Test requirement", + "passed_findings": 5, + "total_findings": 10, + }, + } + ] + mock_calculate_requirements.return_value = ( + mock_attributes_by_id, + mock_requirements_list, + ) + + # Mock the on-demand loaded findings + mock_finding_output = MagicMock() + mock_finding_output.check_id = "check_1" + mock_finding_output.status = "FAIL" + mock_finding_output.metadata = MagicMock() + mock_finding_output.metadata.CheckTitle = "Test Check" + mock_finding_output.metadata.Severity = "HIGH" + mock_finding_output.resource_name = "test-resource" + mock_finding_output.region = "us-east-1" + + mock_load_findings.return_value = {"check_1": [mock_finding_output]} + + # Mock PDF generation components + mock_doc = MagicMock() + mock_doc_template.return_value = mock_doc + + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + mock_buffer = MagicMock() + mock_bytesio.return_value = mock_buffer + + mock_image.return_value = MagicMock() + mock_spacer.return_value = MagicMock() + mock_paragraph.return_value = MagicMock() + mock_page_break.return_value = MagicMock() + mock_table.return_value = MagicMock() + mock_table_style.return_value = MagicMock() + + # Execute the function + generate_threatscore_report( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + compliance_id=self.compliance_id, + output_path=self.output_path, + provider_id=self.provider_id, + only_failed=True, + min_risk_level=4, + ) + + # Verify the new workflow was followed + mock_provider_get.assert_called_once_with(id=self.provider_id) + mock_initialize_provider.assert_called_once_with(mock_provider) + mock_compliance_get_bulk.assert_called_once_with("aws") + + # Verify the new functions were called in correct order with correct parameters + mock_aggregate_statistics.assert_called_once_with(self.tenant_id, self.scan_id) + mock_calculate_requirements.assert_called_once_with( + mock_compliance_obj, {"check_1": {"passed": 5, "total": 10}} + ) + mock_load_findings.assert_called_once_with( + self.tenant_id, self.scan_id, ["check_1"], prowler_provider + ) + + # Verify PDF was built + mock_doc_template.assert_called_once() + mock_doc.build.assert_called_once() + + @patch("tasks.jobs.report.initialize_prowler_provider") + @patch("tasks.jobs.report.Provider.objects.get") + @patch("tasks.jobs.report.Compliance.get_bulk") + @patch("tasks.jobs.report.Finding.all_objects.filter") + def test_generate_threatscore_report_exception_handling( + self, + mock_finding_filter, + mock_compliance_get_bulk, + mock_provider_get, + mock_initialize_provider, + ): + mock_provider_get.side_effect = Exception("Provider not found") + + with pytest.raises(Exception, match="Provider not found"): + generate_threatscore_report( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + compliance_id=self.compliance_id, + output_path=self.output_path, + provider_id=self.provider_id, + only_failed=True, + min_risk_level=4, + ) + + +@pytest.mark.django_db +class TestGenerateThreatscoreReportTask: + def setup_method(self): + self.scan_id = str(uuid.uuid4()) + self.provider_id = str(uuid.uuid4()) + self.tenant_id = str(uuid.uuid4()) + + @patch("tasks.tasks.generate_threatscore_report_job") + def test_generate_threatscore_report_task_calls_job(self, mock_generate_job): + mock_generate_job.return_value = {"upload": True} + + result = generate_threatscore_report_task( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) + + assert result == {"upload": True} + mock_generate_job.assert_called_once_with( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) + + @patch("tasks.tasks.generate_threatscore_report_job") + def test_generate_threatscore_report_task_handles_job_exception( + self, mock_generate_job + ): + mock_generate_job.side_effect = Exception("Job failed") + + with pytest.raises(Exception, match="Job failed"): + generate_threatscore_report_task( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index b4262027f6..a98341ef35 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -98,7 +98,11 @@ class TestGenerateOutputs: ), patch( "tasks.tasks._generate_output_directory", - return_value=("out-dir", "comp-dir"), + return_value=( + "/tmp/test/out-dir", + "/tmp/test/comp-dir", + "/tmp/test/threat-dir", + ), ), patch("tasks.tasks.Scan.all_objects.filter") as mock_scan_update, patch("tasks.tasks.rmtree"), @@ -126,7 +130,8 @@ class TestGenerateOutputs: patch("tasks.tasks.get_compliance_frameworks"), patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( - "tasks.tasks._generate_output_directory", return_value=("out", "comp") + "tasks.tasks._generate_output_directory", + return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), patch("tasks.tasks.FindingOutput.transform_api_finding"), @@ -168,15 +173,35 @@ class TestGenerateOutputs: mock_finding_output = MagicMock() mock_finding_output.compliance = {"cis": ["requirement-1", "requirement-2"]} + html_writer_mock = MagicMock() + html_writer_mock._data = [] + html_writer_mock.close_file = False + html_writer_mock.transform = MagicMock() + html_writer_mock.batch_write_data_to_file = MagicMock() + + compliance_writer_mock = MagicMock() + compliance_writer_mock._data = [] + compliance_writer_mock.close_file = False + compliance_writer_mock.transform = MagicMock() + compliance_writer_mock.batch_write_data_to_file = MagicMock() + + # Create a mock class that returns our mock instance when called + mock_compliance_class = MagicMock(return_value=compliance_writer_mock) + + mock_provider = MagicMock() + mock_provider.provider = "aws" + mock_provider.uid = "test-provider-uid" + with ( patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter, - patch("tasks.tasks.Provider.objects.get"), + patch("tasks.tasks.Provider.objects.get", return_value=mock_provider), patch("tasks.tasks.initialize_prowler_provider"), patch("tasks.tasks.Compliance.get_bulk", return_value={"cis": MagicMock()}), patch("tasks.tasks.get_compliance_frameworks", return_value=["cis"]), patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( - "tasks.tasks._generate_output_directory", return_value=("out", "comp") + "tasks.tasks._generate_output_directory", + return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), ), patch( "tasks.tasks.FindingOutput._transform_findings_stats", @@ -190,6 +215,20 @@ class TestGenerateOutputs: patch("tasks.tasks._upload_to_s3", return_value="s3://bucket/f.zip"), patch("tasks.tasks.Scan.all_objects.filter"), patch("tasks.tasks.rmtree"), + patch( + "tasks.tasks.OUTPUT_FORMATS_MAPPING", + { + "html": { + "class": lambda *args, **kwargs: html_writer_mock, + "suffix": ".html", + "kwargs": {}, + } + }, + ), + patch( + "tasks.tasks.COMPLIANCE_CLASS_MAP", + {"aws": [(lambda x: True, mock_compliance_class)]}, + ), ): mock_filter.return_value.exists.return_value = True mock_findings.return_value.order_by.return_value.iterator.return_value = [ @@ -197,29 +236,12 @@ class TestGenerateOutputs: True, ] - html_writer_mock = MagicMock() - with ( - patch( - "tasks.tasks.OUTPUT_FORMATS_MAPPING", - { - "html": { - "class": lambda *args, **kwargs: html_writer_mock, - "suffix": ".html", - "kwargs": {}, - } - }, - ), - patch( - "tasks.tasks.COMPLIANCE_CLASS_MAP", - {"aws": [(lambda x: True, MagicMock())]}, - ), - ): - generate_outputs_task( - scan_id=self.scan_id, - provider_id=self.provider_id, - tenant_id=self.tenant_id, - ) - html_writer_mock.batch_write_data_to_file.assert_called_once() + generate_outputs_task( + scan_id=self.scan_id, + provider_id=self.provider_id, + tenant_id=self.tenant_id, + ) + html_writer_mock.batch_write_data_to_file.assert_called_once() def test_transform_called_only_on_second_batch(self): raw1 = MagicMock() @@ -256,7 +278,11 @@ class TestGenerateOutputs: ), patch( "tasks.tasks._generate_output_directory", - return_value=("outdir", "compdir"), + return_value=( + "/tmp/test/outdir", + "/tmp/test/compdir", + "/tmp/test/threatdir", + ), ), patch("tasks.tasks._compress_output_files", return_value="outdir.zip"), patch("tasks.tasks._upload_to_s3", return_value="s3://bucket/outdir.zip"), @@ -303,12 +329,14 @@ class TestGenerateOutputs: def __init__(self, *args, **kwargs): self.transform_calls = [] self._data = [] + self.close_file = False writer_instances.append(self) def transform(self, fos, comp_obj, name): self.transform_calls.append((fos, comp_obj, name)) def batch_write_data_to_file(self): + # Mock implementation - do nothing pass two_batches = [ @@ -329,7 +357,11 @@ class TestGenerateOutputs: patch("tasks.tasks.get_compliance_frameworks", return_value=["cis"]), patch( "tasks.tasks._generate_output_directory", - return_value=("outdir", "compdir"), + return_value=( + "/tmp/test/outdir", + "/tmp/test/compdir", + "/tmp/test/threatdir", + ), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), patch( @@ -368,15 +400,35 @@ class TestGenerateOutputs: mock_finding_output = MagicMock() mock_finding_output.compliance = {"cis": ["requirement-1", "requirement-2"]} + json_writer_mock = MagicMock() + json_writer_mock._data = [] + json_writer_mock.close_file = False + json_writer_mock.transform = MagicMock() + json_writer_mock.batch_write_data_to_file = MagicMock() + + compliance_writer_mock = MagicMock() + compliance_writer_mock._data = [] + compliance_writer_mock.close_file = False + compliance_writer_mock.transform = MagicMock() + compliance_writer_mock.batch_write_data_to_file = MagicMock() + + # Create a mock class that returns our mock instance when called + mock_compliance_class = MagicMock(return_value=compliance_writer_mock) + + mock_provider = MagicMock() + mock_provider.provider = "aws" + mock_provider.uid = "test-provider-uid" + with ( patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter, - patch("tasks.tasks.Provider.objects.get"), + patch("tasks.tasks.Provider.objects.get", return_value=mock_provider), patch("tasks.tasks.initialize_prowler_provider"), patch("tasks.tasks.Compliance.get_bulk", return_value={"cis": MagicMock()}), patch("tasks.tasks.get_compliance_frameworks", return_value=["cis"]), patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( - "tasks.tasks._generate_output_directory", return_value=("out", "comp") + "tasks.tasks._generate_output_directory", + return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), ), patch( "tasks.tasks.FindingOutput._transform_findings_stats", @@ -390,6 +442,20 @@ class TestGenerateOutputs: patch("tasks.tasks._upload_to_s3", return_value="s3://bucket/file.zip"), patch("tasks.tasks.Scan.all_objects.filter"), patch("tasks.tasks.rmtree", side_effect=Exception("Test deletion error")), + patch( + "tasks.tasks.OUTPUT_FORMATS_MAPPING", + { + "json": { + "class": lambda *args, **kwargs: json_writer_mock, + "suffix": ".json", + "kwargs": {}, + } + }, + ), + patch( + "tasks.tasks.COMPLIANCE_CLASS_MAP", + {"aws": [(lambda x: True, mock_compliance_class)]}, + ), ): mock_filter.return_value.exists.return_value = True mock_findings.return_value.order_by.return_value.iterator.return_value = [ @@ -397,29 +463,13 @@ class TestGenerateOutputs: True, ] - with ( - patch( - "tasks.tasks.OUTPUT_FORMATS_MAPPING", - { - "json": { - "class": lambda *args, **kwargs: MagicMock(), - "suffix": ".json", - "kwargs": {}, - } - }, - ), - patch( - "tasks.tasks.COMPLIANCE_CLASS_MAP", - {"aws": [(lambda x: True, MagicMock())]}, - ), - ): - with caplog.at_level("ERROR"): - generate_outputs_task( - scan_id=self.scan_id, - provider_id=self.provider_id, - tenant_id=self.tenant_id, - ) - assert "Error deleting output files" in caplog.text + with caplog.at_level("ERROR"): + generate_outputs_task( + scan_id=self.scan_id, + provider_id=self.provider_id, + tenant_id=self.tenant_id, + ) + assert "Error deleting output files" in caplog.text @patch("tasks.tasks.rls_transaction") @patch("tasks.tasks.Integration.objects.filter") @@ -435,7 +485,8 @@ class TestGenerateOutputs: patch("tasks.tasks.get_compliance_frameworks", return_value=[]), patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( - "tasks.tasks._generate_output_directory", return_value=("out", "comp") + "tasks.tasks._generate_output_directory", + return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), patch("tasks.tasks.FindingOutput.transform_api_finding"), @@ -476,8 +527,15 @@ class TestScanCompleteTasks: @patch("tasks.tasks.create_compliance_requirements_task.apply_async") @patch("tasks.tasks.perform_scan_summary_task.si") @patch("tasks.tasks.generate_outputs_task.si") + @patch("tasks.tasks.generate_threatscore_report_task.si") + @patch("tasks.tasks.check_integrations_task.si") def test_scan_complete_tasks( - self, mock_outputs_task, mock_scan_summary_task, mock_compliance_tasks + self, + mock_check_integrations_task, + mock_threatscore_task, + mock_outputs_task, + mock_scan_summary_task, + mock_compliance_tasks, ): _perform_scan_complete_tasks("tenant-id", "scan-id", "provider-id") mock_compliance_tasks.assert_called_once_with( @@ -492,6 +550,16 @@ class TestScanCompleteTasks: provider_id="provider-id", tenant_id="tenant-id", ) + mock_threatscore_task.assert_called_once_with( + tenant_id="tenant-id", + scan_id="scan-id", + provider_id="provider-id", + ) + mock_check_integrations_task.assert_called_once_with( + tenant_id="tenant-id", + provider_id="provider-id", + scan_id="scan-id", + ) @pytest.mark.django_db @@ -662,7 +730,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings @@ -787,7 +855,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings @@ -903,7 +971,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 9f0174f4ba..7c5a3edc09 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file. - React Compiler support for automatic optimization [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Turbopack support for faster development builds [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Add compliance name in compliance detail view [(#8775)](https://github.com/prowler-cloud/prowler/pull/8775) +- PDF reporting for Prowler ThreatScore [(#8867)](https://github.com/prowler-cloud/prowler/pull/8867) - Support C5 compliance framework for the AWS provider [(#8830)](https://github.com/prowler-cloud/prowler/pull/8830) - API key management in user profile [(#8308)](https://github.com/prowler-cloud/prowler/pull/8308) - Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864) diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index c17f056f3f..6dc3654344 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -268,3 +268,45 @@ export const getComplianceCsv = async ( }; } }; + +export const getThreatScorePdf = async (scanId: string) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/scans/${scanId}/threatscore`); + + try { + const response = await fetch(url.toString(), { headers }); + + if (response.status === 202) { + const json = await response.json(); + const taskId = json?.data?.id; + const state = json?.data?.attributes?.state; + return { + pending: true, + state, + taskId, + }; + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData?.errors?.detail || + "Unable to retrieve ThreatScore PDF report. Contact support if the issue continues.", + ); + } + + const arrayBuffer = await response.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString("base64"); + + return { + success: true, + data: base64, + filename: `scan-${scanId}-threatscore.pdf`, + }; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 2ea1bc823b..2b7cfd8cfd 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -29,6 +29,8 @@ import { } from "@/types/compliance"; import { ScanEntity } from "@/types/scans"; +import { ThreatScoreDownloadButton } from "./threatscore-download-button"; + interface ComplianceDetailSearchParams { complianceId: string; version?: string; @@ -143,13 +145,24 @@ export default async function ComplianceDetail({
)} - +
+
+ +
+ {attributesData?.data?.[0]?.attributes?.framework === + "ProwlerThreatScore" && + selectedScanId && ( +
+ +
+ )} +
{ + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = async () => { + setIsDownloading(true); + try { + await downloadThreatScorePdf(scanId, toast); + } finally { + setIsDownloading(false); + } + }; + + return ( + + ); +}; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index de94cf0fe2..a4bde8104f 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -1,16 +1,22 @@ export const dynamic = "force-dynamic"; import { Suspense } from "react"; -import { getCompliancesOverview } from "@/actions/compliances"; -import { getComplianceOverviewMetadataInfo } from "@/actions/compliances"; +import { + getComplianceAttributes, + getComplianceOverviewMetadataInfo, + getComplianceRequirements, + getCompliancesOverview, +} from "@/actions/compliances"; import { getScans } from "@/actions/scans"; import { ComplianceCard, ComplianceSkeletonGrid, NoScansAvailable, + ThreatScoreBadge, } from "@/components/compliance"; import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header"; import { ContentLayout } from "@/components/ui"; +import { calculateThreatScore } from "@/lib/compliance/threatscore-calculator"; import { ExpandedScanData, ScanEntity, @@ -74,6 +80,7 @@ export default async function Compliance({ }) .filter(Boolean) as ExpandedScanData[]; + // Use scanId from URL, or select the first scan if not provided const selectedScanId = resolvedSearchParams.scanId || expandedScansData[0]?.id || null; const query = (filters["filter[search]"] as string) || ""; @@ -94,6 +101,7 @@ export default async function Compliance({ } : undefined; + // Fetch metadata if we have a selected scan const metadataInfoData = selectedScanId ? await getComplianceOverviewMetadataInfo({ query, @@ -105,14 +113,52 @@ export default async function Compliance({ const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; + // Fetch ThreatScore data if we have a selected scan + let threatScoreData = null; + if ( + selectedScanId && + typeof selectedScanId === "string" && + selectedScan?.providerInfo?.provider + ) { + const complianceId = `prowler_threatscore_${selectedScan.providerInfo.provider.toLowerCase()}`; + + const [attributesData, requirementsData] = await Promise.all([ + getComplianceAttributes(complianceId), + getComplianceRequirements({ + complianceId, + scanId: selectedScanId, + }), + ]); + + threatScoreData = calculateThreatScore(attributesData, requirementsData); + } + return ( {selectedScanId ? ( <> - +
+
+
+ +
+ {threatScoreData && + typeof selectedScanId === "string" && + selectedScan && ( +
+ +
+ )} +
+
}> - {compliancesData.data.map((compliance: ComplianceOverviewData) => { - const { attributes, id } = compliance; - const { framework, version, requirements_passed, total_requirements } = - attributes; + {compliancesData.data + .filter((compliance: ComplianceOverviewData) => { + // Filter out ProwlerThreatScore from the grid + return compliance.attributes.framework !== "ProwlerThreatScore"; + }) + .map((compliance: ComplianceOverviewData) => { + const { attributes, id } = compliance; + const { + framework, + version, + requirements_passed, + total_requirements, + } = attributes; - return ( - - ); - })} + return ( + + ); + })}
); }; diff --git a/ui/components/compliance/compliance-header/data-compliance.tsx b/ui/components/compliance/compliance-header/data-compliance.tsx index 7d29ebfe7d..992dc3d683 100644 --- a/ui/components/compliance/compliance-header/data-compliance.tsx +++ b/ui/components/compliance/compliance-header/data-compliance.tsx @@ -19,11 +19,13 @@ export const DataCompliance = ({ scans }: DataComplianceProps) => { const selectedScanId = scanIdParam || (scans.length > 0 ? scans[0].id : ""); + // Don't auto-push scanId to URL - the server already handles the default scan selection + // This avoids duplicate API calls caused by client-side navigation useEffect(() => { if (!scanIdParam && scans.length > 0) { const params = new URLSearchParams(searchParams); params.set("scanId", scans[0].id); - router.push(`?${params.toString()}`); + router.replace(`?${params.toString()}`, { scroll: false }); } }, [scans, scanIdParam, searchParams, router]); diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index 096951a261..4365f4ec65 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -19,3 +19,5 @@ export * from "./skeletons/compliance-accordion-skeleton"; export * from "./skeletons/compliance-grid-skeleton"; export * from "./skeletons/heatmap-chart-skeleton"; export * from "./skeletons/pie-chart-skeleton"; +export * from "./threatscore-badge"; +export * from "./threatscore-logo"; diff --git a/ui/components/compliance/threatscore-badge.tsx b/ui/components/compliance/threatscore-badge.tsx new file mode 100644 index 0000000000..b9ba9d5b7b --- /dev/null +++ b/ui/components/compliance/threatscore-badge.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Button } from "@heroui/button"; +import { Card, CardBody } from "@heroui/card"; +import { Progress } from "@heroui/progress"; +import { DownloadIcon, FileTextIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; + +import { ThreatScoreLogo } from "@/components/compliance/threatscore-logo"; +import { toast } from "@/components/ui"; +import { downloadComplianceCsv, downloadThreatScorePdf } from "@/lib/helper"; +import type { ScanEntity } from "@/types/scans"; + +interface ThreatScoreBadgeProps { + score: number; + scanId: string; + provider: string; + selectedScan?: ScanEntity; +} + +export const ThreatScoreBadge = ({ + score, + scanId, + provider, + selectedScan, +}: ThreatScoreBadgeProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); + const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); + + const complianceId = `prowler_threatscore_${provider.toLowerCase()}`; + + const getScoreColor = (): "success" | "warning" | "danger" => { + if (score >= 80) return "success"; + if (score >= 40) return "warning"; + return "danger"; + }; + + const getTextColor = () => { + if (score >= 80) return "text-success"; + if (score >= 40) return "text-warning"; + return "text-danger"; + }; + + const handleCardClick = () => { + const title = "ProwlerThreatScore"; + const version = "1.0"; + const formattedTitleForUrl = encodeURIComponent(title); + const path = `/compliance/${formattedTitleForUrl}`; + const params = new URLSearchParams(); + + params.set("complianceId", complianceId); + params.set("version", version); + params.set("scanId", scanId); + + if (selectedScan) { + params.set( + "scanData", + JSON.stringify({ + id: selectedScan.id, + providerInfo: selectedScan.providerInfo, + attributes: selectedScan.attributes, + }), + ); + } + + const regionFilter = searchParams.get("filter[region__in]"); + if (regionFilter) { + params.set("filter[region__in]", regionFilter); + } + + router.push(`${path}?${params.toString()}`); + }; + + const handleDownloadPdf = async () => { + setIsDownloadingPdf(true); + try { + await downloadThreatScorePdf(scanId, toast); + } finally { + setIsDownloadingPdf(false); + } + }; + + const handleDownloadCsv = async () => { + setIsDownloadingCsv(true); + try { + await downloadComplianceCsv(scanId, complianceId, toast); + } finally { + setIsDownloadingCsv(false); + } + }; + + return ( + + + +
+ + +
+
+
+ ); +}; diff --git a/ui/components/compliance/threatscore-logo.tsx b/ui/components/compliance/threatscore-logo.tsx new file mode 100644 index 0000000000..d4b98d4ccc --- /dev/null +++ b/ui/components/compliance/threatscore-logo.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +export const ThreatScoreLogo = () => { + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + // Avoid hydration mismatch by only rendering after mount + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return
; + } + + const prowlerColor = resolvedTheme === "dark" ? "#fff" : "#000"; + + return ( + + {/* Prowler logo from the new SVG - scaled and positioned to match THREATSCORE size */} + + + + + {/* THREATSCORE text */} + + THREATSCORE + + + {/* Gauge icon - semicircular meter - 1.5x larger */} + + {/* Gauge arcs - drawing from left to right (orange, red, green) */} + + + + + {/* Checkmark */} + + + + ); +}; diff --git a/ui/lib/compliance/threatscore-calculator.ts b/ui/lib/compliance/threatscore-calculator.ts new file mode 100644 index 0000000000..d2067d08fb --- /dev/null +++ b/ui/lib/compliance/threatscore-calculator.ts @@ -0,0 +1,69 @@ +import { AttributesData, RequirementsData } from "@/types/compliance"; + +export interface ThreatScoreResult { + score: number; +} + +/** + * Calculates the ThreatScore for a given provider's compliance data. + * This function replicates the calculation logic from the server-side getThreatScore + * but operates on already-fetched attribute and requirement data. + * + * @param attributesData - Compliance attributes containing metadata like Weight and LevelOfRisk + * @param requirementsData - Compliance requirements containing passed and total findings + * @returns The calculated ThreatScore or null if calculation fails + */ +export function calculateThreatScore( + attributesData: AttributesData | undefined, + requirementsData: RequirementsData | undefined, +): ThreatScoreResult | null { + if (!attributesData?.data || !requirementsData?.data) { + return null; + } + + // Create requirements map for fast lookup + const requirementsMap = new Map(); + for (const req of requirementsData.data) { + requirementsMap.set(req.id, req); + } + + // Calculate ThreatScore using the same formula as the server-side version + let numerator = 0; + let denominator = 0; + let hasFindings = false; + + for (const attributeItem of attributesData.data) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as any[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const pass_i = requirementData.attributes.passed_findings || 0; + const total_i = requirementData.attributes.total_findings || 0; + + if (total_i === 0) continue; + + hasFindings = true; + const rate_i = pass_i / total_i; + const weight_i = attrs.Weight || 1; + const levelOfRisk = attrs.LevelOfRisk || 0; + const rfac_i = 1 + 0.25 * levelOfRisk; + + numerator += rate_i * total_i * weight_i * rfac_i; + denominator += total_i * weight_i * rfac_i; + } + + const score = !hasFindings + ? 100 + : denominator > 0 + ? (numerator / denominator) * 100 + : 0; + + return { + score: Math.round(score * 100) / 100, + }; +} diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 1888b63dcb..89b352c90b 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,4 +1,8 @@ -import { getComplianceCsv, getExportsZip } from "@/actions/scans"; +import { + getComplianceCsv, + getExportsZip, + getThreatScorePdf, +} from "@/actions/scans"; import { getTask } from "@/actions/task"; import { auth } from "@/auth.config"; import { useToast } from "@/components/ui"; @@ -137,13 +141,15 @@ export const downloadScanZip = async ( } }; -export const downloadComplianceCsv = async ( - scanId: string, - complianceId: string, +/** + * Generic function to download a file from base64 data + */ +const downloadFile = async ( + result: any, + outputType: string, + successMessage: string, toast: ReturnType["toast"], ): Promise => { - const result = await getComplianceCsv(scanId, complianceId); - if (result?.pending) { toast({ title: "The report is still being generated", @@ -160,7 +166,7 @@ export const downloadComplianceCsv = async ( bytes[i] = binaryString.charCodeAt(i); } - const blob = new Blob([bytes], { type: "text/csv" }); + const blob = new Blob([bytes], { type: outputType }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -172,7 +178,7 @@ export const downloadComplianceCsv = async ( toast({ title: "Download Complete", - description: "The compliance report has been downloaded successfully.", + description: successMessage, }); } catch (error) { toast({ @@ -201,6 +207,33 @@ export const downloadComplianceCsv = async ( }); }; +export const downloadComplianceCsv = async ( + scanId: string, + complianceId: string, + toast: ReturnType["toast"], +): Promise => { + const result = await getComplianceCsv(scanId, complianceId); + await downloadFile( + result, + "text/csv", + "The compliance report has been downloaded successfully.", + toast, + ); +}; + +export const downloadThreatScorePdf = async ( + scanId: string, + toast: ReturnType["toast"], +): Promise => { + const result = await getThreatScorePdf(scanId); + await downloadFile( + result, + "application/pdf", + "The ThreatScore PDF report has been downloaded successfully.", + toast, + ); +}; + export const isGoogleOAuthEnabled = !!process.env.SOCIAL_GOOGLE_OAUTH_CLIENT_ID && !!process.env.SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET; From 665662939157074b422ac63ec08147e0a356792f Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:07:28 +0200 Subject: [PATCH 03/11] docs: include docker platform warning in App installation too (#8979) --- docs/getting-started/installation/prowler-app.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 862d76a73f..4c49b8d4f0 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -25,6 +25,9 @@ Prowler configuration is based in `.env` files. Every version of Prowler can hav curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/.env docker compose up -d ``` + + > Containers are built for `linux/amd64`. If your workstation's architecture is different, please set `DOCKER_DEFAULT_PLATFORM=linux/amd64` in your environment or use the `--platform linux/amd64` flag in the docker command. + _Requirements_: From f8c8dee2b32cf7768f18787f9763a497dc04f710 Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:45:26 +0200 Subject: [PATCH 04/11] feat(gcp): add `cloudstorage_bucket_lifecycle_management_enabled` check (#8936) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...lifecycle_management_enabled.metadata.json | 34 +++ ...age_bucket_lifecycle_management_enabled.py | 48 ++++ .../cloudstorage/cloudstorage_service.py | 10 + ...ucket_lifecycle_management_enabled_test.py | 223 ++++++++++++++++++ 6 files changed, 316 insertions(+) create mode 100644 prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/__init__.py create mode 100644 prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled.metadata.json create mode 100644 prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled.py create mode 100644 tests/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 712c53ac25..2b5bdfd42a 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Oracle Cloud provider with CIS 3.0 benchmark [(#8893)](https://github.com/prowler-cloud/prowler/pull/8893) - Support for Atlassian Document Format (ADF) in Jira integration [(#8878)](https://github.com/prowler-cloud/prowler/pull/8878) - Add Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000) +- `cloudstorage_bucket_lifecycle_management_enabled` check for GCP provider [(#8936)](https://github.com/prowler-cloud/prowler/pull/8936) ### Changed diff --git a/prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/__init__.py b/prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled.metadata.json b/prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled.metadata.json new file mode 100644 index 0000000000..450f2d96c5 --- /dev/null +++ b/prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "gcp", + "CheckID": "cloudstorage_bucket_lifecycle_management_enabled", + "CheckTitle": "Cloud Storage buckets have lifecycle management enabled", + "CheckType": [], + "ServiceName": "cloudstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "storage.googleapis.com/Bucket", + "Description": "**Google Cloud Storage buckets** are evaluated for the presence of **lifecycle management** with at least one valid rule (supported action and non-empty condition) to automatically transition or delete objects and optimize storage costs.", + "Risk": "Buckets without lifecycle rules can accumulate stale data, increase storage costs, and fail to meet data retention and internal compliance requirements.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudStorage/enable-lifecycle-management.html", + "https://cloud.google.com/storage/docs/lifecycle" + ], + "Remediation": { + "Code": { + "CLI": "gcloud storage buckets update gs:// --lifecycle-file=", + "NativeIaC": "", + "Other": "1) Open Google Cloud Console → Storage → Buckets → \n2) Tab 'Lifecycle'\n3) Add rule(s) to delete or transition objects (e.g., delete after 365 days; transition STANDARD→NEARLINE after 90 days)\n4) Save", + "Terraform": "```hcl\n# Example: enable lifecycle to transition and delete objects\nresource \"google_storage_bucket\" \"example\" {\n name = var.bucket_name\n location = var.location\n\n # Transition STANDARD → NEARLINE after 90 days\n lifecycle_rule {\n action {\n type = \"SetStorageClass\"\n storage_class = \"NEARLINE\"\n }\n condition {\n age = 90\n matches_storage_class = [\"STANDARD\"]\n }\n }\n\n # Delete objects after 365 days\n lifecycle_rule {\n action {\n type = \"Delete\"\n }\n condition {\n age = 365\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure lifecycle rules to automatically delete stale objects or transition them to colder storage classes according to your organization's retention and cost-optimization policy.", + "Url": "https://hub.prowler.com/check/cloudstorage_bucket_lifecycle_management_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled.py b/prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled.py new file mode 100644 index 0000000000..951bf57d4f --- /dev/null +++ b/prowler/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled.py @@ -0,0 +1,48 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudstorage.cloudstorage_client import ( + cloudstorage_client, +) + + +class cloudstorage_bucket_lifecycle_management_enabled(Check): + """Ensure Cloud Storage buckets have lifecycle management enabled with at least one valid rule. + + Reports PASS if a bucket has at least one valid lifecycle rule + (with a supported action and condition), otherwise FAIL. + + """ + + def execute(self) -> list[Check_Report_GCP]: + """Run the lifecycle management check for each Cloud Storage bucket. + + Returns: + list[Check_Report_GCP]: Results for all evaluated buckets. + """ + + findings = [] + for bucket in cloudstorage_client.buckets: + report = Check_Report_GCP(metadata=self.metadata(), resource=bucket) + report.status = "FAIL" + report.status_extended = ( + f"Bucket {bucket.name} does not have lifecycle management enabled." + ) + + rules = bucket.lifecycle_rules + + if rules: + valid_rules = [] + for rule in rules: + action_type = rule.get("action", {}).get("type") + condition = rule.get("condition") + if action_type and condition: + valid_rules.append(rule) + + if valid_rules: + report.status = "PASS" + report.status_extended = f"Bucket {bucket.name} has lifecycle management enabled with {len(valid_rules)} valid rule(s)." + else: + report.status = "FAIL" + report.status_extended = f"Bucket {bucket.name} has lifecycle rules configured but none are valid." + + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudstorage/cloudstorage_service.py b/prowler/providers/gcp/services/cloudstorage/cloudstorage_service.py index 294455077c..7d155d254f 100644 --- a/prowler/providers/gcp/services/cloudstorage/cloudstorage_service.py +++ b/prowler/providers/gcp/services/cloudstorage/cloudstorage_service.py @@ -31,6 +31,14 @@ class CloudStorage(GCPService): bucket_iam ) or "allUsers" in str(bucket_iam): public = True + + lifecycle_rules = None + lifecycle = bucket.get("lifecycle") + if isinstance(lifecycle, dict): + rules = lifecycle.get("rule") + if isinstance(rules, list): + lifecycle_rules = rules + self.buckets.append( Bucket( name=bucket["name"], @@ -42,6 +50,7 @@ class CloudStorage(GCPService): public=public, retention_policy=bucket.get("retentionPolicy"), project_id=project_id, + lifecycle_rules=lifecycle_rules, ) ) @@ -62,3 +71,4 @@ class Bucket(BaseModel): public: bool project_id: str retention_policy: Optional[dict] = None + lifecycle_rules: Optional[list[dict]] = None diff --git a/tests/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled_test.py b/tests/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled_test.py new file mode 100644 index 0000000000..17016645a7 --- /dev/null +++ b/tests/providers/gcp/services/cloudstorage/cloudstorage_bucket_lifecycle_management_enabled/cloudstorage_bucket_lifecycle_management_enabled_test.py @@ -0,0 +1,223 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + + +class TestCloudStorageBucketLifecycleManagementEnabled: + def test_bucket_without_lifecycle_rules(self): + cloudstorage_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_client", + new=cloudstorage_client, + ), + ): + from prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_bucket_lifecycle_management_enabled import ( + cloudstorage_bucket_lifecycle_management_enabled, + ) + from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import ( + Bucket, + ) + + cloudstorage_client.project_ids = [GCP_PROJECT_ID] + cloudstorage_client.region = GCP_US_CENTER1_LOCATION + + cloudstorage_client.buckets = [ + Bucket( + name="no-lifecycle", + id="no-lifecycle", + region=GCP_US_CENTER1_LOCATION, + uniform_bucket_level_access=True, + public=False, + retention_policy=None, + project_id=GCP_PROJECT_ID, + lifecycle_rules=[], + ) + ] + + check = cloudstorage_bucket_lifecycle_management_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Bucket {cloudstorage_client.buckets[0].name} does not have lifecycle management enabled." + ) + assert result[0].resource_id == "no-lifecycle" + assert result[0].resource_name == "no-lifecycle" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_bucket_with_minimal_delete_rule(self): + cloudstorage_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_client", + new=cloudstorage_client, + ), + ): + from prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_bucket_lifecycle_management_enabled import ( + cloudstorage_bucket_lifecycle_management_enabled, + ) + from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import ( + Bucket, + ) + + cloudstorage_client.project_ids = [GCP_PROJECT_ID] + cloudstorage_client.region = GCP_US_CENTER1_LOCATION + + cloudstorage_client.buckets = [ + Bucket( + name="delete-rule", + id="delete-rule", + region=GCP_US_CENTER1_LOCATION, + uniform_bucket_level_access=True, + public=False, + retention_policy=None, + project_id=GCP_PROJECT_ID, + lifecycle_rules=[ + {"action": {"type": "Delete"}, "condition": {"age": 30}} + ], + ) + ] + + check = cloudstorage_bucket_lifecycle_management_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Bucket {cloudstorage_client.buckets[0].name} has lifecycle management enabled with 1 valid rule(s)." + ) + assert result[0].resource_id == "delete-rule" + assert result[0].resource_name == "delete-rule" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_bucket_with_transition_and_delete_rules(self): + cloudstorage_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_client", + new=cloudstorage_client, + ), + ): + from prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_bucket_lifecycle_management_enabled import ( + cloudstorage_bucket_lifecycle_management_enabled, + ) + from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import ( + Bucket, + ) + + cloudstorage_client.project_ids = [GCP_PROJECT_ID] + cloudstorage_client.region = GCP_US_CENTER1_LOCATION + + cloudstorage_client.buckets = [ + Bucket( + name="transition-delete", + id="transition-delete", + region=GCP_US_CENTER1_LOCATION, + uniform_bucket_level_access=True, + public=False, + retention_policy=None, + project_id=GCP_PROJECT_ID, + lifecycle_rules=[ + { + "action": { + "type": "SetStorageClass", + "storageClass": "NEARLINE", + }, + "condition": {"matchesStorageClass": ["STANDARD"]}, + }, + {"action": {"type": "Delete"}, "condition": {"age": 365}}, + ], + ) + ] + + check = cloudstorage_bucket_lifecycle_management_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Bucket {cloudstorage_client.buckets[0].name} has lifecycle management enabled with 2 valid rule(s)." + ) + assert result[0].resource_id == "transition-delete" + assert result[0].resource_name == "transition-delete" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_bucket_with_invalid_lifecycle_rules(self): + cloudstorage_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_client", + new=cloudstorage_client, + ), + ): + from prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_lifecycle_management_enabled.cloudstorage_bucket_lifecycle_management_enabled import ( + cloudstorage_bucket_lifecycle_management_enabled, + ) + from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import ( + Bucket, + ) + + cloudstorage_client.project_ids = [GCP_PROJECT_ID] + cloudstorage_client.region = GCP_US_CENTER1_LOCATION + + cloudstorage_client.buckets = [ + Bucket( + name="invalid-rules", + id="invalid-rules", + region=GCP_US_CENTER1_LOCATION, + uniform_bucket_level_access=True, + public=False, + retention_policy=None, + project_id=GCP_PROJECT_ID, + lifecycle_rules=[ + {"action": {}, "condition": {"age": 30}}, + {"action": {"type": "Delete"}, "condition": {}}, + ], + ) + ] + + check = cloudstorage_bucket_lifecycle_management_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Bucket {cloudstorage_client.buckets[0].name} has lifecycle rules configured but none are valid." + ) + assert result[0].resource_id == "invalid-rules" + assert result[0].resource_name == "invalid-rules" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID From a47f6444f8220822a2fca3e7b4219a18c6553118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:45:38 +0200 Subject: [PATCH 05/11] chore(github): improve conflicts checker action (#8980) --- .github/workflows/pr-conflict-checker.yml | 156 ++++++++-------------- 1 file changed, 52 insertions(+), 104 deletions(-) diff --git a/.github/workflows/pr-conflict-checker.yml b/.github/workflows/pr-conflict-checker.yml index 77280d5136..3761d252a3 100644 --- a/.github/workflows/pr-conflict-checker.yml +++ b/.github/workflows/pr-conflict-checker.yml @@ -1,42 +1,40 @@ -name: Prowler - PR Conflict Checker +name: 'Tools: PR Conflict Checker' on: - pull_request: + pull_request_target: types: - - opened - - synchronize - - reopened + - 'opened' + - 'synchronize' + - 'reopened' branches: - - "master" - - "v5.*" - # Leaving this commented until we find a way to run it for forks but in Prowler's context - # pull_request_target: - # types: - # - opened - # - synchronize - # - reopened - # branches: - # - "master" - # - "v5.*" + - 'master' + - 'v5.*' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: - conflict-checker: + check-conflicts: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read pull-requests: write issues: write steps: - - name: Checkout repository + - name: Checkout PR head uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 - name: Get changed files id: changed-files uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 with: - files: | - ** + files: '**' - name: Check for conflict markers id: conflict-check @@ -51,10 +49,10 @@ jobs: if [ -f "$file" ]; then echo "Checking file: $file" - # Look for conflict markers - if grep -l "^<<<<<<<\|^=======\|^>>>>>>>" "$file" 2>/dev/null; then + # Look for conflict markers (more precise regex) + if grep -qE '^(<<<<<<<|=======|>>>>>>>)' "$file" 2>/dev/null; then echo "Conflict markers found in: $file" - CONFLICT_FILES="$CONFLICT_FILES$file " + CONFLICT_FILES="${CONFLICT_FILES}- \`${file}\`"$'\n' HAS_CONFLICTS=true fi fi @@ -62,114 +60,64 @@ jobs: if [ "$HAS_CONFLICTS" = true ]; then echo "has_conflicts=true" >> $GITHUB_OUTPUT - echo "conflict_files=$CONFLICT_FILES" >> $GITHUB_OUTPUT - echo "Conflict markers detected in files: $CONFLICT_FILES" + { + echo "conflict_files<> $GITHUB_OUTPUT + echo "Conflict markers detected" else echo "has_conflicts=false" >> $GITHUB_OUTPUT echo "No conflict markers found in changed files" fi - - name: Add conflict label - if: steps.conflict-check.outputs.has_conflicts == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - github-token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - script: | - const { data: labels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); + - name: Manage conflict label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HAS_CONFLICTS: ${{ steps.conflict-check.outputs.has_conflicts }} + run: | + LABEL_NAME="has-conflicts" - const hasConflictLabel = labels.some(label => label.name === 'has-conflicts'); + # Add or remove label based on conflict status + if [ "$HAS_CONFLICTS" = "true" ]; then + echo "Adding conflict label to PR #${PR_NUMBER}..." + gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME" --repo ${{ github.repository }} || true + else + echo "Removing conflict label from PR #${PR_NUMBER}..." + gh pr edit "$PR_NUMBER" --remove-label "$LABEL_NAME" --repo ${{ github.repository }} || true + fi - if (!hasConflictLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: ['has-conflicts'] - }); - console.log('Added has-conflicts label'); - } else { - console.log('has-conflicts label already exists'); - } - - - name: Remove conflict label - if: steps.conflict-check.outputs.has_conflicts == 'false' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - github-token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - script: | - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: 'has-conflicts' - }); - console.log('Removed has-conflicts label'); - } catch (error) { - if (error.status === 404) { - console.log('has-conflicts label was not present'); - } else { - throw error; - } - } - - - name: Find existing conflict comment - if: steps.conflict-check.outputs.has_conflicts == 'true' + - name: Find existing comment uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} comment-author: 'github-actions[bot]' - body-regex: '(⚠️ \*\*Conflict Markers Detected\*\*|✅ \*\*Conflict Markers Resolved\*\*)' + body-includes: '' - - name: Create or update conflict comment - if: steps.conflict-check.outputs.has_conflicts == 'true' + - name: Create or update comment uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 with: comment-id: ${{ steps.find-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} edit-mode: replace body: | - ⚠️ **Conflict Markers Detected** + + ${{ steps.conflict-check.outputs.has_conflicts == 'true' && '⚠️ **Conflict Markers Detected**' || '✅ **Conflict Markers Resolved**' }} - This pull request contains unresolved conflict markers in the following files: - ``` - ${{ steps.conflict-check.outputs.conflict_files }} - ``` + ${{ steps.conflict-check.outputs.has_conflicts == 'true' && format('This pull request contains unresolved conflict markers in the following files: + + {0} Please resolve these conflicts by: 1. Locating the conflict markers: `<<<<<<<`, `=======`, and `>>>>>>>` 2. Manually editing the files to resolve the conflicts 3. Removing all conflict markers - 4. Committing and pushing the changes - - - name: Find existing conflict comment when resolved - if: steps.conflict-check.outputs.has_conflicts == 'false' - uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 - id: find-resolved-comment - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-regex: '(⚠️ \*\*Conflict Markers Detected\*\*|✅ \*\*Conflict Markers Resolved\*\*)' - - - name: Update comment when conflicts resolved - if: steps.conflict-check.outputs.has_conflicts == 'false' && steps.find-resolved-comment.outputs.comment-id != '' - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 - with: - comment-id: ${{ steps.find-resolved-comment.outputs.comment-id }} - issue-number: ${{ github.event.pull_request.number }} - edit-mode: replace - body: | - ✅ **Conflict Markers Resolved** - - All conflict markers have been successfully resolved in this pull request. + 4. Committing and pushing the changes', steps.conflict-check.outputs.conflict_files) || 'All conflict markers have been successfully resolved in this pull request.' }} - name: Fail workflow if conflicts detected if: steps.conflict-check.outputs.has_conflicts == 'true' run: | - echo "::error::Workflow failed due to conflict markers in files: ${{ steps.conflict-check.outputs.conflict_files }}" + echo "::error::Workflow failed due to conflict markers detected in the PR" exit 1 From f5cccecac67260e293ee20a79fcb7519fddb4f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:02:51 +0200 Subject: [PATCH 06/11] chore(github): improve prepare release action (#8981) --- ...reparation.yml => prowler-prepare-release.yml} | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) rename .github/workflows/{prowler-release-preparation.yml => prowler-prepare-release.yml} (98%) diff --git a/.github/workflows/prowler-release-preparation.yml b/.github/workflows/prowler-prepare-release.yml similarity index 98% rename from .github/workflows/prowler-release-preparation.yml rename to .github/workflows/prowler-prepare-release.yml index 7ecd937554..33f554d898 100644 --- a/.github/workflows/prowler-release-preparation.yml +++ b/.github/workflows/prowler-prepare-release.yml @@ -1,6 +1,6 @@ -name: Prowler - Release Preparation +name: 'Tools: Prepare Release' -run-name: Prowler Release Preparation for ${{ inputs.prowler_version }} +run-name: 'Prepare Release for Prowler ${{ inputs.prowler_version }}' on: workflow_dispatch: @@ -10,18 +10,23 @@ on: required: true type: string +concurrency: + group: ${{ github.workflow }}-${{ inputs.prowler_version }} + cancel-in-progress: false + env: - PROWLER_VERSION: ${{ github.event.inputs.prowler_version }} + PROWLER_VERSION: ${{ inputs.prowler_version }} jobs: prepare-release: if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write pull-requests: write steps: - - name: Checkout code + - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 @@ -34,7 +39,7 @@ jobs: - name: Install Poetry run: | - python3 -m pip install --user poetry + python3 -m pip install --user poetry==2.1.1 echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Configure Git From 52ed92ac6a692640b7d2be03033a1bd35380adb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:17:22 +0200 Subject: [PATCH 07/11] chore(github): improve check changelog action (#8983) --- .github/workflows/pr-check-changelog.yml | 103 ++++++++++++++++++ .../pull-request-check-changelog.yml | 77 ------------- 2 files changed, 103 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/pr-check-changelog.yml delete mode 100644 .github/workflows/pull-request-check-changelog.yml diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml new file mode 100644 index 0000000000..f8de212e2a --- /dev/null +++ b/.github/workflows/pr-check-changelog.yml @@ -0,0 +1,103 @@ +name: 'Tools: Check Changelog' + +on: + pull_request: + types: + - 'opened' + - 'synchronize' + - 'reopened' + - 'labeled' + - 'unlabeled' + branches: + - 'master' + - 'v5.*' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-changelog: + if: contains(github.event.pull_request.labels.*.name, 'no-changelog') == false + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + env: + MONITORED_FOLDERS: 'api ui prowler mcp_server' + + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + with: + files: | + api/** + ui/** + prowler/** + mcp_server/** + + - name: Check for folder changes and changelog presence + id: check-folders + run: | + missing_changelogs="" + + # Check api folder + if [[ "${{ steps.changed-files.outputs.any_changed }}" == "true" ]]; then + for folder in $MONITORED_FOLDERS; do + # Get files changed in this folder + changed_in_folder=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep "^${folder}/" || true) + + if [ -n "$changed_in_folder" ]; then + echo "Detected changes in ${folder}/" + + # Check if CHANGELOG.md was updated + if ! echo "$changed_in_folder" | grep -q "^${folder}/CHANGELOG.md$"; then + echo "No changelog update found for ${folder}/" + missing_changelogs="${missing_changelogs}- \`${folder}\`"$'\n' + fi + fi + done + fi + + { + echo "missing_changelogs<> $GITHUB_OUTPUT + + - name: Find existing changelog comment + if: github.event.pull_request.head.repo.full_name == github.repository + id: find-comment + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: Update PR comment with changelog status + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + + ${{ steps.check-folders.outputs.missing_changelogs != '' && format('⚠️ **Changes detected in the following folders without a corresponding update to the `CHANGELOG.md`:** + + {0} + + Please add an entry to the corresponding `CHANGELOG.md` file to maintain a clear history of changes.', steps.check-folders.outputs.missing_changelogs) || '✅ All necessary `CHANGELOG.md` files have been updated.' }} + + - name: Fail if changelog is missing + if: steps.check-folders.outputs.missing_changelogs != '' + run: | + echo "::error::Missing changelog updates in some folders" + exit 1 diff --git a/.github/workflows/pull-request-check-changelog.yml b/.github/workflows/pull-request-check-changelog.yml deleted file mode 100644 index 3b96e6d499..0000000000 --- a/.github/workflows/pull-request-check-changelog.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Prowler - Check Changelog - -on: - pull_request: - types: [opened, synchronize, reopened, labeled, unlabeled] - -jobs: - check-changelog: - if: contains(github.event.pull_request.labels.*.name, 'no-changelog') == false - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - pull-requests: write - env: - MONITORED_FOLDERS: "api ui prowler mcp_server" - - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 - - - name: Get list of changed files - id: changed_files - run: | - git fetch origin ${{ github.base_ref }} - git diff --name-only origin/${{ github.base_ref }}...HEAD > changed_files.txt - cat changed_files.txt - - - name: Check for folder changes and changelog presence - id: check_folders - run: | - missing_changelogs="" - - for folder in $MONITORED_FOLDERS; do - if grep -q "^${folder}/" changed_files.txt; then - echo "Detected changes in ${folder}/" - if ! grep -q "^${folder}/CHANGELOG.md$" changed_files.txt; then - echo "No changelog update found for ${folder}/" - missing_changelogs="${missing_changelogs}- \`${folder}\`\n" - fi - fi - done - - echo "missing_changelogs<> $GITHUB_OUTPUT - echo -e "${missing_changelogs}" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Find existing changelog comment - if: github.event.pull_request.head.repo.full_name == github.repository - id: find_comment - uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad #v4.0.0 - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: '' - - - name: Update PR comment with changelog status - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 - with: - issue-number: ${{ github.event.pull_request.number }} - comment-id: ${{ steps.find_comment.outputs.comment-id }} - edit-mode: replace - body: | - - ${{ steps.check_folders.outputs.missing_changelogs != '' && format('⚠️ **Changes detected in the following folders without a corresponding update to the `CHANGELOG.md`:** - - {0} - - Please add an entry to the corresponding `CHANGELOG.md` file to maintain a clear history of changes.', steps.check_folders.outputs.missing_changelogs) || '✅ All necessary `CHANGELOG.md` files have been updated. Great job! 🎉' }} - - - name: Fail if changelog is missing - if: steps.check_folders.outputs.missing_changelogs != '' - run: | - echo "ERROR: Missing changelog updates in some folders." - exit 1 From 2039a5005cf843f9b733fa6db7fb88667a62eeb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:29:22 +0200 Subject: [PATCH 08/11] chore(github): rename prepare release action (#8985) --- ...repare-release.yml => prepare-release.yml} | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) rename .github/workflows/{prowler-prepare-release.yml => prepare-release.yml} (95%) diff --git a/.github/workflows/prowler-prepare-release.yml b/.github/workflows/prepare-release.yml similarity index 95% rename from .github/workflows/prowler-prepare-release.yml rename to .github/workflows/prepare-release.yml index 33f554d898..7aeb7a90e6 100644 --- a/.github/workflows/prowler-prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -19,7 +19,7 @@ env: jobs: prepare-release: - if: github.repository == 'prowler-cloud/prowler' + if: github.event_name == 'workflow_dispatch' && github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -44,10 +44,10 @@ jobs: - name: Configure Git run: | - git config --global user.name "prowler-bot" - git config --global user.email "179230569+prowler-bot@users.noreply.github.com" + git config --global user.name 'prowler-bot' + git config --global user.email '179230569+prowler-bot@users.noreply.github.com' - - name: Parse version and determine branch + - name: Parse version and read changelogs run: | # Validate version format (reusing pattern from sdk-bump-version.yml) if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then @@ -124,7 +124,7 @@ jobs: exit 1 fi - - name: Extract changelog entries + - name: Extract and combine changelog entries run: | set -e @@ -250,7 +250,7 @@ jobs: echo "Combined changelog preview:" cat combined_changelog.md - - name: Checkout existing branch for patch release + - name: Checkout release branch for patch release if: ${{ env.PATCH_VERSION != '0' }} run: | echo "Patch release detected, checking out existing branch $BRANCH_NAME..." @@ -265,7 +265,7 @@ jobs: exit 1 fi - - name: Verify version in pyproject.toml + - name: Verify SDK version in pyproject.toml run: | CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/' | tr -d '[:space:]') PROWLER_VERSION_TRIMMED=$(echo "$PROWLER_VERSION" | tr -d '[:space:]') @@ -275,7 +275,7 @@ jobs: fi echo "✓ pyproject.toml version: $CURRENT_VERSION" - - name: Verify version in prowler/config/config.py + - name: Verify SDK version in prowler/config/config.py run: | CURRENT_VERSION=$(grep '^prowler_version = ' prowler/config/config.py | sed -E 's/prowler_version = "([^"]+)"/\1/' | tr -d '[:space:]') PROWLER_VERSION_TRIMMED=$(echo "$PROWLER_VERSION" | tr -d '[:space:]') @@ -285,7 +285,7 @@ jobs: fi echo "✓ prowler/config/config.py version: $CURRENT_VERSION" - - name: Verify version in api/pyproject.toml + - name: Verify API version in api/pyproject.toml if: ${{ env.HAS_API_CHANGES == 'true' }} run: | CURRENT_API_VERSION=$(grep '^version = ' api/pyproject.toml | sed -E 's/version = "([^"]+)"/\1/' | tr -d '[:space:]') @@ -296,7 +296,7 @@ jobs: fi echo "✓ api/pyproject.toml version: $CURRENT_API_VERSION" - - name: Verify prowler dependency in api/pyproject.toml + - name: Verify API prowler dependency in api/pyproject.toml if: ${{ env.PATCH_VERSION != '0' && env.HAS_API_CHANGES == 'true' }} run: | CURRENT_PROWLER_REF=$(grep 'prowler @ git+https://github.com/prowler-cloud/prowler.git@' api/pyproject.toml | sed -E 's/.*@([^"]+)".*/\1/' | tr -d '[:space:]') @@ -307,7 +307,7 @@ jobs: fi echo "✓ api/pyproject.toml prowler dependency: $CURRENT_PROWLER_REF" - - name: Verify version in api/src/backend/api/v1/views.py + - name: Verify API version in api/src/backend/api/v1/views.py if: ${{ env.HAS_API_CHANGES == 'true' }} run: | CURRENT_API_VERSION=$(grep 'spectacular_settings.VERSION = ' api/src/backend/api/v1/views.py | sed -E 's/.*spectacular_settings.VERSION = "([^"]+)".*/\1/' | tr -d '[:space:]') @@ -318,7 +318,7 @@ jobs: fi echo "✓ api/src/backend/api/v1/views.py version: $CURRENT_API_VERSION" - - name: Checkout existing release branch for minor release + - name: Checkout release branch for minor release if: ${{ env.PATCH_VERSION == '0' }} run: | echo "Minor release detected (patch = 0), checking out existing branch $BRANCH_NAME..." @@ -330,7 +330,7 @@ jobs: exit 1 fi - - name: Prepare prowler dependency update for minor release + - name: Update API prowler dependency for minor release if: ${{ env.PATCH_VERSION == '0' }} run: | CURRENT_PROWLER_REF=$(grep 'prowler @ git+https://github.com/prowler-cloud/prowler.git@' api/pyproject.toml | sed -E 's/.*@([^"]+)".*/\1/' | tr -d '[:space:]') @@ -367,7 +367,7 @@ jobs: echo "✓ Prepared prowler dependency update to: $UPDATED_PROWLER_REF" - - name: Create Pull Request against release branch + - name: Create PR for API dependency update if: ${{ env.PATCH_VERSION == '0' }} uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 with: From b7ce9ae5f31ce4056c61cd45d2c35804bc717109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:35:38 +0200 Subject: [PATCH 09/11] chore(github): improve mcp container action (#8986) --- .../workflows/mcp-container-build-push.yml | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/.github/workflows/mcp-container-build-push.yml b/.github/workflows/mcp-container-build-push.yml index fa4f5be578..aecec30592 100644 --- a/.github/workflows/mcp-container-build-push.yml +++ b/.github/workflows/mcp-container-build-push.yml @@ -3,21 +3,13 @@ name: 'MCP: Container Build and Push' on: push: branches: - - "master" + - 'master' paths: - - "mcp_server/**" - - ".github/workflows/mcp-container-build-push.yml" - - # Uncomment to test this workflow on PRs - # pull_request: - # branches: - # - "master" - # paths: - # - "mcp_server/**" - # - ".github/workflows/mcp-container-build-push.yml" - + - 'mcp_server/**' + - '.github/workflows/mcp-container-build-push.yml' release: - types: [published] + types: + - 'published' permissions: contents: read @@ -41,6 +33,7 @@ jobs: setup: if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest + timeout-minutes: 5 outputs: short-sha: ${{ steps.set-short-sha.outputs.short-sha }} steps: @@ -51,8 +44,12 @@ jobs: container-build-push: needs: setup runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Login to DockerHub @@ -64,7 +61,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - - name: Build and push container (latest) + - name: Build and push MCP container (latest) if: github.event_name == 'push' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: @@ -83,7 +80,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Build and push container (release) + - name: Build and push MCP container (release) if: github.event_name == 'release' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: @@ -103,7 +100,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Trigger deployment + - name: Trigger MCP deployment if: github.event_name == 'push' uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 with: From fdf45ea777a71df8a6c172cf07dfbf9992029a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:52:00 +0200 Subject: [PATCH 10/11] chore(github): improve pr merged action (#8987) --- ...{pull-request-merged.yml => pr-merged.yml} | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) rename .github/workflows/{pull-request-merged.yml => pr-merged.yml} (52%) diff --git a/.github/workflows/pull-request-merged.yml b/.github/workflows/pr-merged.yml similarity index 52% rename from .github/workflows/pull-request-merged.yml rename to .github/workflows/pr-merged.yml index 4b5a93aabd..da8e359adb 100644 --- a/.github/workflows/pull-request-merged.yml +++ b/.github/workflows/pr-merged.yml @@ -1,27 +1,31 @@ -name: Prowler - Merged Pull Request +name: 'Tools: PR Merged' on: pull_request_target: - branches: ['master'] - types: ['closed'] + branches: + - 'master' + types: + - 'closed' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false jobs: trigger-cloud-pull-request: - name: Trigger Cloud Pull Request if: github.event.pull_request.merged == true && github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - ref: ${{ github.event.pull_request.merge_commit_sha }} - - - name: Set short git commit SHA + - name: Calculate short commit SHA id: vars run: | - shortSha=$(git rev-parse --short ${{ github.event.pull_request.merge_commit_sha }}) - echo "SHORT_SHA=${shortSha}" >> $GITHUB_ENV + SHORT_SHA="${{ github.event.pull_request.merge_commit_sha }}" + echo "SHORT_SHA=${SHORT_SHA::7}" >> $GITHUB_ENV - - name: Trigger pull request + - name: Trigger Cloud repository pull request uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -31,8 +35,13 @@ jobs: { "PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}", "PROWLER_COMMIT_SHORT_SHA": "${{ env.SHORT_SHA }}", + "PROWLER_PR_NUMBER": "${{ github.event.pull_request.number }}", "PROWLER_PR_TITLE": ${{ toJson(github.event.pull_request.title) }}, "PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }}, "PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }}, - "PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }} + "PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }}, + "PROWLER_PR_MERGED_BY": "${{ github.event.pull_request.merged_by.login }}", + "PROWLER_PR_AUTHOR": "${{ github.event.pull_request.user.login }}", + "PROWLER_PR_BASE_BRANCH": "${{ github.event.pull_request.base.ref }}", + "PROWLER_PR_HEAD_BRANCH": "${{ github.event.pull_request.head.ref }}" } From 6184de52d9323d4c10e12441243a09e09ca57e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 22 Oct 2025 18:05:31 +0200 Subject: [PATCH 11/11] chore(github): fix pr merged action (#8988) --- .github/workflows/pr-merged.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pr-merged.yml b/.github/workflows/pr-merged.yml index da8e359adb..d8255026e6 100644 --- a/.github/workflows/pr-merged.yml +++ b/.github/workflows/pr-merged.yml @@ -41,7 +41,6 @@ jobs: "PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }}, "PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }}, "PROWLER_PR_MERGED_BY": "${{ github.event.pull_request.merged_by.login }}", - "PROWLER_PR_AUTHOR": "${{ github.event.pull_request.user.login }}", "PROWLER_PR_BASE_BRANCH": "${{ github.event.pull_request.base.ref }}", "PROWLER_PR_HEAD_BRANCH": "${{ github.event.pull_request.head.ref }}" }